diff --git a/.changeset/easy-pens-judge.md b/.changeset/easy-pens-judge.md
new file mode 100644
index 0000000000..fe9dde7665
--- /dev/null
+++ b/.changeset/easy-pens-judge.md
@@ -0,0 +1,7 @@
+---
+'@backstage/ui': patch
+---
+
+Made `SearchAutocomplete` background-aware. The input now adapts its background color based on its parent container's background level.
+
+**Affected components:** SearchAutocomplete
diff --git a/.changeset/fuzzy-mangos-design.md b/.changeset/fuzzy-mangos-design.md
new file mode 100644
index 0000000000..be4a33566f
--- /dev/null
+++ b/.changeset/fuzzy-mangos-design.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-techdocs': patch
+---
+
+Migrated the TechDocs alpha plugin pages to use BUI components. The index page and reader page now use BUI `Header` and `Container` instead of legacy `Page`/`Content`/`ContentHeader` wrappers. Added a `SupportButton` as a plugin header action. Changed plugin title to "Documentation" and icon to `RiArticleLine`.
diff --git a/.changeset/happy-masks-allow.md b/.changeset/happy-masks-allow.md
new file mode 100644
index 0000000000..56d9b04e91
--- /dev/null
+++ b/.changeset/happy-masks-allow.md
@@ -0,0 +1,7 @@
+---
+'@backstage/ui': patch
+---
+
+Added `aria-hidden` to the `PluginHeader` icon to prevent screen readers from announcing decorative plugin icons.
+
+**Affected components:** PluginHeader
diff --git a/.patches/pr-33437.txt b/.patches/pr-33437.txt
new file mode 100644
index 0000000000..e43813042b
--- /dev/null
+++ b/.patches/pr-33437.txt
@@ -0,0 +1 @@
+Migrates TechDocs alpha plugin pages to BUI header system, fixing double scrollbar issue with the new plugin header.
\ No newline at end of file
diff --git a/packages/ui/report.api.md b/packages/ui/report.api.md
index 364592a6ff..511074f8bb 100644
--- a/packages/ui/report.api.md
+++ b/packages/ui/report.api.md
@@ -2159,6 +2159,7 @@ export const SearchAutocompleteDefinition: {
readonly styles: {
readonly [key: string]: string;
};
+ readonly bg: 'consumer';
readonly classNames: {
readonly root: 'bui-SearchAutocomplete';
readonly searchField: 'bui-SearchAutocompleteSearchField';
diff --git a/packages/ui/src/components/PluginHeader/PluginHeader.tsx b/packages/ui/src/components/PluginHeader/PluginHeader.tsx
index d153a3af18..2414e22975 100644
--- a/packages/ui/src/components/PluginHeader/PluginHeader.tsx
+++ b/packages/ui/src/components/PluginHeader/PluginHeader.tsx
@@ -125,7 +125,9 @@ export const PluginHeader = (props: PluginHeaderProps) => {
const titleContent = (
<>
-
{icon || }
+
+ {icon || }
+
{title || 'Your plugin'}
>
);
diff --git a/packages/ui/src/components/SearchAutocomplete/SearchAutocomplete.module.css b/packages/ui/src/components/SearchAutocomplete/SearchAutocomplete.module.css
index ca6de73402..5eccb188ed 100644
--- a/packages/ui/src/components/SearchAutocomplete/SearchAutocomplete.module.css
+++ b/packages/ui/src/components/SearchAutocomplete/SearchAutocomplete.module.css
@@ -38,6 +38,18 @@
--search-autocomplete-item-height: 2.5rem;
height: 2.5rem;
}
+
+ &[data-on-bg='neutral-1'] {
+ background-color: var(--bui-bg-neutral-2);
+ }
+
+ &[data-on-bg='neutral-2'] {
+ background-color: var(--bui-bg-neutral-3);
+ }
+
+ &[data-on-bg='neutral-3'] {
+ background-color: var(--bui-bg-neutral-4);
+ }
}
.bui-SearchAutocompleteSearchField {
diff --git a/packages/ui/src/components/SearchAutocomplete/SearchAutocomplete.tsx b/packages/ui/src/components/SearchAutocomplete/SearchAutocomplete.tsx
index d8c4640e26..5fd63661f4 100644
--- a/packages/ui/src/components/SearchAutocomplete/SearchAutocomplete.tsx
+++ b/packages/ui/src/components/SearchAutocomplete/SearchAutocomplete.tsx
@@ -124,7 +124,7 @@ export function SearchAutocomplete(props: SearchAutocompleteProps) {
diff --git a/packages/ui/src/components/SearchAutocomplete/definition.ts b/packages/ui/src/components/SearchAutocomplete/definition.ts
index 4a2e47881d..d6204dad6a 100644
--- a/packages/ui/src/components/SearchAutocomplete/definition.ts
+++ b/packages/ui/src/components/SearchAutocomplete/definition.ts
@@ -28,6 +28,7 @@ import styles from './SearchAutocomplete.module.css';
export const SearchAutocompleteDefinition =
defineComponent
()({
styles,
+ bg: 'consumer',
classNames: {
root: 'bui-SearchAutocomplete',
searchField: 'bui-SearchAutocompleteSearchField',
diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json
index 4b40c3f72d..b2de90dc85 100644
--- a/plugins/techdocs/package.json
+++ b/plugins/techdocs/package.json
@@ -75,11 +75,13 @@
"@backstage/plugin-techdocs-common": "workspace:^",
"@backstage/plugin-techdocs-react": "workspace:^",
"@backstage/theme": "workspace:^",
+ "@backstage/ui": "workspace:^",
"@material-ui/core": "^4.12.2",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.61",
"@material-ui/styles": "^4.10.0",
"@microsoft/fetch-event-source": "^2.0.1",
+ "@remixicon/react": "^4.6.0",
"dompurify": "^3.3.2",
"git-url-parse": "^15.0.0",
"lodash": "^4.17.21",
diff --git a/plugins/techdocs/report-alpha.api.md b/plugins/techdocs/report-alpha.api.md
index aa14098288..47c10f5441 100644
--- a/plugins/techdocs/report-alpha.api.md
+++ b/plugins/techdocs/report-alpha.api.md
@@ -448,6 +448,19 @@ const _default: OverridableFrontendPlugin<
noHeader?: boolean;
};
}>;
+ 'plugin-header-action:techdocs': OverridableExtensionDefinition<{
+ kind: 'plugin-header-action';
+ name: undefined;
+ config: {};
+ configInput: {};
+ output: ExtensionDataRef;
+ inputs: {};
+ params: (params: {
+ loader: () => Promise;
+ }) => ExtensionBlueprintParams<{
+ loader: () => Promise;
+ }>;
+ }>;
'search-result-list-item:techdocs': OverridableExtensionDefinition<{
config: {
title: string | undefined;
diff --git a/plugins/techdocs/src/alpha/components/TechDocsIndexPageContent.test.tsx b/plugins/techdocs/src/alpha/components/TechDocsIndexPageContent.test.tsx
new file mode 100644
index 0000000000..579d624a7f
--- /dev/null
+++ b/plugins/techdocs/src/alpha/components/TechDocsIndexPageContent.test.tsx
@@ -0,0 +1,75 @@
+/*
+ * Copyright 2026 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 { configApiRef, storageApiRef } from '@backstage/core-plugin-api';
+import {
+ MockStarredEntitiesApi,
+ catalogApiRef,
+ starredEntitiesApiRef,
+} from '@backstage/plugin-catalog-react';
+import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils';
+import {
+ TestApiRegistry,
+ mockApis,
+ renderInTestApp,
+} from '@backstage/test-utils';
+import { screen } from '@testing-library/react';
+import { rootDocsRouteRef } from '../../routes';
+import { TechDocsIndexPageContent } from './TechDocsIndexPageContent';
+
+const mockCatalogApi = catalogApiMock({
+ entities: [
+ {
+ apiVersion: 'version',
+ kind: 'User',
+ metadata: {
+ name: 'owned',
+ namespace: 'default',
+ },
+ },
+ ],
+});
+
+describe('', () => {
+ const configApi = mockApis.config({
+ data: { organization: { name: 'My Company' } },
+ });
+
+ const apiRegistry = TestApiRegistry.from(
+ [catalogApiRef, mockCatalogApi],
+ [configApiRef, configApi],
+ [storageApiRef, mockApis.storage()],
+ [starredEntitiesApiRef, new MockStarredEntitiesApi()],
+ );
+
+ it('should render the filter layout with pickers', async () => {
+ await renderInTestApp(
+
+
+ ,
+ {
+ mountedRoutes: {
+ '/docs/:namespace/:kind/:name/*': rootDocsRouteRef,
+ },
+ },
+ );
+
+ // The UserListPicker renders filter options
+ expect(await screen.findByTestId('user-picker-owned')).toBeInTheDocument();
+ expect(screen.getByTestId('user-picker-all')).toBeInTheDocument();
+ });
+});
diff --git a/plugins/techdocs/src/alpha/components/TechDocsIndexPageContent.tsx b/plugins/techdocs/src/alpha/components/TechDocsIndexPageContent.tsx
new file mode 100644
index 0000000000..16b866b0c0
--- /dev/null
+++ b/plugins/techdocs/src/alpha/components/TechDocsIndexPageContent.tsx
@@ -0,0 +1,60 @@
+/*
+ * Copyright 2026 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 { Container } from '@backstage/ui';
+import {
+ CatalogFilterLayout,
+ EntityListProvider,
+ EntityOwnerPicker,
+ EntityTagPicker,
+ UserListPicker,
+} from '@backstage/plugin-catalog-react';
+import { TechDocsPicker } from '../../home/components/TechDocsPicker';
+import { EntityListDocsTable } from '../../home/components/Tables';
+import type { TechDocsIndexPageProps } from '../../home/components/TechDocsIndexPage';
+
+export const TechDocsIndexPageContent = (props: TechDocsIndexPageProps) => {
+ const {
+ initialFilter = 'owned',
+ columns,
+ actions,
+ ownerPickerMode,
+ pagination,
+ options,
+ } = props;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/plugins/techdocs/src/alpha/components/TechDocsReaderContent.test.tsx b/plugins/techdocs/src/alpha/components/TechDocsReaderContent.test.tsx
new file mode 100644
index 0000000000..cb443b3533
--- /dev/null
+++ b/plugins/techdocs/src/alpha/components/TechDocsReaderContent.test.tsx
@@ -0,0 +1,238 @@
+/*
+ * Copyright 2026 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 { ReactNode } from 'react';
+import { waitFor } from '@testing-library/react';
+
+import {
+ CompoundEntityRef,
+ getCompoundEntityRef,
+} from '@backstage/catalog-model';
+import {
+ techdocsApiRef,
+ TechDocsReaderPageProvider,
+ useShadowRootElements,
+} from '@backstage/plugin-techdocs-react';
+import {
+ renderInTestApp,
+ TestApiProvider,
+ mockApis,
+} from '@backstage/test-utils';
+import { configApiRef } from '@backstage/core-plugin-api';
+
+const useTechDocsReaderDom = jest.fn();
+jest.mock('../../reader/components/TechDocsReaderPageContent/dom', () => ({
+ ...jest.requireActual(
+ '../../reader/components/TechDocsReaderPageContent/dom',
+ ),
+ useTechDocsReaderDom: (...args: any[]) => useTechDocsReaderDom(...args),
+}));
+const useReaderState = jest.fn();
+jest.mock('../../reader/components/useReaderState', () => ({
+ ...jest.requireActual('../../reader/components/useReaderState'),
+ useReaderState: (...args: any[]) => useReaderState(...args),
+}));
+const useShadowDomStylesLoading = jest.fn().mockReturnValue(false);
+jest.mock('@backstage/plugin-techdocs-react', () => ({
+ ...jest.requireActual('@backstage/plugin-techdocs-react'),
+ useShadowDomStylesLoading: (...args: any[]) =>
+ useShadowDomStylesLoading(...args),
+ useShadowRootElements: jest.fn(),
+}));
+
+import { TechDocsReaderContent } from './TechDocsReaderContent';
+
+const mockEntityMetadata = {
+ locationMetadata: {
+ type: 'github',
+ target: 'https://example.com/',
+ },
+ apiVersion: 'v1',
+ kind: 'test',
+ metadata: {
+ name: 'test-name',
+ namespace: 'test-namespace',
+ },
+ spec: {
+ owner: 'test',
+ },
+};
+
+const mockTechDocsMetadata = {
+ site_name: 'test-site-name',
+ site_description: 'test-site-desc',
+};
+
+const getEntityMetadata = jest.fn();
+const getTechDocsMetadata = jest.fn();
+
+const techdocsApiMock = {
+ getEntityMetadata,
+ getTechDocsMetadata,
+};
+
+const Wrapper = ({
+ entityRef = {
+ kind: mockEntityMetadata.kind,
+ name: mockEntityMetadata.metadata.name,
+ namespace: mockEntityMetadata.metadata.namespace!!,
+ },
+ children,
+}: {
+ entityRef?: CompoundEntityRef;
+ children: ReactNode;
+}) => (
+
+
+ {children}
+
+
+);
+
+describe('', () => {
+ const useShadowRootElementsMock = useShadowRootElements as jest.Mock;
+
+ beforeEach(() => {
+ useShadowRootElementsMock.mockReturnValue([]);
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should render techdocs content', async () => {
+ getEntityMetadata.mockResolvedValue(mockEntityMetadata);
+ getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata);
+ useTechDocsReaderDom.mockReturnValue(document.createElement('html'));
+ useReaderState.mockReturnValue({ state: 'cached' });
+
+ const rendered = await renderInTestApp(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(
+ rendered.getByTestId('techdocs-native-shadowroot'),
+ ).toBeInTheDocument();
+ });
+ });
+
+ it('should render with default path', async () => {
+ getEntityMetadata.mockResolvedValue(mockEntityMetadata);
+ getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata);
+ useTechDocsReaderDom.mockReturnValue(document.createElement('html'));
+ useReaderState.mockReturnValue({ state: 'cached' });
+
+ const defaultPath = '/some/path';
+
+ const rendered = await renderInTestApp(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(
+ rendered.getByTestId('techdocs-native-shadowroot'),
+ ).toBeInTheDocument();
+ });
+
+ const entityRef = getCompoundEntityRef(mockEntityMetadata);
+ expect(useTechDocsReaderDom).toHaveBeenCalledWith(entityRef, defaultPath);
+ });
+
+ it('should not render content if entity metadata is missing', async () => {
+ getEntityMetadata.mockResolvedValue(undefined);
+ useTechDocsReaderDom.mockReturnValue(document.createElement('html'));
+ useReaderState.mockReturnValue({ state: 'cached' });
+
+ await expect(
+ renderInTestApp(
+
+
+ ,
+ ),
+ ).rejects.toThrow('Reached NotFound Page');
+
+ const shadowRoot = document.querySelector(
+ '[data-testid="techdocs-native-shadowroot"]',
+ );
+ expect(shadowRoot).not.toBeInTheDocument();
+ });
+
+ it('should render 404 if there is no dom and reader state is not found', async () => {
+ getEntityMetadata.mockResolvedValue(mockEntityMetadata);
+ getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata);
+ useTechDocsReaderDom.mockReturnValue(undefined);
+ useReaderState.mockReturnValue({ state: 'CONTENT_NOT_FOUND' });
+
+ const rendered = await renderInTestApp(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(
+ rendered.queryByTestId('techdocs-native-shadowroot'),
+ ).not.toBeInTheDocument();
+ expect(
+ rendered.getByText('ERROR 404: Documentation not found'),
+ ).toBeInTheDocument();
+ });
+ });
+
+ it('should render progress bar when content is loading', async () => {
+ getEntityMetadata.mockResolvedValue(mockEntityMetadata);
+ getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata);
+ useTechDocsReaderDom.mockReturnValue(document.createElement('html'));
+ useReaderState.mockReturnValue({ state: 'CHECKING' });
+
+ const rendered = await renderInTestApp(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(rendered.queryByRole('progressbar')).toBeInTheDocument();
+ });
+ });
+
+ it('should render progress bar when styles are loading', async () => {
+ getEntityMetadata.mockResolvedValue(mockEntityMetadata);
+ getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata);
+ useTechDocsReaderDom.mockReturnValue(document.createElement('html'));
+ useReaderState.mockReturnValue({ state: 'cached' });
+ useShadowDomStylesLoading.mockReturnValue(true);
+
+ const rendered = await renderInTestApp(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(rendered.queryByRole('progressbar')).toBeInTheDocument();
+ });
+ });
+});
diff --git a/plugins/techdocs/src/alpha/components/TechDocsReaderContent.tsx b/plugins/techdocs/src/alpha/components/TechDocsReaderContent.tsx
new file mode 100644
index 0000000000..fdb0be680e
--- /dev/null
+++ b/plugins/techdocs/src/alpha/components/TechDocsReaderContent.tsx
@@ -0,0 +1,56 @@
+/*
+ * Copyright 2026 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 { TechDocsShadowDom } from '@backstage/plugin-techdocs-react';
+import { Progress } from '@backstage/core-components';
+
+import { TechDocsStateIndicator } from '../../reader/components/TechDocsStateIndicator';
+import { withTechDocsReaderProvider } from '../../reader/components/TechDocsReaderProvider';
+import { TechDocsReaderPageContentAddons } from '../../reader/components/TechDocsReaderPageContent/TechDocsReaderPageContentAddons';
+import type { TechDocsReaderPageContentProps } from '../../reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent';
+import { useTechDocsReaderContentData } from '../../hooks/useTechDocsReaderContentData';
+
+export const TechDocsReaderContent = withTechDocsReaderProvider(
+ (props: TechDocsReaderPageContentProps) => {
+ const {
+ dom,
+ handleAppend,
+ isNotFound,
+ isDomReady,
+ showProgress,
+ NotFoundErrorPage,
+ } = useTechDocsReaderContentData({
+ defaultPath: props.defaultPath,
+ onReady: props.onReady,
+ });
+
+ if (isNotFound) return ;
+
+ if (!isDomReady) {
+ return ;
+ }
+
+ return (
+ <>
+
+ {showProgress && }
+
+
+
+ >
+ );
+ },
+);
diff --git a/plugins/techdocs/src/alpha/components/TechDocsReaderEntityCard.module.css b/plugins/techdocs/src/alpha/components/TechDocsReaderEntityCard.module.css
new file mode 100644
index 0000000000..14fa9082fb
--- /dev/null
+++ b/plugins/techdocs/src/alpha/components/TechDocsReaderEntityCard.module.css
@@ -0,0 +1,36 @@
+.entityCard {
+ margin-bottom: var(--bui-space-6);
+}
+
+.cardBody {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ gap: var(--bui-space-4);
+}
+
+.definitionList {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--bui-space-6);
+ margin: 0;
+ padding: 0;
+ flex: 1 1 0;
+ align-self: center;
+}
+
+.definitionGroup {
+ display: flex;
+ align-items: baseline;
+ gap: var(--bui-space-2);
+}
+
+.definitionValue {
+ margin: 0;
+}
+
+.searchWrapper {
+ flex: 1 1 200px;
+ max-width: 240px;
+}
diff --git a/plugins/techdocs/src/alpha/components/TechDocsReaderEntityCard.test.tsx b/plugins/techdocs/src/alpha/components/TechDocsReaderEntityCard.test.tsx
new file mode 100644
index 0000000000..90fcc4b753
--- /dev/null
+++ b/plugins/techdocs/src/alpha/components/TechDocsReaderEntityCard.test.tsx
@@ -0,0 +1,215 @@
+/*
+ * Copyright 2026 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 { ReactNode } from 'react';
+
+import { CompoundEntityRef, RELATION_OWNED_BY } from '@backstage/catalog-model';
+import { entityRouteRef } from '@backstage/plugin-catalog-react';
+import {
+ techdocsApiRef,
+ TechDocsReaderPageProvider,
+} from '@backstage/plugin-techdocs-react';
+import {
+ renderInTestApp,
+ TestApiProvider,
+ mockApis,
+} from '@backstage/test-utils';
+import { configApiRef } from '@backstage/core-plugin-api';
+import { searchApiRef, MockSearchApi } from '@backstage/plugin-search-react';
+
+import { TechDocsReaderEntityCard } from './TechDocsReaderEntityCard';
+
+const mockEntityMetadata = {
+ apiVersion: 'v1',
+ kind: 'Component',
+ metadata: {
+ name: 'test-component',
+ namespace: 'default',
+ title: 'Test Component Title',
+ },
+ spec: {
+ owner: 'team-a',
+ lifecycle: 'production',
+ },
+ relations: [
+ {
+ type: RELATION_OWNED_BY,
+ targetRef: 'group:default/team-a',
+ },
+ ],
+};
+
+const mockTechDocsMetadata = {
+ site_name: 'test-site-name',
+ site_description: 'test-site-desc',
+};
+
+const getEntityMetadata = jest.fn();
+const getTechDocsMetadata = jest.fn();
+
+const techdocsApiMock = {
+ getEntityMetadata,
+ getTechDocsMetadata,
+};
+
+const Wrapper = ({
+ entityRef = {
+ kind: mockEntityMetadata.kind,
+ name: mockEntityMetadata.metadata.name,
+ namespace: mockEntityMetadata.metadata.namespace!!,
+ },
+ children,
+}: {
+ entityRef?: CompoundEntityRef;
+ children: ReactNode;
+}) => (
+
+
+ {children}
+
+
+);
+
+const mountedRoutes = {
+ '/catalog/:namespace/:kind/:name/*': entityRouteRef,
+};
+
+describe('', () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should render entity kind, owner, and lifecycle', async () => {
+ getEntityMetadata.mockResolvedValue(mockEntityMetadata);
+ getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata);
+
+ const rendered = await renderInTestApp(
+
+
+ ,
+ { mountedRoutes },
+ );
+
+ expect(await rendered.findByText('Component:')).toBeInTheDocument();
+ expect(rendered.getByText('Owner:')).toBeInTheDocument();
+ expect(rendered.getByText('Lifecycle:')).toBeInTheDocument();
+ expect(rendered.getByText('production')).toBeInTheDocument();
+ });
+
+ it('should not render when entity metadata is loading', async () => {
+ // Don't resolve the metadata — it stays in loading state
+ getEntityMetadata.mockReturnValue(new Promise(() => {}));
+ getTechDocsMetadata.mockReturnValue(new Promise(() => {}));
+
+ const rendered = await renderInTestApp(
+
+
+ ,
+ { mountedRoutes },
+ );
+
+ expect(rendered.queryByText('Component:')).not.toBeInTheDocument();
+ });
+
+ it('should not render when entity metadata is missing', async () => {
+ getEntityMetadata.mockResolvedValue(undefined);
+ getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata);
+
+ const rendered = await renderInTestApp(
+
+
+ ,
+ { mountedRoutes },
+ );
+
+ expect(rendered.queryByText('Component:')).not.toBeInTheDocument();
+ });
+
+ it('should not render lifecycle when not present', async () => {
+ getEntityMetadata.mockResolvedValue({
+ ...mockEntityMetadata,
+ spec: { owner: 'team-a' },
+ });
+ getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata);
+
+ const rendered = await renderInTestApp(
+
+
+ ,
+ { mountedRoutes },
+ );
+
+ expect(await rendered.findByText('Component:')).toBeInTheDocument();
+ expect(rendered.queryByText('Lifecycle:')).not.toBeInTheDocument();
+ });
+
+ it('should not render owner when no owned-by relations exist', async () => {
+ getEntityMetadata.mockResolvedValue({
+ ...mockEntityMetadata,
+ relations: [],
+ });
+ getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata);
+
+ const rendered = await renderInTestApp(
+
+
+ ,
+ { mountedRoutes },
+ );
+
+ expect(await rendered.findByText('Component:')).toBeInTheDocument();
+ expect(rendered.queryByText('Owner:')).not.toBeInTheDocument();
+ });
+
+ it('should render search when withSearch is true', async () => {
+ getEntityMetadata.mockResolvedValue(mockEntityMetadata);
+ getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata);
+
+ const rendered = await renderInTestApp(
+
+
+ ,
+ { mountedRoutes },
+ );
+
+ expect(await rendered.findByText('Component:')).toBeInTheDocument();
+ expect(
+ rendered.getByRole('searchbox', { name: 'Search docs' }),
+ ).toBeInTheDocument();
+ });
+
+ it('should not render search when withSearch is false', async () => {
+ getEntityMetadata.mockResolvedValue(mockEntityMetadata);
+ getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata);
+
+ const rendered = await renderInTestApp(
+
+
+ ,
+ { mountedRoutes },
+ );
+
+ expect(await rendered.findByText('Component:')).toBeInTheDocument();
+ expect(
+ rendered.queryByRole('searchbox', { name: 'Search docs' }),
+ ).not.toBeInTheDocument();
+ });
+});
diff --git a/plugins/techdocs/src/alpha/components/TechDocsReaderEntityCard.tsx b/plugins/techdocs/src/alpha/components/TechDocsReaderEntityCard.tsx
new file mode 100644
index 0000000000..1448eb3fda
--- /dev/null
+++ b/plugins/techdocs/src/alpha/components/TechDocsReaderEntityCard.tsx
@@ -0,0 +1,105 @@
+/*
+ * Copyright 2026 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 { useTechDocsReaderPage } from '@backstage/plugin-techdocs-react';
+import {
+ EntityRefLink,
+ EntityRefLinks,
+ getEntityRelations,
+} from '@backstage/plugin-catalog-react';
+import { RELATION_OWNED_BY } from '@backstage/catalog-model';
+import { Card, CardBody, Text } from '@backstage/ui';
+import capitalize from 'lodash/capitalize';
+import styles from './TechDocsReaderEntityCard.module.css';
+import { TechDocsReaderSearch } from './TechDocsReaderSearch';
+
+export type TechDocsReaderEntityCardProps = {
+ withSearch?: boolean;
+};
+
+export const TechDocsReaderEntityCard = (
+ props: TechDocsReaderEntityCardProps,
+) => {
+ const { withSearch } = props;
+ const {
+ entityRef,
+ entityMetadata: { value: entityMetadata, loading: entityMetadataLoading },
+ } = useTechDocsReaderPage();
+
+ if (entityMetadataLoading || !entityMetadata) return null;
+
+ const { spec } = entityMetadata;
+ const lifecycle = spec?.lifecycle;
+ const ownedByRelations = getEntityRelations(
+ entityMetadata,
+ RELATION_OWNED_BY,
+ );
+
+ return (
+
+
+
+
+
-
+
+ {capitalize(entityMetadata.kind)}:
+
+
+ -
+
+
+
+ {ownedByRelations.length > 0 && (
+
+
-
+
+ Owner:
+
+
+ -
+
+
+
+ )}
+ {lifecycle && (
+
+
-
+
+ Lifecycle:
+
+
+ -
+ {String(lifecycle)}
+
+
+ )}
+
+ {withSearch && (
+
+
+
+ )}
+
+
+ );
+};
diff --git a/plugins/techdocs/src/alpha/components/TechDocsReaderHeader.test.tsx b/plugins/techdocs/src/alpha/components/TechDocsReaderHeader.test.tsx
new file mode 100644
index 0000000000..625ee832d9
--- /dev/null
+++ b/plugins/techdocs/src/alpha/components/TechDocsReaderHeader.test.tsx
@@ -0,0 +1,224 @@
+/*
+ * Copyright 2026 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 { ReactNode } from 'react';
+import { waitFor } from '@testing-library/react';
+
+import { CompoundEntityRef } from '@backstage/catalog-model';
+import {
+ entityPresentationApiRef,
+ entityRouteRef,
+} from '@backstage/plugin-catalog-react';
+import {
+ techdocsApiRef,
+ TechDocsReaderPageProvider,
+} from '@backstage/plugin-techdocs-react';
+import {
+ renderInTestApp,
+ TestApiProvider,
+ mockApis,
+} from '@backstage/test-utils';
+import { configApiRef } from '@backstage/core-plugin-api';
+
+import { rootRouteRef } from '../../routes';
+
+import { TechDocsReaderHeader } from './TechDocsReaderHeader';
+
+const mockEntityMetadata = {
+ locationMetadata: {
+ type: 'github',
+ target: 'https://example.com/',
+ },
+ apiVersion: 'v1',
+ kind: 'test',
+ metadata: {
+ name: 'test-name',
+ namespace: 'test-namespace',
+ },
+ spec: {
+ owner: 'test',
+ },
+};
+
+const mockTechDocsMetadata = {
+ site_name: 'test-site-name',
+ site_description: 'test-site-desc',
+};
+
+let useParamsPath = '/';
+jest.mock('react-router-dom', () => {
+ return {
+ ...(jest.requireActual('react-router-dom') as any),
+ useParams: () => ({ '*': useParamsPath }),
+ };
+});
+
+const getEntityMetadata = jest.fn();
+const getTechDocsMetadata = jest.fn();
+
+const techdocsApiMock = {
+ getEntityMetadata,
+ getTechDocsMetadata,
+};
+
+const forEntity = jest.fn();
+
+forEntity.mockReturnValue({
+ snapshot: {
+ primaryTitle: 'Test Entity',
+ },
+});
+
+const entityPresentationApiMock = {
+ forEntity,
+};
+
+const configApiMock = mockApis.config();
+
+const Wrapper = ({
+ entityRef = {
+ kind: mockEntityMetadata.kind,
+ name: mockEntityMetadata.metadata.name,
+ namespace: mockEntityMetadata.metadata.namespace!!,
+ },
+ children,
+}: {
+ entityRef?: CompoundEntityRef;
+ children: ReactNode;
+}) => (
+
+
+ {children}
+
+
+);
+
+const mountedRoutes = {
+ '/catalog/:namespace/:kind/:name/*': entityRouteRef,
+ '/docs': rootRouteRef,
+};
+
+describe('', () => {
+ beforeEach(() => {
+ useParamsPath = '/';
+ jest.clearAllMocks();
+ forEntity.mockReturnValue({
+ snapshot: { primaryTitle: 'Test Entity' },
+ });
+ });
+
+ it('should render the header with site name as title', async () => {
+ getEntityMetadata.mockResolvedValue(mockEntityMetadata);
+ getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata);
+
+ const rendered = await renderInTestApp(
+
+
+ ,
+ { mountedRoutes },
+ );
+
+ expect(await rendered.findByText('test-site-name')).toBeInTheDocument();
+ });
+
+ it('should not render the header if entity metadata is missing', async () => {
+ getEntityMetadata.mockResolvedValue(undefined);
+
+ const rendered = await renderInTestApp(
+
+
+ ,
+ { mountedRoutes },
+ );
+
+ expect(rendered.container.innerHTML).not.toContain('header');
+ });
+
+ it('should not render the header if techdocs metadata is missing', async () => {
+ getTechDocsMetadata.mockResolvedValue(undefined);
+
+ const rendered = await renderInTestApp(
+
+
+ ,
+ { mountedRoutes },
+ );
+
+ expect(rendered.container.innerHTML).not.toContain('header');
+ });
+
+ it('should render a source link for remote docs', async () => {
+ getEntityMetadata.mockResolvedValue(mockEntityMetadata);
+ getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata);
+
+ const rendered = await renderInTestApp(
+
+
+ ,
+ { mountedRoutes },
+ );
+
+ const sourceLink = await rendered.findByRole('link', {
+ name: 'View source',
+ });
+ expect(sourceLink).toHaveAttribute('href', 'https://example.com/');
+ expect(sourceLink).toHaveAttribute('target', '_blank');
+ });
+
+ it('should not render a source link for local docs', async () => {
+ getEntityMetadata.mockResolvedValue({
+ ...mockEntityMetadata,
+ locationMetadata: { type: 'dir', target: '/local/path' },
+ });
+ getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata);
+
+ const rendered = await renderInTestApp(
+
+
+ ,
+ { mountedRoutes },
+ );
+
+ await rendered.findByText('test-site-name');
+ expect(
+ rendered.queryByRole('link', { name: 'View source' }),
+ ).not.toBeInTheDocument();
+ });
+
+ it('should set document title based on URL path segments', async () => {
+ getEntityMetadata.mockResolvedValue(mockEntityMetadata);
+ getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata);
+
+ useParamsPath = 'foo/bar/baz/';
+ await renderInTestApp(
+
+
+ ,
+ { mountedRoutes },
+ );
+
+ await waitFor(() => {
+ expect(document.title).toEqual(
+ 'Test Entity | Foo | Bar | Baz | Backstage',
+ );
+ });
+ });
+});
diff --git a/plugins/techdocs/src/alpha/components/TechDocsReaderHeader.tsx b/plugins/techdocs/src/alpha/components/TechDocsReaderHeader.tsx
new file mode 100644
index 0000000000..becec9612d
--- /dev/null
+++ b/plugins/techdocs/src/alpha/components/TechDocsReaderHeader.tsx
@@ -0,0 +1,69 @@
+/*
+ * Copyright 2026 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 Helmet from 'react-helmet';
+import { Header, ButtonLink } from '@backstage/ui';
+import { RiCodeLine } from '@remixicon/react';
+import { TechDocsAddonLocations as locations } from '@backstage/plugin-techdocs-react';
+import { TechDocsReaderSearch } from './TechDocsReaderSearch';
+import { useTechDocsReaderHeaderData } from '../../hooks/useTechDocsReaderHeaderData';
+
+export type TechDocsReaderHeaderProps = {
+ withSearch?: boolean;
+};
+
+export const TechDocsReaderHeader = (props: TechDocsReaderHeaderProps) => {
+ const { withSearch = true } = props;
+ const {
+ title,
+ entityRef,
+ tabTitle,
+ hidden,
+ showSourceLink,
+ sourceLink,
+ addons,
+ } = useTechDocsReaderHeaderData();
+
+ if (hidden) return null;
+
+ return (
+ <>
+
+ {tabTitle}
+
+
+ {withSearch && }
+ {showSourceLink && (
+ }
+ aria-label="View source"
+ />
+ )}
+ {addons.renderComponentsByLocation(locations.Header)}
+ >
+ }
+ />
+ >
+ );
+};
diff --git a/plugins/techdocs/src/alpha/components/TechDocsReaderLayout.tsx b/plugins/techdocs/src/alpha/components/TechDocsReaderLayout.tsx
new file mode 100644
index 0000000000..b43d9c6d14
--- /dev/null
+++ b/plugins/techdocs/src/alpha/components/TechDocsReaderLayout.tsx
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2026 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 { Container } from '@backstage/ui';
+import { TechDocsReaderHeader } from './TechDocsReaderHeader';
+import { TechDocsReaderEntityCard } from './TechDocsReaderEntityCard';
+import { TechDocsReaderPageSubheader } from '../../reader/components/TechDocsReaderPageSubheader';
+import { TechDocsReaderContent } from './TechDocsReaderContent';
+
+export type TechDocsReaderLayoutProps = {
+ withHeader?: boolean;
+ withSearch?: boolean;
+};
+
+export const TechDocsReaderLayout = (props: TechDocsReaderLayoutProps) => {
+ const { withSearch, withHeader = true } = props;
+ return (
+ <>
+ {withHeader && }
+
+
+
+
+
+ >
+ );
+};
diff --git a/plugins/techdocs/src/alpha/components/TechDocsReaderSearch.test.tsx b/plugins/techdocs/src/alpha/components/TechDocsReaderSearch.test.tsx
new file mode 100644
index 0000000000..d5b3bbfb67
--- /dev/null
+++ b/plugins/techdocs/src/alpha/components/TechDocsReaderSearch.test.tsx
@@ -0,0 +1,73 @@
+/*
+ * Copyright 2026 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 { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import { searchApiRef, MockSearchApi } from '@backstage/plugin-search-react';
+import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
+
+import { TechDocsReaderSearch } from './TechDocsReaderSearch';
+
+const entityId = {
+ kind: 'Component',
+ name: 'test-component',
+ namespace: 'default',
+};
+
+const mockSearchApi = new MockSearchApi({
+ results: [
+ {
+ type: 'techdocs',
+ document: {
+ location: '/docs/default/component/test-component/getting-started',
+ title: 'Getting Started',
+ text: 'This guide helps you get started.',
+ },
+ },
+ ],
+});
+
+describe('', () => {
+ it('should render the search input', async () => {
+ await renderInTestApp(
+
+
+ ,
+ );
+
+ expect(
+ screen.getByRole('searchbox', { name: 'Search docs' }),
+ ).toBeInTheDocument();
+ });
+
+ it('should render search results when user types', async () => {
+ const user = userEvent.setup();
+
+ await renderInTestApp(
+
+
+ ,
+ );
+
+ const input = screen.getByRole('searchbox', { name: 'Search docs' });
+ await user.type(input, 'getting');
+
+ expect(await screen.findByText('Getting Started')).toBeInTheDocument();
+ expect(
+ screen.getByText('This guide helps you get started.'),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/plugins/techdocs/src/alpha/components/TechDocsReaderSearch.tsx b/plugins/techdocs/src/alpha/components/TechDocsReaderSearch.tsx
new file mode 100644
index 0000000000..aba8a60c7c
--- /dev/null
+++ b/plugins/techdocs/src/alpha/components/TechDocsReaderSearch.tsx
@@ -0,0 +1,120 @@
+/*
+ * Copyright 2026 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 { useNavigate } from 'react-router-dom';
+import {
+ Text,
+ Flex,
+ SearchAutocomplete,
+ SearchAutocompleteItem,
+} from '@backstage/ui';
+import { SearchContextProvider } from '@backstage/plugin-search-react';
+import { CompoundEntityRef } from '@backstage/catalog-model';
+import { HighlightedSearchResultText } from '@backstage/plugin-search-react';
+import { useTechDocsSearch } from '../../hooks/useTechDocsSearch';
+
+type TechDocsReaderSearchBarProps = {
+ entityId: CompoundEntityRef;
+};
+
+const TechDocsReaderSearchBar = (props: TechDocsReaderSearchBarProps) => {
+ const { entityId } = props;
+ const navigate = useNavigate();
+ const { results, term, setTerm, deferredLoading } =
+ useTechDocsSearch(entityId);
+
+ return (
+
+ {results.map((result, index) => (
+ {
+ setTerm('');
+ navigate(result.document.location);
+ requestAnimationFrame(() => {
+ (document.activeElement as HTMLElement)?.blur();
+ });
+ }}
+ >
+
+
+ {result.highlight?.fields.title ? (
+
+ ) : (
+ result.document.title
+ )}
+
+ {(result.highlight?.fields.text || result.document.text) && (
+
+ {result.highlight?.fields.text ? (
+
+ ) : (
+ result.document.text
+ )}
+
+ )}
+
+
+ ))}
+
+ );
+};
+
+export type TechDocsReaderSearchProps = {
+ entityId: CompoundEntityRef;
+};
+
+export const TechDocsReaderSearch = (props: TechDocsReaderSearchProps) => {
+ const initialState = {
+ term: '',
+ types: ['techdocs'],
+ pageCursor: '',
+ filters: props.entityId,
+ };
+ return (
+
+
+
+ );
+};
diff --git a/plugins/techdocs/src/alpha/index.tsx b/plugins/techdocs/src/alpha/index.tsx
index 1ec92edfd3..9f68fd7907 100644
--- a/plugins/techdocs/src/alpha/index.tsx
+++ b/plugins/techdocs/src/alpha/index.tsx
@@ -15,12 +15,13 @@
*/
import { Suspense } from 'react';
-import LibraryBooks from '@material-ui/icons/LibraryBooks';
+import { RiArticleLine } from '@remixicon/react';
import {
createFrontendPlugin,
ApiBlueprint,
PageBlueprint,
NavItemBlueprint,
+ PluginHeaderActionBlueprint,
createExtensionInput,
coreExtensionData,
createExtension,
@@ -46,7 +47,7 @@ import {
rootDocsRouteRef,
rootRouteRef,
} from '../routes';
-import { TechDocsReaderLayout } from '../reader';
+import { TechDocsReaderLayout } from './components/TechDocsReaderLayout';
import {
TechDocsAddons,
techdocsApiRef,
@@ -54,7 +55,7 @@ import {
} from '@backstage/plugin-techdocs-react';
import { useTechdocsReaderIconLinkProps } from './hooks/useTechdocsReaderIconLinkProps';
-import { DocsIcon } from '@backstage/core-components';
+import { DocsIcon, SupportButton } from '@backstage/core-components';
/** @alpha */
const techdocsEntityIconLink = EntityIconLinkBlueprint.make({
@@ -140,8 +141,8 @@ const techDocsPage = PageBlueprint.make({
path: '/docs',
routeRef: rootRouteRef,
loader: () =>
- import('../home/components/TechDocsIndexPage').then(m => (
-
+ import('./components/TechDocsIndexPageContent').then(m => (
+
)),
},
});
@@ -270,23 +271,33 @@ const techDocsEntityContentEmptyState = createExtension({
/** @alpha */
const techDocsNavItem = NavItemBlueprint.make({
params: {
- icon: LibraryBooks,
+ icon: () => ,
title: 'Docs',
routeRef: rootRouteRef,
},
});
+const techDocsSupportAction = PluginHeaderActionBlueprint.make({
+ params: defineParams =>
+ defineParams({
+ loader: async () => (
+ Discover documentation in your ecosystem.
+ ),
+ }),
+});
+
/** @alpha */
export default createFrontendPlugin({
pluginId: 'techdocs',
- title: 'Docs',
- icon: ,
+ title: 'Documentation',
+ icon: ,
info: { packageJson: () => import('../../package.json') },
extensions: [
techDocsClientApi,
techDocsStorageApi,
TechDocsAddonsApiExtension,
techDocsNavItem,
+ techDocsSupportAction,
techDocsPage,
techDocsReaderPage,
techdocsEntityIconLink,
diff --git a/plugins/techdocs/src/hooks/useTechDocsReaderContentData.test.tsx b/plugins/techdocs/src/hooks/useTechDocsReaderContentData.test.tsx
new file mode 100644
index 0000000000..324d50f0dc
--- /dev/null
+++ b/plugins/techdocs/src/hooks/useTechDocsReaderContentData.test.tsx
@@ -0,0 +1,184 @@
+/*
+ * Copyright 2026 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 { ReactNode, useState, useEffect } from 'react';
+import { waitFor, screen } from '@testing-library/react';
+
+import { CompoundEntityRef } from '@backstage/catalog-model';
+import {
+ techdocsApiRef,
+ TechDocsReaderPageProvider,
+ useShadowRootElements,
+} from '@backstage/plugin-techdocs-react';
+import {
+ renderInTestApp,
+ TestApiProvider,
+ mockApis,
+} from '@backstage/test-utils';
+import { configApiRef } from '@backstage/core-plugin-api';
+
+const useTechDocsReaderDom = jest.fn();
+jest.mock('../reader/components/TechDocsReaderPageContent/dom', () => ({
+ ...jest.requireActual('../reader/components/TechDocsReaderPageContent/dom'),
+ useTechDocsReaderDom: (...args: any[]) => useTechDocsReaderDom(...args),
+}));
+const useTechDocsReader = jest.fn();
+jest.mock('../reader/components/TechDocsReaderProvider', () => ({
+ ...jest.requireActual('../reader/components/TechDocsReaderProvider'),
+ useTechDocsReader: (...args: any[]) => useTechDocsReader(...args),
+}));
+const useShadowDomStylesLoading = jest.fn().mockReturnValue(false);
+jest.mock('@backstage/plugin-techdocs-react', () => ({
+ ...jest.requireActual('@backstage/plugin-techdocs-react'),
+ useShadowDomStylesLoading: (...args: any[]) =>
+ useShadowDomStylesLoading(...args),
+ useShadowRootElements: jest.fn(),
+}));
+
+import { useTechDocsReaderContentData } from './useTechDocsReaderContentData';
+
+const mockEntityMetadata = {
+ apiVersion: 'v1',
+ kind: 'test',
+ metadata: {
+ name: 'test-name',
+ namespace: 'test-namespace',
+ },
+ spec: { owner: 'test' },
+};
+
+const mockTechDocsMetadata = {
+ site_name: 'test-site-name',
+ site_description: 'test-site-desc',
+};
+
+const getEntityMetadata = jest.fn();
+const getTechDocsMetadata = jest.fn();
+
+const Wrapper = ({
+ entityRef = {
+ kind: mockEntityMetadata.kind,
+ name: mockEntityMetadata.metadata.name,
+ namespace: mockEntityMetadata.metadata.namespace!!,
+ },
+ children,
+}: {
+ entityRef?: CompoundEntityRef;
+ children: ReactNode;
+}) => (
+
+
+ {children}
+
+
+);
+
+function HookRenderer({ defaultPath }: { defaultPath?: string }) {
+ const data = useTechDocsReaderContentData({ defaultPath });
+ const [, setRender] = useState(0);
+ useEffect(() => setRender(r => r + 1), [data.isNotFound, data.isDomReady]);
+ return (
+
+ {String(data.isNotFound)}
+ {String(data.isDomReady)}
+ {String(data.showProgress)}
+
+ );
+}
+
+describe('useTechDocsReaderContentData', () => {
+ const useShadowRootElementsMock = useShadowRootElements as jest.Mock;
+
+ beforeEach(() => {
+ useShadowRootElementsMock.mockReturnValue([]);
+ jest.clearAllMocks();
+ });
+
+ it('should return ready state when DOM is loaded', async () => {
+ getEntityMetadata.mockResolvedValue(mockEntityMetadata);
+ getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata);
+ useTechDocsReaderDom.mockReturnValue(document.createElement('html'));
+ useTechDocsReader.mockReturnValue({ state: 'cached' });
+
+ await renderInTestApp(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId('isDomReady')).toHaveTextContent('true');
+ });
+
+ expect(screen.getByTestId('isNotFound')).toHaveTextContent('false');
+ expect(screen.getByTestId('showProgress')).toHaveTextContent('false');
+ });
+
+ it('should return not found when entity metadata is missing', async () => {
+ getEntityMetadata.mockResolvedValue(undefined);
+ useTechDocsReaderDom.mockReturnValue(document.createElement('html'));
+ useTechDocsReader.mockReturnValue({ state: 'cached' });
+
+ await renderInTestApp(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId('isNotFound')).toHaveTextContent('true');
+ });
+ });
+
+ it('should show progress when checking state', async () => {
+ getEntityMetadata.mockResolvedValue(mockEntityMetadata);
+ getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata);
+ useTechDocsReaderDom.mockReturnValue(document.createElement('html'));
+ useTechDocsReader.mockReturnValue({ state: 'CHECKING' });
+
+ await renderInTestApp(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId('showProgress')).toHaveTextContent('true');
+ });
+ });
+
+ it('should show progress when styles are loading', async () => {
+ getEntityMetadata.mockResolvedValue(mockEntityMetadata);
+ getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata);
+ useTechDocsReaderDom.mockReturnValue(document.createElement('html'));
+ useTechDocsReader.mockReturnValue({ state: 'cached' });
+ useShadowDomStylesLoading.mockReturnValue(true);
+
+ await renderInTestApp(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId('showProgress')).toHaveTextContent('true');
+ });
+ });
+});
diff --git a/plugins/techdocs/src/hooks/useTechDocsReaderContentData.ts b/plugins/techdocs/src/hooks/useTechDocsReaderContentData.ts
new file mode 100644
index 0000000000..02a3422617
--- /dev/null
+++ b/plugins/techdocs/src/hooks/useTechDocsReaderContentData.ts
@@ -0,0 +1,94 @@
+/*
+ * Copyright 2026 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 { useCallback, useEffect } from 'react';
+import { useLocation } from 'react-router-dom';
+import {
+ useShadowDomStylesLoading,
+ useShadowRootElements,
+ useTechDocsReaderPage,
+} from '@backstage/plugin-techdocs-react';
+import { useApp } from '@backstage/core-plugin-api';
+import { useTechDocsReaderDom } from '../reader/components/TechDocsReaderPageContent/dom';
+import { useTechDocsReader } from '../reader/components/TechDocsReaderProvider';
+
+/**
+ * Shared hook for TechDocs reader content data.
+ * Encapsulates DOM setup, hash scrolling, shadow root handling,
+ * 404 detection, and loading state.
+ */
+export function useTechDocsReaderContentData(options: {
+ defaultPath?: string;
+ onReady?: () => void;
+}) {
+ const { defaultPath, onReady } = options;
+
+ const {
+ entityMetadata: { value: entityMetadata, loading: entityMetadataLoading },
+ entityRef,
+ setShadowRoot,
+ } = useTechDocsReaderPage();
+ const { state } = useTechDocsReader();
+ const dom = useTechDocsReaderDom(entityRef, defaultPath);
+ const location = useLocation();
+ const path = location.pathname;
+ const hash = location.hash;
+ const isStyleLoading = useShadowDomStylesLoading(dom);
+ const [hashElement] = useShadowRootElements([`[id="${hash.slice(1)}"]`]);
+ const app = useApp();
+ const { NotFoundErrorPage } = app.getComponents();
+
+ useEffect(() => {
+ if (isStyleLoading) return;
+
+ if (hash) {
+ if (hashElement) {
+ hashElement.scrollIntoView();
+ const link = hashElement.querySelector('a.headerlink');
+ if (link) {
+ link.focus();
+ }
+ }
+ } else {
+ document?.querySelector('header')?.scrollIntoView();
+ }
+ }, [path, hash, hashElement, isStyleLoading]);
+
+ const handleAppend = useCallback(
+ (newShadowRoot: ShadowRoot) => {
+ setShadowRoot(newShadowRoot);
+ if (onReady instanceof Function) {
+ onReady();
+ }
+ },
+ [setShadowRoot, onReady],
+ );
+
+ const isNotFound = entityMetadataLoading === false && !entityMetadata;
+ const isDomReady = !!dom;
+ const showProgress = state === 'CHECKING' || isStyleLoading;
+
+ return {
+ entityRef,
+ entityMetadata,
+ dom,
+ handleAppend,
+ isNotFound,
+ isDomReady,
+ showProgress,
+ NotFoundErrorPage,
+ };
+}
diff --git a/plugins/techdocs/src/hooks/useTechDocsReaderHeaderData.test.tsx b/plugins/techdocs/src/hooks/useTechDocsReaderHeaderData.test.tsx
new file mode 100644
index 0000000000..6e1c14627d
--- /dev/null
+++ b/plugins/techdocs/src/hooks/useTechDocsReaderHeaderData.test.tsx
@@ -0,0 +1,206 @@
+/*
+ * Copyright 2026 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 { ReactNode, useState, useEffect } from 'react';
+import { waitFor, screen } from '@testing-library/react';
+
+import { CompoundEntityRef } from '@backstage/catalog-model';
+import { entityPresentationApiRef } from '@backstage/plugin-catalog-react';
+import {
+ techdocsApiRef,
+ TechDocsReaderPageProvider,
+} from '@backstage/plugin-techdocs-react';
+import {
+ renderInTestApp,
+ TestApiProvider,
+ mockApis,
+} from '@backstage/test-utils';
+import { configApiRef } from '@backstage/core-plugin-api';
+
+import { useTechDocsReaderHeaderData } from './useTechDocsReaderHeaderData';
+
+const mockEntityMetadata = {
+ locationMetadata: {
+ type: 'github',
+ target: 'https://example.com/',
+ },
+ apiVersion: 'v1',
+ kind: 'test',
+ metadata: {
+ name: 'test-name',
+ namespace: 'test-namespace',
+ },
+ spec: {
+ owner: 'test',
+ },
+};
+
+const mockTechDocsMetadata = {
+ site_name: 'test-site-name',
+ site_description: 'test-site-desc',
+};
+
+let useParamsPath = '/';
+jest.mock('react-router-dom', () => {
+ return {
+ ...(jest.requireActual('react-router-dom') as any),
+ useParams: () => ({ '*': useParamsPath }),
+ };
+});
+
+const getEntityMetadata = jest.fn();
+const getTechDocsMetadata = jest.fn();
+
+const forEntity = jest.fn().mockReturnValue({
+ snapshot: { primaryTitle: 'Test Entity' },
+});
+
+const Wrapper = ({
+ entityRef = {
+ kind: mockEntityMetadata.kind,
+ name: mockEntityMetadata.metadata.name,
+ namespace: mockEntityMetadata.metadata.namespace!!,
+ },
+ children,
+}: {
+ entityRef?: CompoundEntityRef;
+ children: ReactNode;
+}) => (
+
+
+ {children}
+
+
+);
+
+function HookRenderer() {
+ const data = useTechDocsReaderHeaderData();
+ const [, setRender] = useState(0);
+ useEffect(() => setRender(r => r + 1), [data.hidden, data.title]);
+ return (
+
+ {String(data.hidden)}
+ {data.title || ''}
+ {data.tabTitle}
+ {String(data.showSourceLink)}
+ {data.sourceLink || ''}
+
+ );
+}
+
+describe('useTechDocsReaderHeaderData', () => {
+ beforeEach(() => {
+ useParamsPath = '/';
+ jest.clearAllMocks();
+ forEntity.mockReturnValue({
+ snapshot: { primaryTitle: 'Test Entity' },
+ });
+ });
+
+ it('should return header data when metadata is loaded', async () => {
+ getEntityMetadata.mockResolvedValue(mockEntityMetadata);
+ getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata);
+
+ await renderInTestApp(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId('hidden')).toHaveTextContent('false');
+ });
+
+ expect(screen.getByTestId('title')).toHaveTextContent('test-site-name');
+ expect(screen.getByTestId('showSourceLink')).toHaveTextContent('true');
+ expect(screen.getByTestId('sourceLink')).toHaveTextContent(
+ 'https://example.com/',
+ );
+ });
+
+ it('should be hidden when entity metadata is missing', async () => {
+ getEntityMetadata.mockResolvedValue(undefined);
+
+ await renderInTestApp(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId('hidden')).toHaveTextContent('true');
+ });
+ });
+
+ it('should be hidden when techdocs metadata is missing', async () => {
+ getTechDocsMetadata.mockResolvedValue(undefined);
+
+ await renderInTestApp(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId('hidden')).toHaveTextContent('true');
+ });
+ });
+
+ it('should not show source link for local docs', async () => {
+ getEntityMetadata.mockResolvedValue({
+ ...mockEntityMetadata,
+ locationMetadata: { type: 'dir', target: '/local/path' },
+ });
+ getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata);
+
+ await renderInTestApp(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId('hidden')).toHaveTextContent('false');
+ });
+
+ expect(screen.getByTestId('showSourceLink')).toHaveTextContent('false');
+ });
+
+ it('should compute tab title from URL path segments', async () => {
+ getEntityMetadata.mockResolvedValue(mockEntityMetadata);
+ getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata);
+
+ useParamsPath = 'foo/bar/baz/';
+ await renderInTestApp(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId('hidden')).toHaveTextContent('false');
+ });
+
+ expect(screen.getByTestId('tabTitle')).toHaveTextContent(
+ 'Test Entity | Foo | Bar | Baz | Backstage',
+ );
+ });
+});
diff --git a/plugins/techdocs/src/hooks/useTechDocsReaderHeaderData.ts b/plugins/techdocs/src/hooks/useTechDocsReaderHeaderData.ts
new file mode 100644
index 0000000000..56958d05d0
--- /dev/null
+++ b/plugins/techdocs/src/hooks/useTechDocsReaderHeaderData.ts
@@ -0,0 +1,107 @@
+/*
+ * Copyright 2026 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 { useEffect } from 'react';
+import { useParams } from 'react-router-dom';
+import { stringifyEntityRef } from '@backstage/catalog-model';
+import { configApiRef, useApi } from '@backstage/core-plugin-api';
+import {
+ useTechDocsAddons,
+ useTechDocsReaderPage,
+} from '@backstage/plugin-techdocs-react';
+import { entityPresentationApiRef } from '@backstage/plugin-catalog-react';
+import capitalize from 'lodash/capitalize';
+
+/**
+ * Shared hook for TechDocs reader header data.
+ * Encapsulates title/subtitle sync, tab title computation,
+ * 404 detection, and source link visibility.
+ */
+export function useTechDocsReaderHeaderData() {
+ const addons = useTechDocsAddons();
+ const configApi = useApi(configApiRef);
+ const entityPresentationApi = useApi(entityPresentationApiRef);
+ const { '*': path = '' } = useParams();
+
+ const {
+ title,
+ setTitle,
+ subtitle,
+ setSubtitle,
+ entityRef,
+ metadata: { value: metadata, loading: metadataLoading },
+ entityMetadata: { value: entityMetadata, loading: entityMetadataLoading },
+ } = useTechDocsReaderPage();
+
+ useEffect(() => {
+ if (!metadata) return;
+ setTitle(metadata.site_name);
+ setSubtitle(() => {
+ let { site_description } = metadata;
+ if (!site_description || site_description === 'None') {
+ site_description = '';
+ }
+ return site_description;
+ });
+ }, [metadata, setTitle, setSubtitle]);
+
+ const appTitle = configApi.getOptionalString('app.title') || 'Backstage';
+ const locationMetadata = entityMetadata?.locationMetadata;
+ const showSourceLink =
+ !!locationMetadata &&
+ locationMetadata.type !== 'dir' &&
+ locationMetadata.type !== 'file';
+
+ const noEntMetadata = !entityMetadataLoading && entityMetadata === undefined;
+ const noTdMetadata = !metadataLoading && metadata === undefined;
+ const hidden = noEntMetadata || noTdMetadata;
+
+ const removeTrailingSlash = (str: string) => str.replace(/\/$/, '');
+ const normalizeAndSpace = (str: string) =>
+ str.replace(/[-_]/g, ' ').split(' ').map(capitalize).join(' ');
+
+ let tabTitle = appTitle;
+ if (!hidden) {
+ const stringEntityRef = stringifyEntityRef(entityRef);
+ const entityDisplayName =
+ entityPresentationApi.forEntity(stringEntityRef).snapshot.primaryTitle;
+
+ let techdocsTabTitleItems: string[] = [];
+ if (path !== '')
+ techdocsTabTitleItems = removeTrailingSlash(path)
+ .split('/')
+ .map(normalizeAndSpace);
+
+ const tabTitleItems = [
+ entityDisplayName,
+ ...techdocsTabTitleItems,
+ appTitle,
+ ];
+ tabTitle = tabTitleItems.join(' | ');
+ }
+
+ return {
+ title,
+ subtitle,
+ entityRef,
+ entityMetadata,
+ tabTitle,
+ hidden,
+ showSourceLink,
+ sourceLink: locationMetadata?.target,
+ addons,
+ };
+}
diff --git a/plugins/techdocs/src/hooks/useTechDocsSearch.test.tsx b/plugins/techdocs/src/hooks/useTechDocsSearch.test.tsx
new file mode 100644
index 0000000000..ff5fd7418e
--- /dev/null
+++ b/plugins/techdocs/src/hooks/useTechDocsSearch.test.tsx
@@ -0,0 +1,105 @@
+/*
+ * Copyright 2026 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 { ReactNode, useState, useEffect } from 'react';
+import { waitFor, screen } from '@testing-library/react';
+
+import {
+ searchApiRef,
+ MockSearchApi,
+ SearchContextProvider,
+} from '@backstage/plugin-search-react';
+import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
+
+import { useTechDocsSearch } from './useTechDocsSearch';
+
+const entityId = {
+ kind: 'Component',
+ name: 'test-component',
+ namespace: 'default',
+};
+
+const mockSearchApi = new MockSearchApi({
+ results: [
+ {
+ type: 'techdocs',
+ document: {
+ location: '/docs/default/component/test-component/getting-started',
+ title: 'Getting Started',
+ text: 'This guide helps you get started.',
+ },
+ },
+ ],
+});
+
+function HookRenderer() {
+ const data = useTechDocsSearch(entityId);
+ const [, setRender] = useState(0);
+ useEffect(() => setRender(r => r + 1), [data.results.length, data.loading]);
+ return (
+
+ {data.results.length}
+ {String(data.loading)}
+ {String(data.deferredLoading)}
+ {data.term}
+ {data.results.map((r, i) => (
+
+ {r.document.title}
+
+ ))}
+
+ );
+}
+
+const Wrapper = ({ children }: { children: ReactNode }) => (
+
+
+ {children}
+
+
+);
+
+describe('useTechDocsSearch', () => {
+ it('should return search results', async () => {
+ await renderInTestApp(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId('resultCount')).toHaveTextContent('1');
+ });
+
+ expect(screen.getByTestId('result-0')).toHaveTextContent('Getting Started');
+ });
+
+ it('should not show deferred loading initially', async () => {
+ await renderInTestApp(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId('deferredLoading')).toHaveTextContent('false');
+ });
+});
diff --git a/plugins/techdocs/src/hooks/useTechDocsSearch.ts b/plugins/techdocs/src/hooks/useTechDocsSearch.ts
new file mode 100644
index 0000000000..86fa89e864
--- /dev/null
+++ b/plugins/techdocs/src/hooks/useTechDocsSearch.ts
@@ -0,0 +1,86 @@
+/*
+ * Copyright 2026 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 { useEffect, useRef, useState } from 'react';
+import { useSearch } from '@backstage/plugin-search-react';
+import { CompoundEntityRef } from '@backstage/catalog-model';
+import { ResultHighlight } from '@backstage/plugin-search-common';
+
+export type TechDocsDoc = {
+ namespace: string;
+ kind: string;
+ name: string;
+ path: string;
+ location: string;
+ title: string;
+ text: string;
+};
+
+export type TechDocsSearchResult = {
+ type: string;
+ document: TechDocsDoc;
+ highlight?: ResultHighlight;
+};
+
+/**
+ * Shared hook for TechDocs search logic.
+ * Encapsulates entity filter sync, results slicing,
+ * and deferred loading state.
+ */
+export function useTechDocsSearch(entityId: CompoundEntityRef) {
+ const {
+ setFilters,
+ setTerm,
+ term,
+ result: { loading, value: searchVal },
+ } = useSearch();
+ const [results, setResults] = useState([]);
+ const [deferredLoading, setDeferredLoading] = useState(false);
+ const loadingTimer = useRef>();
+
+ useEffect(() => {
+ if (searchVal) {
+ setResults(searchVal.results.slice(0, 10) as TechDocsSearchResult[]);
+ }
+ }, [loading, searchVal]);
+
+ useEffect(() => {
+ clearTimeout(loadingTimer.current);
+ setDeferredLoading(false);
+ if (loading) {
+ loadingTimer.current = setTimeout(() => setDeferredLoading(true), 200);
+ }
+ return () => clearTimeout(loadingTimer.current);
+ }, [term, loading]);
+
+ const { kind, name, namespace } = entityId;
+ useEffect(() => {
+ setFilters(prevFilters => ({
+ ...prevFilters,
+ kind,
+ namespace,
+ name,
+ }));
+ }, [kind, namespace, name, setFilters]);
+
+ return {
+ results,
+ term,
+ setTerm,
+ loading,
+ deferredLoading,
+ };
+}
diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.test.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.test.tsx
index 77bb3d134f..cc4735ed02 100644
--- a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.test.tsx
+++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.test.tsx
@@ -233,12 +233,13 @@ describe('', () => {
useTechDocsReaderDom.mockReturnValue(mockTechDocsPage);
useReaderState.mockReturnValue({ state: 'cached' });
- window.location.hash = '#emojis';
-
const rendered = await renderInTestApp(
,
+ {
+ routeEntries: ['/#emojis'],
+ },
);
await waitFor(() => {
@@ -248,8 +249,6 @@ describe('', () => {
expect(mockScrollIntoView).toHaveBeenCalled();
expect(document.querySelector).not.toHaveBeenCalledWith('header');
});
-
- window.location.hash = '';
});
it('should render progress bar when content is loading', async () => {
diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.tsx
index fe72b09073..0ee13703c3 100644
--- a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.tsx
+++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.tsx
@@ -14,30 +14,19 @@
* limitations under the License.
*/
-import { useCallback, useEffect } from 'react';
-
import Grid from '@material-ui/core/Grid';
import { makeStyles } from '@material-ui/core/styles';
-import {
- TechDocsShadowDom,
- useShadowDomStylesLoading,
- useShadowRootElements,
- useTechDocsReaderPage,
-} from '@backstage/plugin-techdocs-react';
+import { TechDocsShadowDom } from '@backstage/plugin-techdocs-react';
import { CompoundEntityRef } from '@backstage/catalog-model';
import { Content, Progress } from '@backstage/core-components';
import { TechDocsSearch } from '../../../search';
import { TechDocsStateIndicator } from '../TechDocsStateIndicator';
-import { useTechDocsReaderDom } from './dom';
-import {
- useTechDocsReader,
- withTechDocsReaderProvider,
-} from '../TechDocsReaderProvider';
+import { withTechDocsReaderProvider } from '../TechDocsReaderProvider';
import { TechDocsReaderPageContentAddons } from './TechDocsReaderPageContentAddons';
-import { useApp } from '@backstage/core-plugin-api';
+import { useTechDocsReaderContentData } from '../../../hooks/useTechDocsReaderContentData';
const useStyles = makeStyles({
search: {
@@ -89,52 +78,26 @@ export type TechDocsReaderPageContentProps = {
*/
export const TechDocsReaderPageContent = withTechDocsReaderProvider(
(props: TechDocsReaderPageContentProps) => {
- const { withSearch = true, searchResultUrlMapper, onReady } = props;
+ const { withSearch = true, searchResultUrlMapper } = props;
const classes = useStyles();
const {
- entityMetadata: { value: entityMetadata, loading: entityMetadataLoading },
entityRef,
- setShadowRoot,
- } = useTechDocsReaderPage();
- const { state } = useTechDocsReader();
- const dom = useTechDocsReaderDom(entityRef, props.defaultPath);
- const path = window.location.pathname;
- const hash = window.location.hash;
- const isStyleLoading = useShadowDomStylesLoading(dom);
- const [hashElement] = useShadowRootElements([`[id="${hash.slice(1)}"]`]);
- const app = useApp();
- const { NotFoundErrorPage } = app.getComponents();
+ entityMetadata,
+ dom,
+ handleAppend,
+ isNotFound,
+ isDomReady,
+ showProgress,
+ NotFoundErrorPage,
+ } = useTechDocsReaderContentData({
+ defaultPath: props.defaultPath,
+ onReady: props.onReady,
+ });
- useEffect(() => {
- if (isStyleLoading) return;
+ if (isNotFound) return ;
- if (hash) {
- if (hashElement) {
- hashElement.scrollIntoView();
- }
- } else {
- document?.querySelector('header')?.scrollIntoView();
- }
- }, [path, hash, hashElement, isStyleLoading]);
-
- const handleAppend = useCallback(
- (newShadowRoot: ShadowRoot) => {
- setShadowRoot(newShadowRoot);
- if (onReady instanceof Function) {
- onReady();
- }
- },
- [setShadowRoot, onReady],
- );
-
- // No entity metadata = 404. Don't render content at all.
- if (entityMetadataLoading === false && !entityMetadata)
- return ;
-
- // Do not return content until dom is ready; instead, render a state
- // indicator, which handles progress and content errors on our behalf.
- if (!dom) {
+ if (!isDomReady) {
return (
@@ -162,10 +125,9 @@ export const TechDocsReaderPageContent = withTechDocsReaderProvider(
)}
- {/* Centers the styles loaded event to avoid having multiple locations setting the opacity style in Shadow Dom causing the screen to flash multiple times */}
- {(state === 'CHECKING' || isStyleLoading) && }
+ {showProgress && }
-
+
diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.tsx
index 6328becc73..b646c5bfe8 100644
--- a/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.tsx
+++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.tsx
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-import { PropsWithChildren, useEffect } from 'react';
+import { PropsWithChildren } from 'react';
import Helmet from 'react-helmet';
import Grid from '@material-ui/core/Grid';
@@ -23,29 +23,22 @@ import CodeIcon from '@material-ui/icons/Code';
import {
TechDocsAddonLocations as locations,
- useTechDocsAddons,
- useTechDocsReaderPage,
TechDocsEntityMetadata,
TechDocsMetadata,
} from '@backstage/plugin-techdocs-react';
import {
- entityPresentationApiRef,
EntityRefLink,
EntityRefLinks,
getEntityRelations,
} from '@backstage/plugin-catalog-react';
-import {
- RELATION_OWNED_BY,
- CompoundEntityRef,
- stringifyEntityRef,
-} from '@backstage/catalog-model';
+import { RELATION_OWNED_BY, CompoundEntityRef } from '@backstage/catalog-model';
import { Header, HeaderLabel } from '@backstage/core-components';
-import { useRouteRef, configApiRef, useApi } from '@backstage/core-plugin-api';
+import { useRouteRef } from '@backstage/core-plugin-api';
import capitalize from 'lodash/capitalize';
import { rootRouteRef } from '../../../routes';
-import { useParams } from 'react-router-dom';
+import { useTechDocsReaderHeaderData } from '../../../hooks/useTechDocsReaderHeaderData';
const skeleton = ;
@@ -71,45 +64,29 @@ export const TechDocsReaderPageHeader = (
props: TechDocsReaderPageHeaderProps,
) => {
const { children } = props;
- const addons = useTechDocsAddons();
- const configApi = useApi(configApiRef);
-
- const entityPresentationApi = useApi(entityPresentationApiRef);
- const { '*': path = '' } = useParams();
-
const {
title,
- setTitle,
subtitle,
- setSubtitle,
entityRef,
- metadata: { value: metadata, loading: metadataLoading },
- entityMetadata: { value: entityMetadata, loading: entityMetadataLoading },
- } = useTechDocsReaderPage();
+ entityMetadata,
+ tabTitle,
+ hidden,
+ showSourceLink,
+ sourceLink,
+ addons,
+ } = useTechDocsReaderHeaderData();
- useEffect(() => {
- if (!metadata) return;
- setTitle(metadata.site_name);
- setSubtitle(() => {
- let { site_description } = metadata;
- if (!site_description || site_description === 'None') {
- site_description = '';
- }
- return site_description;
- });
- }, [metadata, setTitle, setSubtitle]);
+ const docsRootLink = useRouteRef(rootRouteRef)();
- const appTitle = configApi.getOptional('app.title') || 'Backstage';
+ if (hidden) return null;
- const { locationMetadata, spec } = entityMetadata || {};
+ const { spec } = entityMetadata || {};
const lifecycle = spec?.lifecycle;
const ownedByRelations = entityMetadata
? getEntityRelations(entityMetadata, RELATION_OWNED_BY)
: [];
- const docsRootLink = useRouteRef(rootRouteRef)();
-
const labels = (
<>
) : null}
- {locationMetadata &&
- locationMetadata.type !== 'dir' &&
- locationMetadata.type !== 'file' ? (
+ {showSourceLink ? (
}
- url={locationMetadata.target}
+ url={sourceLink}
/>
) : null}
>
);
- // If there is no entity or techdocs metadata, there's no reason to show the
- // header (hides the header on 404 error pages).
- const noEntMetadata = !entityMetadataLoading && entityMetadata === undefined;
- const noTdMetadata = !metadataLoading && metadata === undefined;
- if (noEntMetadata || noTdMetadata) return null;
-
- const stringEntityRef = stringifyEntityRef(entityRef);
-
- const entityDisplayName =
- entityPresentationApi.forEntity(stringEntityRef).snapshot.primaryTitle;
-
- const removeTrailingSlash = (str: string) => str.replace(/\/$/, '');
- const normalizeAndSpace = (str: string) =>
- str.replace(/[-_]/g, ' ').split(' ').map(capitalize).join(' ');
-
- let techdocsTabTitleItems: string[] = [];
-
- if (path !== '')
- techdocsTabTitleItems = removeTrailingSlash(path)
- .split('/')
- .map(normalizeAndSpace);
-
- const tabTitleItems = [entityDisplayName, ...techdocsTabTitleItems, appTitle];
- const tabTitle = tabTitleItems.join(' | ');
-
return (
string;
};
-type TechDocsDoc = {
- namespace: string;
- kind: string;
- name: string;
- path: string;
- location: string;
- title: string;
-};
-
-type TechDocsSearchResult = {
- type: string;
- document: TechDocsDoc;
- highlight?: ResultHighlight;
-};
-
const isTechDocsSearchResult = (
option: any,
): option is TechDocsSearchResult => {
@@ -67,40 +54,7 @@ const TechDocsSearchBar = (props: TechDocsSearchProps) => {
} = props;
const [open, setOpen] = useState(false);
const navigate = useNavigate();
- const {
- setFilters,
- term,
- result: { loading, value: searchVal },
- } = useSearch();
- const [options, setOptions] = useState([]);
- useEffect(() => {
- let mounted = true;
-
- if (mounted && searchVal) {
- // TODO: Change this into getting only subset of search results from the BE in the first place
- // once pagination is implemented for search engines
- // See: https://github.com/backstage/backstage/issues/6062
- const searchResults = searchVal.results.slice(0, 10);
- setOptions(searchResults);
- }
- return () => {
- mounted = false;
- };
- }, [loading, searchVal]);
-
- // Update the filter context when the entityId changes, e.g. when the search
- // bar continues to be rendered, navigating between different TechDocs sites.
- const { kind, name, namespace } = entityId;
- useEffect(() => {
- setFilters(prevFilters => {
- return {
- ...prevFilters,
- kind,
- namespace,
- name,
- };
- });
- }, [kind, namespace, name, setFilters]);
+ const { results, term, loading } = useTechDocsSearch(entityId);
const handleSelection = (
_: any,
@@ -133,7 +87,7 @@ const TechDocsSearchBar = (props: TechDocsSearchProps) => {
blurOnSelect
noOptionsText="No results found"
value={null}
- options={options}
+ options={results}
renderOption={({ document, highlight }) => (