Merge pull request #29760 from csuich2/techdocs-entity-path

feat: add techdocs-entity-path annotation for techdocs deep linking
This commit is contained in:
Fredrik Adelöw
2025-05-27 14:46:39 +02:00
committed by GitHub
18 changed files with 294 additions and 38 deletions
+8
View File
@@ -0,0 +1,8 @@
---
'@backstage/plugin-techdocs': minor
'@backstage/plugin-techdocs-react': minor
'@backstage/plugin-catalog': minor
'@backstage/plugin-techdocs-common': patch
---
Introduced `backstage.io/techdocs-entity-path` annotation which allows deep linking into another entities TechDocs in conjunction with `backstage.io/techdocs-entity`.
@@ -115,6 +115,20 @@ the TechDocs in the TechDocs page or needing multiple builds of the same docs.
This is for situations where you have complex systems where they share a single repo, and likely a single TechDoc location.
### backstage.io/techdocs-entity-path
```yaml
# Example:
metadata:
annotations:
backstage.io/techdocs-entity: component:default/example
backstage.io/techdocs-entity-path: /path/to/this/component
```
The value of this annotation informs of the path to this component's TechDocs within an external entity that owns the TechDocs.
In conjunction with [backstage.io/techdocs-entity](#backstageiotechdocs-entity) this allows for deep linking into the TechDocs of
another entity, not just linking to the root of another entities TechDocs.
### backstage.io/view-url, backstage.io/edit-url
```yaml
+29
View File
@@ -942,6 +942,35 @@ metadata:
backstage.io/techdocs-entity: system:default/example
```
### Deep linking into TechDocs
The `backstage.io/techdocs-entity-path` annotation can be use to deep link into a specific page within the components TechDocs.
This can be used in conjunction with `backstage.io/techdocs-entity` or standalone.
```yaml
apiVersion: backstage.io/v1alpha1
kind: System
metadata:
name: example
namespace: default
title: Example
description: This is the parent entity
annotations:
backstage.io/techdocs-ref: dir:.
---
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: example-platfrom
title: Example Application Platform
namespace: default
description: This is the child entity
annotations:
backstage.io/techdocs-entity: system:default/example
backstage.io/techdocs-entity-path: /path/to/component/docs
```
## How to resolve broken links from moved or renamed pages in your documentation site
TechDocs supports using the [mkdocs-redirects](https://github.com/mkdocs/mkdocs-redirects/tree/master) plugin to create a redirect map for any TechDocs site. This allows broken links from renamed or moved pages in your site to be redirected to their specified replacement.
+2
View File
@@ -72,6 +72,8 @@
"@backstage/plugin-scaffolder-common": "workspace:^",
"@backstage/plugin-search-common": "workspace:^",
"@backstage/plugin-search-react": "workspace:^",
"@backstage/plugin-techdocs-common": "workspace:^",
"@backstage/plugin-techdocs-react": "workspace:^",
"@backstage/types": "workspace:^",
"@backstage/version-bridge": "workspace:^",
"@material-ui/core": "^4.12.2",
@@ -589,6 +589,64 @@ describe('<AboutCard />', () => {
);
});
it('renders techdocs link to specific path', async () => {
const entity = {
apiVersion: 'v1',
kind: 'Component',
metadata: {
name: 'software',
annotations: {
'backstage.io/techdocs-entity': 'system:default/example',
'backstage.io/techdocs-entity-path': '/path/to/component',
},
},
spec: {
owner: 'guest',
type: 'service',
lifecycle: 'production',
},
};
await renderInTestApp(
<TestApiProvider
apis={[
[
scmIntegrationsApiRef,
ScmIntegrationsApi.fromConfig(
new ConfigReader({
integrations: {
github: [
{
host: 'github.com',
token: '...',
},
],
},
}),
),
],
[catalogApiRef, catalogApi],
[permissionApiRef, {}],
]}
>
<EntityProvider entity={entity}>
<AboutCard />
</EntityProvider>
</TestApiProvider>,
{
mountedRoutes: {
'/docs/:namespace/:kind/:name': viewTechDocRouteRef,
'/catalog/:namespace/:kind/:name': entityRouteRef,
},
},
);
expect(screen.getByText('View TechDocs').closest('a')).toHaveAttribute(
'href',
'/docs/default/system/example/path/to/component',
);
});
it('renders techdocs link', async () => {
const entity = {
apiVersion: 'v1',
@@ -16,10 +16,8 @@
import {
ANNOTATION_EDIT_URL,
ANNOTATION_LOCATION,
CompoundEntityRef,
DEFAULT_NAMESPACE,
stringifyEntityRef,
parseEntityRef,
} from '@backstage/catalog-model';
import Card from '@material-ui/core/Card';
import CardContent from '@material-ui/core/CardContent';
@@ -66,10 +64,11 @@ import { taskCreatePermission } from '@backstage/plugin-scaffolder-common/alpha'
import { usePermission } from '@backstage/plugin-permission-react';
import { catalogTranslationRef } from '../../alpha/translation';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
const TECHDOCS_ANNOTATION = 'backstage.io/techdocs-ref';
const TECHDOCS_EXTERNAL_ANNOTATION = 'backstage.io/techdocs-entity';
import { buildTechDocsURL } from '@backstage/plugin-techdocs-react';
import {
TECHDOCS_ANNOTATION,
TECHDOCS_EXTERNAL_ANNOTATION,
} from '@backstage/plugin-techdocs-common';
const useStyles = makeStyles({
gridItemCard: {
@@ -135,19 +134,6 @@ export function AboutCard(props: AboutCardProps) {
const entityMetadataEditUrl =
entity.metadata.annotations?.[ANNOTATION_EDIT_URL];
let techdocsRef: CompoundEntityRef | undefined;
if (entity.metadata.annotations?.[TECHDOCS_EXTERNAL_ANNOTATION]) {
try {
techdocsRef = parseEntityRef(
entity.metadata.annotations?.[TECHDOCS_EXTERNAL_ANNOTATION],
);
// not a fan of this but we don't care if the parseEntityRef fails
} catch {
techdocsRef = undefined;
}
}
const viewInSource: IconLinkVerticalProps = {
label: t('aboutCard.viewSource'),
disabled: !entitySourceLocation,
@@ -162,19 +148,7 @@ export function AboutCard(props: AboutCardProps) {
entity.metadata.annotations?.[TECHDOCS_EXTERNAL_ANNOTATION]
) || !viewTechdocLink,
icon: <DocsIcon />,
href:
viewTechdocLink &&
(techdocsRef
? viewTechdocLink({
namespace: techdocsRef.namespace || DEFAULT_NAMESPACE,
kind: techdocsRef.kind,
name: techdocsRef.name,
})
: viewTechdocLink({
namespace: entity.metadata.namespace || DEFAULT_NAMESPACE,
kind: entity.kind,
name: entity.metadata.name,
})),
href: buildTechDocsURL(entity, viewTechdocLink),
};
const subHeaderLinks = [viewInSource, viewInTechDocs];
+4
View File
@@ -8,4 +8,8 @@ export const TECHDOCS_ANNOTATION = 'backstage.io/techdocs-ref';
// @public (undocumented)
export const TECHDOCS_EXTERNAL_ANNOTATION = 'backstage.io/techdocs-entity';
// @public (undocumented)
export const TECHDOCS_EXTERNAL_PATH_ANNOTATION =
'backstage.io/techdocs-entity-path';
```
+3
View File
@@ -18,3 +18,6 @@
export const TECHDOCS_ANNOTATION = 'backstage.io/techdocs-ref';
/** @public */
export const TECHDOCS_EXTERNAL_ANNOTATION = 'backstage.io/techdocs-entity';
/** @public */
export const TECHDOCS_EXTERNAL_PATH_ANNOTATION =
'backstage.io/techdocs-entity-path';
+1
View File
@@ -63,6 +63,7 @@
"@backstage/core-components": "workspace:^",
"@backstage/core-plugin-api": "workspace:^",
"@backstage/frontend-plugin-api": "workspace:^",
"@backstage/plugin-techdocs-common": "workspace:^",
"@backstage/version-bridge": "workspace:^",
"@material-ui/core": "^4.12.2",
"@material-ui/styles": "^4.11.0",
+16
View File
@@ -15,8 +15,21 @@ import { JSX as JSX_2 } from 'react/jsx-runtime';
import { MemoExoticComponent } from 'react';
import { PropsWithChildren } from 'react';
import { ReactNode } from 'react';
import { RouteFunc } from '@backstage/core-plugin-api';
import { SetStateAction } from 'react';
// @public
export const buildTechDocsURL: (
entity: Entity,
routeFunc:
| RouteFunc<{
namespace: string;
kind: string;
name: string;
}>
| undefined,
) => string | undefined;
// @public
export function createTechDocsAddonExtension(
options: TechDocsAddonOptions,
@@ -27,6 +40,9 @@ export function createTechDocsAddonExtension<TComponentProps>(
options: TechDocsAddonOptions<TComponentProps>,
): Extension<(props: TComponentProps) => JSX.Element | null>;
// @public
export function getEntityRootTechDocsPath(entity: Entity): string;
// @public
export const SHADOW_DOM_STYLE_LOAD_EVENT = 'TECH_DOCS_SHADOW_DOM_STYLE_LOAD';
+75 -1
View File
@@ -15,7 +15,17 @@
*/
import { Config } from '@backstage/config';
import { CompoundEntityRef } from '@backstage/catalog-model';
import {
CompoundEntityRef,
Entity,
getCompoundEntityRef,
parseEntityRef,
} from '@backstage/catalog-model';
import {
TECHDOCS_EXTERNAL_ANNOTATION,
TECHDOCS_EXTERNAL_PATH_ANNOTATION,
} from '@backstage/plugin-techdocs-common';
import { RouteFunc } from '@backstage/core-plugin-api';
/**
* Lower-case entity triplets by default, but allow override.
@@ -35,3 +45,67 @@ export function toLowercaseEntityRefMaybe(
return entityRef;
}
/**
* Get the entity path annotation from the given entity and ensure it starts with a slash.
*
* @public
*/
export function getEntityRootTechDocsPath(entity: Entity): string {
let path = entity.metadata.annotations?.[TECHDOCS_EXTERNAL_PATH_ANNOTATION];
if (!path) {
return '';
}
if (!path.startsWith('/')) {
path = `/${path}`;
}
return path;
}
/**
* Build the TechDocs URL for the given entity. This helper should be used anywhere there
* is a link to an entities TechDocs.
*
* @public
*/
export const buildTechDocsURL = (
entity: Entity,
routeFunc:
| RouteFunc<{
namespace: string;
kind: string;
name: string;
}>
| undefined,
) => {
if (!routeFunc) {
return undefined;
}
let { namespace, kind, name } = getCompoundEntityRef(entity);
if (entity.metadata.annotations?.[TECHDOCS_EXTERNAL_ANNOTATION]) {
try {
const techdocsRef = parseEntityRef(
entity.metadata.annotations?.[TECHDOCS_EXTERNAL_ANNOTATION],
);
namespace = techdocsRef.namespace;
kind = techdocsRef.kind;
name = techdocsRef.name;
} catch {
// not a fan of this but we don't care if the parseEntityRef fails
}
}
const url = routeFunc({
namespace,
kind,
name,
});
// Add on the external entity path to the url if one exists. This allows deep linking into another
// entities TechDocs.
const path = getEntityRootTechDocsPath(entity);
return `${url}${path}`;
};
+5 -1
View File
@@ -52,4 +52,8 @@ export {
useShadowRootElements,
useShadowRootSelection,
} from './hooks';
export { toLowercaseEntityRefMaybe } from './helpers';
export {
toLowercaseEntityRefMaybe,
getEntityRootTechDocsPath,
buildTechDocsURL,
} from './helpers';
+1
View File
@@ -431,6 +431,7 @@ export const TechDocsReaderPageContent: (
// @public
export type TechDocsReaderPageContentProps = {
entityRef?: CompoundEntityRef;
defaultPath?: string;
withSearch?: boolean;
searchResultUrlMapper?: (url: string) => string;
onReady?: () => void;
+4
View File
@@ -20,6 +20,7 @@ import {
parseEntityRef,
} from '@backstage/catalog-model';
import { TECHDOCS_EXTERNAL_ANNOTATION } from '@backstage/plugin-techdocs-common';
import { getEntityRootTechDocsPath } from '@backstage/plugin-techdocs-react';
import { TechDocsReaderPage } from './plugin';
import { TechDocsReaderPageContent } from './reader/components/TechDocsReaderPageContent';
@@ -52,12 +53,15 @@ export const EntityPageDocs = ({
}
}
const defaultPath = getEntityRootTechDocsPath(entity);
return (
<TechDocsReaderPage entityRef={entityRef}>
<TechDocsReaderPageSubheader />
<TechDocsReaderPageContent
withSearch={withSearch}
searchResultUrlMapper={searchResultUrlMapper}
defaultPath={defaultPath}
/>
</TechDocsReaderPage>
);
@@ -16,7 +16,10 @@
import { ReactNode } from 'react';
import { waitFor } from '@testing-library/react';
import { CompoundEntityRef } from '@backstage/catalog-model';
import {
CompoundEntityRef,
getCompoundEntityRef,
} from '@backstage/catalog-model';
import {
techdocsApiRef,
TechDocsReaderPageProvider,
@@ -121,6 +124,33 @@ describe('<TechDocsReaderPageContent />', () => {
});
});
it('should render techdocs page content 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(
<Wrapper>
<TechDocsReaderPageContent
withSearch={false}
defaultPath={defaultPath}
/>
</Wrapper>,
);
await waitFor(() => {
expect(
rendered.getByTestId('techdocs-native-shadowroot'),
).toBeInTheDocument();
});
const entityRef = getCompoundEntityRef(mockEntityMetadata);
expect(useTechDocsReaderDom).toHaveBeenCalledWith(entityRef, defaultPath);
});
it('should not render techdocs content if entity metadata is missing', async () => {
getEntityMetadata.mockResolvedValue(undefined);
useTechDocsReaderDom.mockReturnValue(document.createElement('html'));
@@ -61,6 +61,11 @@ export type TechDocsReaderPageContentProps = {
* @deprecated No need to pass down entityRef as property anymore. Consumes the entityName from `TechDocsReaderPageContext`. Use the {@link @backstage/plugin-techdocs-react#useTechDocsReaderPage} hook for custom reader page content.
*/
entityRef?: CompoundEntityRef;
/**
* Path in the docs to render by default. This should be used when rendering docs for an entity that specifies the
* "backstage.io/techdocs-entity-path" annotation for deep linking into another entities docs.
*/
defaultPath?: string;
/**
* Show or hide the search bar, defaults to true.
*/
@@ -93,7 +98,7 @@ export const TechDocsReaderPageContent = withTechDocsReaderProvider(
setShadowRoot,
} = useTechDocsReaderPage();
const { state } = useTechDocsReader();
const dom = useTechDocsReaderDom(entityRef);
const dom = useTechDocsReaderDom(entityRef, props.defaultPath);
const path = window.location.pathname;
const hash = window.location.hash;
const isStyleLoading = useShadowDomStylesLoading(dom);
@@ -14,7 +14,13 @@
* limitations under the License.
*/
import { useCallback, useEffect, useState } from 'react';
import {
useCallback,
useEffect,
useLayoutEffect,
// useRef,
useState,
} from 'react';
import useMediaQuery from '@material-ui/core/useMediaQuery';
import { useTheme } from '@material-ui/core/styles';
@@ -47,10 +53,27 @@ import {
handleMetaRedirects,
} from '../../transformers';
import { useNavigateUrl } from './useNavigateUrl';
import { useParams } from 'react-router-dom';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
const MOBILE_MEDIA_QUERY = 'screen and (max-width: 76.1875em)';
// If a defaultPath is specified then we should navigate to that path replacing the
// current location in the history. This should only happen on the initial load so
// navigating to the root of the docs doesn't also redirect.
const useInitialRedirect = (defaultPath?: string) => {
// const hasRun = useRef(false);
const location = useLocation();
const navigate = useNavigate();
const { '*': currPath = '' } = useParams();
useLayoutEffect(() => {
if (currPath === '' && defaultPath !== '') {
navigate(`${location.pathname}${defaultPath}`, { replace: true });
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
};
/**
* Hook that encapsulates the behavior of getting raw HTML and applying
* transforms to it in order to make it function at a basic level in the
@@ -58,6 +81,7 @@ const MOBILE_MEDIA_QUERY = 'screen and (max-width: 76.1875em)';
*/
export const useTechDocsReaderDom = (
entityRef: CompoundEntityRef,
defaultPath?: string,
): Element | null => {
const navigate = useNavigateUrl();
const theme = useTheme();
@@ -76,6 +100,8 @@ export const useTechDocsReaderDom = (
const [dom, setDom] = useState<HTMLElement | null>(null);
const isStyleLoading = useShadowDomStylesLoading(dom);
useInitialRedirect(defaultPath);
const updateSidebarPositionAndHeight = useCallback(() => {
if (!dom) return;
+3
View File
@@ -6136,6 +6136,8 @@ __metadata:
"@backstage/plugin-scaffolder-common": "workspace:^"
"@backstage/plugin-search-common": "workspace:^"
"@backstage/plugin-search-react": "workspace:^"
"@backstage/plugin-techdocs-common": "workspace:^"
"@backstage/plugin-techdocs-react": "workspace:^"
"@backstage/test-utils": "workspace:^"
"@backstage/types": "workspace:^"
"@backstage/version-bridge": "workspace:^"
@@ -8266,6 +8268,7 @@ __metadata:
"@backstage/core-components": "workspace:^"
"@backstage/core-plugin-api": "workspace:^"
"@backstage/frontend-plugin-api": "workspace:^"
"@backstage/plugin-techdocs-common": "workspace:^"
"@backstage/test-utils": "workspace:^"
"@backstage/theme": "workspace:^"
"@backstage/version-bridge": "workspace:^"