diff --git a/.changeset/smooth-eels-think.md b/.changeset/smooth-eels-think.md
new file mode 100644
index 0000000000..c5e2fe0c85
--- /dev/null
+++ b/.changeset/smooth-eels-think.md
@@ -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`.
diff --git a/docs/features/software-catalog/well-known-annotations.md b/docs/features/software-catalog/well-known-annotations.md
index 974fa80993..42cefd3adb 100644
--- a/docs/features/software-catalog/well-known-annotations.md
+++ b/docs/features/software-catalog/well-known-annotations.md
@@ -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
diff --git a/docs/features/techdocs/how-to-guides.md b/docs/features/techdocs/how-to-guides.md
index 8a95c86533..7d8c99ffda 100644
--- a/docs/features/techdocs/how-to-guides.md
+++ b/docs/features/techdocs/how-to-guides.md
@@ -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.
diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json
index 5ef3748e38..ac500b037d 100644
--- a/plugins/catalog/package.json
+++ b/plugins/catalog/package.json
@@ -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",
diff --git a/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx b/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx
index 0255339238..f8bebcf037 100644
--- a/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx
+++ b/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx
@@ -589,6 +589,64 @@ describe('', () => {
);
});
+ 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(
+
+
+
+
+ ,
+ {
+ 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',
diff --git a/plugins/catalog/src/components/AboutCard/AboutCard.tsx b/plugins/catalog/src/components/AboutCard/AboutCard.tsx
index bf5be95a08..88933b2a66 100644
--- a/plugins/catalog/src/components/AboutCard/AboutCard.tsx
+++ b/plugins/catalog/src/components/AboutCard/AboutCard.tsx
@@ -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: ,
- 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];
diff --git a/plugins/techdocs-common/report.api.md b/plugins/techdocs-common/report.api.md
index c7e1829a28..56a86778a8 100644
--- a/plugins/techdocs-common/report.api.md
+++ b/plugins/techdocs-common/report.api.md
@@ -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';
```
diff --git a/plugins/techdocs-common/src/constants.ts b/plugins/techdocs-common/src/constants.ts
index 40187f41ce..a31951204e 100644
--- a/plugins/techdocs-common/src/constants.ts
+++ b/plugins/techdocs-common/src/constants.ts
@@ -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';
diff --git a/plugins/techdocs-react/package.json b/plugins/techdocs-react/package.json
index b1be611d68..e41a231f9b 100644
--- a/plugins/techdocs-react/package.json
+++ b/plugins/techdocs-react/package.json
@@ -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",
diff --git a/plugins/techdocs-react/report.api.md b/plugins/techdocs-react/report.api.md
index 23a9c9faaf..62ac6375fa 100644
--- a/plugins/techdocs-react/report.api.md
+++ b/plugins/techdocs-react/report.api.md
@@ -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(
options: TechDocsAddonOptions,
): 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';
diff --git a/plugins/techdocs-react/src/helpers.ts b/plugins/techdocs-react/src/helpers.ts
index 3f10fff2ba..e6daf99b26 100644
--- a/plugins/techdocs-react/src/helpers.ts
+++ b/plugins/techdocs-react/src/helpers.ts
@@ -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}`;
+};
diff --git a/plugins/techdocs-react/src/index.ts b/plugins/techdocs-react/src/index.ts
index 5cbab1a326..7c4f316c1e 100644
--- a/plugins/techdocs-react/src/index.ts
+++ b/plugins/techdocs-react/src/index.ts
@@ -52,4 +52,8 @@ export {
useShadowRootElements,
useShadowRootSelection,
} from './hooks';
-export { toLowercaseEntityRefMaybe } from './helpers';
+export {
+ toLowercaseEntityRefMaybe,
+ getEntityRootTechDocsPath,
+ buildTechDocsURL,
+} from './helpers';
diff --git a/plugins/techdocs/report.api.md b/plugins/techdocs/report.api.md
index bb7ff9e5a5..2369466674 100644
--- a/plugins/techdocs/report.api.md
+++ b/plugins/techdocs/report.api.md
@@ -431,6 +431,7 @@ export const TechDocsReaderPageContent: (
// @public
export type TechDocsReaderPageContentProps = {
entityRef?: CompoundEntityRef;
+ defaultPath?: string;
withSearch?: boolean;
searchResultUrlMapper?: (url: string) => string;
onReady?: () => void;
diff --git a/plugins/techdocs/src/EntityPageDocs.tsx b/plugins/techdocs/src/EntityPageDocs.tsx
index 68c26cd1c3..14dcb00d29 100644
--- a/plugins/techdocs/src/EntityPageDocs.tsx
+++ b/plugins/techdocs/src/EntityPageDocs.tsx
@@ -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 (
);
diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.test.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.test.tsx
index a8e7ae7287..77bb3d134f 100644
--- a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.test.tsx
+++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.test.tsx
@@ -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('', () => {
});
});
+ 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(
+
+
+ ,
+ );
+
+ 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'));
diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.tsx
index 1db97a82b8..fe72b09073 100644
--- a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.tsx
+++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.tsx
@@ -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);
diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/dom.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/dom.tsx
index 9dbb9745d3..10143aa99c 100644
--- a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/dom.tsx
+++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/dom.tsx
@@ -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(null);
const isStyleLoading = useShadowDomStylesLoading(dom);
+ useInitialRedirect(defaultPath);
+
const updateSidebarPositionAndHeight = useCallback(() => {
if (!dom) return;
diff --git a/yarn.lock b/yarn.lock
index 5276e3d24a..f3e61fd4ad 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -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:^"