diff --git a/.changeset/smooth-eels-think.md b/.changeset/smooth-eels-think.md
new file mode 100644
index 0000000000..33e711045c
--- /dev/null
+++ b/.changeset/smooth-eels-think.md
@@ -0,0 +1,7 @@
+---
+'@backstage/plugin-techdocs': 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..4e2801197f 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 components 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..2ebda55de2 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 another components TechDocs
+
+In addition to linking to another component's TechDocs the `backstage.io/techdocs-entity-path` annotation can be used to link to a
+specific page within another component's TechDocs.
+
+```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 b046eea88d..d9124f0077 100644
--- a/plugins/catalog/package.json
+++ b/plugins/catalog/package.json
@@ -72,6 +72,7 @@
"@backstage/plugin-scaffolder-common": "workspace:^",
"@backstage/plugin-search-common": "workspace:^",
"@backstage/plugin-search-react": "workspace:^",
+ "@backstage/plugin-techdocs": "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..4d8421932b 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,6 +64,7 @@ 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';
+import { buildTechDocsURL } from '@backstage/plugin-techdocs';
const TECHDOCS_ANNOTATION = 'backstage.io/techdocs-ref';
@@ -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/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/src/EntityPageDocs.tsx b/plugins/techdocs/src/EntityPageDocs.tsx
index 68c26cd1c3..a88ec8275d 100644
--- a/plugins/techdocs/src/EntityPageDocs.tsx
+++ b/plugins/techdocs/src/EntityPageDocs.tsx
@@ -25,6 +25,7 @@ import { TechDocsReaderPage } from './plugin';
import { TechDocsReaderPageContent } from './reader/components/TechDocsReaderPageContent';
import { TechDocsReaderPageSubheader } from './reader/components/TechDocsReaderPageSubheader';
import { useEntityPageTechDocsRedirect } from './search/hooks/useTechDocsLocation';
+import { getEntityRootTechDocsPath } from './helpers';
type EntityPageDocsProps = {
entity: Entity;
@@ -52,12 +53,15 @@ export const EntityPageDocs = ({
}
}
+ const defaultPath = getEntityRootTechDocsPath(entity);
+
return (
);
diff --git a/plugins/techdocs/src/helpers.ts b/plugins/techdocs/src/helpers.ts
index 7ff4dec6e7..4b525445d7 100644
--- a/plugins/techdocs/src/helpers.ts
+++ b/plugins/techdocs/src/helpers.ts
@@ -14,7 +14,23 @@
* limitations under the License.
*/
+import {
+ DEFAULT_NAMESPACE,
+ Entity,
+ parseEntityRef,
+} from '@backstage/catalog-model';
import { Config } from '@backstage/config';
+import {
+ TECHDOCS_EXTERNAL_ANNOTATION,
+ TECHDOCS_EXTERNAL_PATH_ANNOTATION,
+} from '@backstage/plugin-techdocs-common';
+import { RouteFunc } from '@backstage/core-plugin-api';
+
+export type TechDocsRouteFunc = RouteFunc<{
+ namespace: string;
+ kind: string;
+ name: string;
+}>;
// Lower-case entity triplets by default, but allow override.
export function toLowerMaybe(str: string, config: Config) {
@@ -24,3 +40,52 @@ export function toLowerMaybe(str: string, config: Config) {
? str
: str.toLocaleLowerCase('en-US');
}
+
+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;
+}
+
+export const buildTechDocsURL = (
+ entity: Entity,
+ routeFunc: TechDocsRouteFunc | undefined,
+) => {
+ if (!routeFunc) {
+ return undefined;
+ }
+
+ let namespace = entity.metadata.namespace || DEFAULT_NAMESPACE;
+ let kind = entity.kind;
+ let name = entity.metadata.name;
+
+ 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/src/index.ts b/plugins/techdocs/src/index.ts
index 713efc17c6..e7cefa6437 100644
--- a/plugins/techdocs/src/index.ts
+++ b/plugins/techdocs/src/index.ts
@@ -46,6 +46,7 @@ export {
LegacyEmbeddedDocsRouter as EmbeddedDocsRouter,
Router,
} from './Router';
+export { buildTechDocsURL, getEntityRootTechDocsPath } from './helpers';
export type { TechDocsSearchResultListItemProps } from './search/components/TechDocsSearchResultListItem';
@@ -69,3 +70,5 @@ export type {
};
export * from './overridableComponents';
+
+export type { TechDocsRouteFunc } from './helpers';
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 c17d5fcac4..f441d1b472 100644
--- a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/dom.tsx
+++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/dom.tsx
@@ -47,10 +47,33 @@ 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, setHasRun] = useState(false);
+
+ const location = useLocation();
+ const navigate = useNavigate();
+ const { '*': currPath = '' } = useParams();
+
+ useEffect(() => {
+ // Only run once
+ if (hasRun) {
+ return;
+ }
+ setHasRun(true);
+
+ if (currPath === '' && defaultPath !== '') {
+ navigate(`${location.pathname}${defaultPath}`, { replace: true });
+ }
+ }, [hasRun, currPath, defaultPath, location, navigate]);
+};
+
/**
* 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 a8f9207723..7e61c3ca48 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -6355,6 +6355,7 @@ __metadata:
"@backstage/plugin-scaffolder-common": "workspace:^"
"@backstage/plugin-search-common": "workspace:^"
"@backstage/plugin-search-react": "workspace:^"
+ "@backstage/plugin-techdocs": "workspace:^"
"@backstage/test-utils": "workspace:^"
"@backstage/types": "workspace:^"
"@backstage/version-bridge": "workspace:^"