feat: add techdocs-entity-path annotation for techdocs deep linking

This annotation enables specifying a path within another entities
techdocs to use as the root techdocs page.

Signed-off-by: Chris Suich <csuich2@gmail.com>
This commit is contained in:
Chris Suich
2025-04-28 11:15:34 -04:00
committed by Chris Suich
parent d75e96cb34
commit ec7b35d77e
14 changed files with 251 additions and 31 deletions
+4
View File
@@ -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 (
<TechDocsReaderPage entityRef={entityRef}>
<TechDocsReaderPageSubheader />
<TechDocsReaderPageContent
withSearch={withSearch}
searchResultUrlMapper={searchResultUrlMapper}
defaultPath={defaultPath}
/>
</TechDocsReaderPage>
);
+65
View File
@@ -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}`;
};
+3
View File
@@ -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';
@@ -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);
@@ -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<HTMLElement | null>(null);
const isStyleLoading = useShadowDomStylesLoading(dom);
useInitialRedirect(defaultPath);
const updateSidebarPositionAndHeight = useCallback(() => {
if (!dom) return;