diff --git a/.changeset/spotty-ducks-exercise.md b/.changeset/spotty-ducks-exercise.md
new file mode 100644
index 0000000000..c04d312714
--- /dev/null
+++ b/.changeset/spotty-ducks-exercise.md
@@ -0,0 +1,5 @@
+---
+'@backstage/integration': patch
+---
+
+Exported `replaceGitLabUrlType` from package
diff --git a/.changeset/techdocs-hold-me-closer.md b/.changeset/techdocs-hold-me-closer.md
new file mode 100644
index 0000000000..544ba41cd8
--- /dev/null
+++ b/.changeset/techdocs-hold-me-closer.md
@@ -0,0 +1,9 @@
+---
+'@backstage/plugin-techdocs-react': minor
+---
+
+This package will house frontend utilities related to TechDocs to be shared across other frontend Backstage packages.
+
+In this release, it introduces a framework that can be used create TechDocs addons.
+
+Note: this package is not necessarily stable yet. After iteration on this package, its stability will be signaled by a major-version bump.
diff --git a/.changeset/techdocs-until-you-puke.md b/.changeset/techdocs-until-you-puke.md
new file mode 100644
index 0000000000..97d39a9095
--- /dev/null
+++ b/.changeset/techdocs-until-you-puke.md
@@ -0,0 +1,56 @@
+---
+'@backstage/plugin-techdocs': minor
+---
+
+TechDocs supports a new, experimental method of customization: addons!
+
+To customize the standalone TechDocs reader page experience, update your `/packages/app/src/App.tsx` in the following way:
+
+```diff
+import { TechDocsIndexPage, TechDocsReaderPage } from '@backstage/plugin-techdocs';
++ import { TechDocsAddons } from '@backstage/plugin-techdocs-react';
++ import { SomeAddon } from '@backstage/plugin-some-plugin';
+
+// ...
+
+ } />
+ }
+ >
++
++
++
+
+
+// ...
+```
+
+To customize the TechDocs reader experience on the Catalog entity page, update your `packages/app/src/components/catalog/EntityPage.tsx` in the following way:
+
+```diff
+import { EntityTechdocsContent } from '@backstage/plugin-techdocs';
++ import { TechDocsAddons } from '@backstage/plugin-techdocs-react';
++ import { SomeAddon } from '@backstage/plugin-some-plugin';
+
+// ...
+
+
+
+ {overviewContent}
+
+
+
+-
++
++
++
++
++
+
+
+
+// ...
+```
+
+If you do not wish to customize your TechDocs reader experience in this way at this time, no changes are necessary!
diff --git a/cypress/fixtures/mkdocs.yml b/cypress/fixtures/mkdocs.yml
index 2362fab898..071d147509 100644
--- a/cypress/fixtures/mkdocs.yml
+++ b/cypress/fixtures/mkdocs.yml
@@ -1,5 +1,8 @@
site_name: e2e Fixture Documentation
site_description: Documentation used for end-to-end tests of TechDocs in Backstage.
+repo_url: https://github.com/backstage/backstage
+edit_uri: edit/master/cypress/fixtures/docs
+
nav:
- Home: index.md
- Sub-page 1: sub-page-one.md
diff --git a/mkdocs.yml b/mkdocs.yml
index 204a4768c3..5c73516ba6 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -1,5 +1,7 @@
site_name: 'Backstage'
site_description: 'Main documentation for Backstage features and platform APIs'
+repo_url: https://github.com/backstage/backstage
+edit_uri: edit/master/docs
plugins:
- techdocs-core
diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx
index 9cbbcc86a9..ac0b9daa52 100644
--- a/packages/app/src/App.tsx
+++ b/packages/app/src/App.tsx
@@ -66,8 +66,8 @@ import { SearchPage } from '@backstage/plugin-search';
import { TechRadarPage } from '@backstage/plugin-tech-radar';
import {
TechDocsIndexPage,
- techdocsPlugin,
TechDocsReaderPage,
+ techdocsPlugin,
} from '@backstage/plugin-techdocs';
import {
UserSettingsPage,
@@ -87,6 +87,7 @@ import { defaultPreviewTemplate } from './components/scaffolder/defaultPreviewTe
import { searchPage } from './components/search/SearchPage';
import { providers } from './identityProviders';
import * as plugins from './plugins';
+
import { techDocsPage } from './components/techdocs/TechDocsPage';
import { ApacheAirflowPage } from '@backstage/plugin-apache-airflow';
import { PermissionedRoute } from '@backstage/plugin-permission-react';
diff --git a/packages/app/src/components/techdocs/TechDocsPage.tsx b/packages/app/src/components/techdocs/TechDocsPage.tsx
index 4f49cf38e8..48a9812160 100644
--- a/packages/app/src/components/techdocs/TechDocsPage.tsx
+++ b/packages/app/src/components/techdocs/TechDocsPage.tsx
@@ -14,29 +14,20 @@
* limitations under the License.
*/
-import { Content } from '@backstage/core-components';
import {
- TechDocsReaderPageHeader,
TechDocsReaderPage,
- Reader,
+ TechDocsReaderPageHeader,
+ TechDocsReaderPageSubheader,
+ TechDocsReaderPageContent,
} from '@backstage/plugin-techdocs';
import React from 'react';
const DefaultTechDocsPage = () => {
return (
- {({ techdocsMetadataValue, entityMetadataValue, entityRef, onReady }) => (
- <>
-
-
-
-
- >
- )}
+
+
+
);
};
diff --git a/packages/integration/api-report.md b/packages/integration/api-report.md
index cdb260f089..190278c99f 100644
--- a/packages/integration/api-report.md
+++ b/packages/integration/api-report.md
@@ -416,6 +416,12 @@ export function replaceGitHubUrlType(
type: 'blob' | 'tree' | 'edit',
): string;
+// @public
+export function replaceGitLabUrlType(
+ url: string,
+ type: 'blob' | 'tree' | 'edit',
+): string;
+
// @public
export interface ScmIntegration {
resolveEditUrl(url: string): string;
diff --git a/packages/integration/src/gitlab/GitLabIntegration.test.ts b/packages/integration/src/gitlab/GitLabIntegration.test.ts
index 01e42e0984..575ce75308 100644
--- a/packages/integration/src/gitlab/GitLabIntegration.test.ts
+++ b/packages/integration/src/gitlab/GitLabIntegration.test.ts
@@ -15,7 +15,7 @@
*/
import { ConfigReader } from '@backstage/config';
-import { GitLabIntegration, replaceUrlType } from './GitLabIntegration';
+import { GitLabIntegration, replaceGitLabUrlType } from './GitLabIntegration';
describe('GitLabIntegration', () => {
it('has a working factory', () => {
@@ -55,28 +55,28 @@ describe('GitLabIntegration', () => {
});
});
-describe('replaceUrlType', () => {
+describe('replaceGitLabUrlType', () => {
it('should replace with expected type', () => {
expect(
- replaceUrlType(
+ replaceGitLabUrlType(
'https://gitlab.com/my-org/my-project/-/blob/develop/README.md',
'edit',
),
).toBe('https://gitlab.com/my-org/my-project/-/edit/develop/README.md');
expect(
- replaceUrlType(
+ replaceGitLabUrlType(
'https://gitlab.com/webmodules/blob/-/blob/develop/test',
'tree',
),
).toBe('https://gitlab.com/webmodules/blob/-/tree/develop/test');
expect(
- replaceUrlType(
+ replaceGitLabUrlType(
'https://gitlab.com/blob/blob/-/blob/develop/test',
'tree',
),
).toBe('https://gitlab.com/blob/blob/-/tree/develop/test');
expect(
- replaceUrlType(
+ replaceGitLabUrlType(
'https://gitlab.com/blob/blob/-/edit/develop/README.md',
'tree',
),
diff --git a/packages/integration/src/gitlab/GitLabIntegration.ts b/packages/integration/src/gitlab/GitLabIntegration.ts
index cb24829946..0c52799599 100644
--- a/packages/integration/src/gitlab/GitLabIntegration.ts
+++ b/packages/integration/src/gitlab/GitLabIntegration.ts
@@ -60,11 +60,18 @@ export class GitLabIntegration implements ScmIntegration {
}
resolveEditUrl(url: string): string {
- return replaceUrlType(url, 'edit');
+ return replaceGitLabUrlType(url, 'edit');
}
}
-export function replaceUrlType(
+/**
+ * Takes a GitLab URL and replaces the type part (blob, tree etc).
+ *
+ * @param url - The original URL
+ * @param type - The desired type, e.g. 'blob', 'tree', 'edit'
+ * @public
+ */
+export function replaceGitLabUrlType(
url: string,
type: 'blob' | 'tree' | 'edit',
): string {
diff --git a/packages/integration/src/gitlab/index.ts b/packages/integration/src/gitlab/index.ts
index 950205d61c..e8d6665a6f 100644
--- a/packages/integration/src/gitlab/index.ts
+++ b/packages/integration/src/gitlab/index.ts
@@ -20,4 +20,4 @@ export {
} from './config';
export type { GitLabIntegrationConfig } from './config';
export { getGitLabFileFetchUrl, getGitLabRequestOptions } from './core';
-export { GitLabIntegration } from './GitLabIntegration';
+export { GitLabIntegration, replaceGitLabUrlType } from './GitLabIntegration';
diff --git a/packages/techdocs-cli-embedded-app/package.json b/packages/techdocs-cli-embedded-app/package.json
index 8771d5c14b..e4db841955 100644
--- a/packages/techdocs-cli-embedded-app/package.json
+++ b/packages/techdocs-cli-embedded-app/package.json
@@ -17,6 +17,7 @@
"@backstage/integration-react": "^1.0.1-next.1",
"@backstage/plugin-catalog": "^1.1.0-next.1",
"@backstage/plugin-techdocs": "^1.0.1-next.1",
+ "@backstage/plugin-techdocs-react": "^0.0.0",
"@backstage/test-utils": "^1.0.1-next.1",
"@backstage/theme": "^0.2.15",
"@material-ui/core": "^4.11.0",
diff --git a/packages/techdocs-cli-embedded-app/src/App.tsx b/packages/techdocs-cli-embedded-app/src/App.tsx
index 51bdfbbb11..194d26943d 100644
--- a/packages/techdocs-cli-embedded-app/src/App.tsx
+++ b/packages/techdocs-cli-embedded-app/src/App.tsx
@@ -16,20 +16,27 @@
import React from 'react';
import { Navigate, Route } from 'react-router';
-import { createApp } from '@backstage/app-defaults';
-import { FlatRoutes } from '@backstage/core-app-api';
-import { CatalogEntityPage } from '@backstage/plugin-catalog';
import {
DefaultTechDocsHome,
TechDocsIndexPage,
TechDocsReaderPage,
+ techdocsPlugin,
} from '@backstage/plugin-techdocs';
+import {
+ createTechDocsAddonExtension,
+ TechDocsAddons,
+ TechDocsAddonLocations,
+} from '@backstage/plugin-techdocs-react';
+import { createApp } from '@backstage/app-defaults';
+import { FlatRoutes } from '@backstage/core-app-api';
+import { CatalogEntityPage } from '@backstage/plugin-catalog';
+
import { apis } from './apis';
-import { Root } from './components/Root';
-import { techDocsPage } from './components/TechDocsPage';
import * as plugins from './plugins';
import { configLoader } from './config';
+import { Root } from './components/Root';
+import { techDocsPage, TechDocsThemeToggle } from './components/TechDocsPage';
const app = createApp({
apis,
@@ -40,6 +47,14 @@ const app = createApp({
const AppProvider = app.getProvider();
const AppRouter = app.getRouter();
+const ThemeToggleAddon = techdocsPlugin.provide(
+ createTechDocsAddonExtension({
+ name: 'ThemeToggleAddon',
+ component: TechDocsThemeToggle,
+ location: TechDocsAddonLocations.Header,
+ }),
+);
+
const routes = (
@@ -56,6 +71,9 @@ const routes = (
element={}
>
{techDocsPage}
+
+
+
);
diff --git a/packages/techdocs-cli-embedded-app/src/components/TechDocsPage/TechDocsPage.tsx b/packages/techdocs-cli-embedded-app/src/components/TechDocsPage/TechDocsPage.tsx
index 56686dd081..c7328a0487 100644
--- a/packages/techdocs-cli-embedded-app/src/components/TechDocsPage/TechDocsPage.tsx
+++ b/packages/techdocs-cli-embedded-app/src/components/TechDocsPage/TechDocsPage.tsx
@@ -14,29 +14,20 @@
* limitations under the License.
*/
-import React, {
- FC,
- createContext,
- useContext,
- useState,
- useCallback,
-} from 'react';
+import React, { useState } from 'react';
import { Theme, makeStyles } from '@material-ui/core';
-import { ThemeProvider, Box, Tooltip, IconButton } from '@material-ui/core';
+import { Box, Tooltip, IconButton } from '@material-ui/core';
import LightIcon from '@material-ui/icons/Brightness7';
import DarkIcon from '@material-ui/icons/Brightness4';
-import { lightTheme, darkTheme } from '@backstage/theme';
-import { CompoundEntityRef } from '@backstage/catalog-model';
-
-import { Content } from '@backstage/core-components';
+import { appThemeApiRef, useApi } from '@backstage/core-plugin-api';
import {
- Reader,
TechDocsReaderPage,
TechDocsReaderPageHeader,
+ TechDocsReaderPageContent,
} from '@backstage/plugin-techdocs';
const useStyles = makeStyles((theme: Theme) => ({
@@ -60,44 +51,12 @@ enum Themes {
DARK = 'dark',
}
-type TechDocsThemeValue = {
- theme: Themes;
- toggleTheme: () => void;
-};
-
-const TechDocsThemeContext = createContext({
- theme: Themes.LIGHT,
- toggleTheme: () => {},
-});
-
-const TechdocsThemeProvider: FC = ({ children }) => {
- const [theme, setTheme] = useState(Themes.LIGHT);
-
- const toggleTheme = useCallback(() => {
- setTheme(prevTheme =>
- prevTheme === Themes.LIGHT ? Themes.DARK : Themes.LIGHT,
- );
- }, [setTheme]);
-
- const value = { theme, toggleTheme };
-
- const themes = {
- [Themes.LIGHT]: lightTheme,
- [Themes.DARK]: darkTheme,
- };
-
- return (
-
- {children}
-
- );
-};
-
-const useTechDocsTheme = () => useContext(TechDocsThemeContext);
-
-const TechDocsThemeToggle = () => {
+export const TechDocsThemeToggle = () => {
+ const appThemeApi = useApi(appThemeApiRef);
const classes = useStyles();
- const { theme, toggleTheme } = useTechDocsTheme();
+ const [theme, setTheme] = useState(
+ (appThemeApi.getActiveThemeId() as Themes) || Themes.LIGHT,
+ );
const themes = {
[Themes.LIGHT]: {
@@ -112,10 +71,18 @@ const TechDocsThemeToggle = () => {
const { title, icon: Icon } = themes[theme];
+ const handleSetTheme = () => {
+ setTheme(prevTheme => {
+ const newTheme = prevTheme === Themes.LIGHT ? Themes.DARK : Themes.LIGHT;
+ appThemeApi.setActiveThemeId(newTheme);
+ return newTheme;
+ });
+ };
+
return (
-
+
@@ -123,47 +90,13 @@ const TechDocsThemeToggle = () => {
);
};
-const TechDocsPageContent = ({
- onReady,
- entityRef,
-}: {
- entityRef: CompoundEntityRef;
- onReady: () => void;
-}) => {
- const classes = useStyles();
-
- return (
-
-
-
- );
-};
-
const DefaultTechDocsPage = () => {
- const techDocsMetadata = {
- site_name: 'Live preview environment',
- site_description: '',
- };
-
return (
- {({ entityRef, onReady }) => (
- <>
-
-
-
-
- >
- )}
+
+
);
};
-export const techDocsPage = (
-
-
-
-);
+export const techDocsPage = ;
diff --git a/plugins/techdocs-backend/examples/documented-component/mkdocs.yml b/plugins/techdocs-backend/examples/documented-component/mkdocs.yml
index 2087979d22..1a159a4d12 100644
--- a/plugins/techdocs-backend/examples/documented-component/mkdocs.yml
+++ b/plugins/techdocs-backend/examples/documented-component/mkdocs.yml
@@ -1,4 +1,6 @@
site_name: 'Example Documentation'
+repo_url: https://github.com/backstage/backstage
+edit_uri: edit/master/plugins/techdocs-backend/examples/documented-component/docs
nav:
- Home: index.md
diff --git a/plugins/techdocs-react/.eslintrc.js b/plugins/techdocs-react/.eslintrc.js
new file mode 100644
index 0000000000..e2a53a6ad2
--- /dev/null
+++ b/plugins/techdocs-react/.eslintrc.js
@@ -0,0 +1 @@
+module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
diff --git a/plugins/techdocs-react/README.md b/plugins/techdocs-react/README.md
new file mode 100644
index 0000000000..548ba8be5a
--- /dev/null
+++ b/plugins/techdocs-react/README.md
@@ -0,0 +1,9 @@
+# @backstage/plugin-techdocs-react
+
+This package provides frontend utilities for TechDocs and Addons.
+
+## Installation
+
+```sh
+yarn add --cwd packages/app @backstage/plugin-techdocs-react
+```
diff --git a/plugins/techdocs-react/api-report.md b/plugins/techdocs-react/api-report.md
new file mode 100644
index 0000000000..29bbf7234b
--- /dev/null
+++ b/plugins/techdocs-react/api-report.md
@@ -0,0 +1,129 @@
+## API Report File for "@backstage/plugin-techdocs-react"
+
+> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
+
+```ts
+import { ApiRef } from '@backstage/core-plugin-api';
+import { AsyncState } from 'react-use/lib/useAsync';
+import { ComponentType } from 'react';
+import { CompoundEntityRef } from '@backstage/catalog-model';
+import { Dispatch } from 'react';
+import { Entity } from '@backstage/catalog-model';
+import { Extension } from '@backstage/core-plugin-api';
+import { default as React_2 } from 'react';
+import { ReactNode } from 'react';
+import { SetStateAction } from 'react';
+
+// @alpha
+export function createTechDocsAddonExtension(
+ options: TechDocsAddonOptions,
+): Extension>;
+
+// @alpha (undocumented)
+export const defaultTechDocsReaderPageValue: TechDocsReaderPageValue;
+
+// @alpha
+export const TECHDOCS_ADDONS_WRAPPER_KEY = 'techdocs.addons.wrapper.v1';
+
+// @alpha
+export const TechDocsAddonLocations: Readonly<{
+ readonly Header: 'Header';
+ readonly Subheader: 'Subheader';
+ readonly PrimarySidebar: 'PrimarySidebar';
+ readonly SecondarySidebar: 'SecondarySidebar';
+ readonly Content: 'Content';
+}>;
+
+// @alpha
+export type TechDocsAddonOptions = {
+ name: string;
+ location: keyof typeof TechDocsAddonLocations;
+ component: ComponentType;
+};
+
+// @alpha
+export const TechDocsAddons: React_2.ComponentType;
+
+// @public
+export interface TechDocsApi {
+ // (undocumented)
+ getApiOrigin(): Promise;
+ // (undocumented)
+ getEntityMetadata(
+ entityId: CompoundEntityRef,
+ ): Promise;
+ // (undocumented)
+ getTechDocsMetadata(entityId: CompoundEntityRef): Promise;
+}
+
+// @public
+export const techdocsApiRef: ApiRef;
+
+// @public
+export type TechDocsEntityMetadata = Entity & {
+ locationMetadata?: {
+ type: string;
+ target: string;
+ };
+};
+
+// @public
+export type TechDocsMetadata = {
+ site_name: string;
+ site_description: string;
+};
+
+// @public
+export const TechDocsReaderPageProvider: React_2.MemoExoticComponent<
+ ({ entityRef, children }: TechDocsReaderPageProviderProps) => JSX.Element
+>;
+
+// @public
+export type TechDocsReaderPageProviderProps = {
+ entityRef: CompoundEntityRef;
+ children: TechDocsReaderPageProviderRenderFunction | ReactNode;
+};
+
+// @public
+export type TechDocsReaderPageProviderRenderFunction = (
+ value: TechDocsReaderPageValue,
+) => JSX.Element;
+
+// @public
+export type TechDocsReaderPageValue = {
+ metadata: AsyncState;
+ entityRef: CompoundEntityRef;
+ entityMetadata: AsyncState;
+ shadowRoot?: ShadowRoot;
+ setShadowRoot: Dispatch>;
+ title: string;
+ setTitle: Dispatch>;
+ subtitle: string;
+ setSubtitle: Dispatch>;
+ onReady?: () => void;
+};
+
+// @alpha
+export const useShadowRoot: () => ShadowRoot | undefined;
+
+// @alpha
+export const useShadowRootElements: <
+ TReturnedElement extends HTMLElement = HTMLElement,
+>(
+ selectors: string[],
+) => TReturnedElement[];
+
+// @alpha
+export const useShadowRootSelection: (wait?: number) => Selection | null;
+
+// @alpha
+export const useTechDocsAddons: () => {
+ renderComponentByName: (name: string) => JSX.Element | null;
+ renderComponentsByLocation: (
+ location: keyof typeof TechDocsAddonLocations,
+ ) => (JSX.Element | null)[] | null;
+};
+
+// @alpha
+export const useTechDocsReaderPage: () => TechDocsReaderPageValue;
+```
diff --git a/plugins/techdocs-react/package.json b/plugins/techdocs-react/package.json
new file mode 100644
index 0000000000..8ef881d406
--- /dev/null
+++ b/plugins/techdocs-react/package.json
@@ -0,0 +1,65 @@
+{
+ "name": "@backstage/plugin-techdocs-react",
+ "description": "Shared frontend utilities for TechDocs and Addons",
+ "version": "0.0.0",
+ "private": false,
+ "publishConfig": {
+ "access": "public",
+ "alphaTypes": "dist/index.alpha.d.ts",
+ "main": "dist/index.esm.js",
+ "types": "dist/index.d.ts"
+ },
+ "backstage": {
+ "role": "web-library"
+ },
+ "homepage": "https://backstage.io",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/backstage/backstage",
+ "directory": "plugins/techdocs-react"
+ },
+ "keywords": [
+ "backstage",
+ "techdocs"
+ ],
+ "license": "Apache-2.0",
+ "main": "src/index.ts",
+ "types": "src/index.ts",
+ "scripts": {
+ "build": "backstage-cli package build --experimental-type-build",
+ "lint": "backstage-cli package lint",
+ "test": "backstage-cli package test",
+ "prepack": "backstage-cli package prepack",
+ "postpack": "backstage-cli package postpack",
+ "clean": "backstage-cli package clean",
+ "start": "backstage-cli package start"
+ },
+ "dependencies": {
+ "@backstage/catalog-model": "^1.0.1-next.1",
+ "@backstage/core-components": "^0.9.3-next.1",
+ "@backstage/core-plugin-api": "^1.0.0",
+ "@backstage/version-bridge": "^1.0.0",
+ "@material-ui/core": "^4.12.2",
+ "@material-ui/lab": "4.0.0-alpha.57",
+ "@material-ui/styles": "^4.11.0",
+ "jss": "~10.8.2",
+ "lodash": "^4.17.21",
+ "react-helmet": "6.1.0",
+ "react-router-dom": "6.0.0-beta.0",
+ "react-use": "^17.2.4"
+ },
+ "peerDependencies": {
+ "@types/react": "^16.13.1 || ^17.0.0",
+ "react": "^16.13.1 || ^17.0.0"
+ },
+ "devDependencies": {
+ "@testing-library/react": "^12.1.3",
+ "@testing-library/react-hooks": "^7.0.2",
+ "@backstage/test-utils": "^1.0.1-next.1",
+ "@backstage/theme": "^0.2.15"
+ },
+ "files": [
+ "alpha",
+ "dist"
+ ]
+}
diff --git a/plugins/techdocs-react/src/addons.tsx b/plugins/techdocs-react/src/addons.tsx
new file mode 100644
index 0000000000..54d4f92e2f
--- /dev/null
+++ b/plugins/techdocs-react/src/addons.tsx
@@ -0,0 +1,132 @@
+/*
+ * Copyright 2022 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 React, { ComponentType, useCallback } from 'react';
+import { useOutlet } from 'react-router-dom';
+
+import {
+ attachComponentData,
+ createReactExtension,
+ ElementCollection,
+ Extension,
+ useElementFilter,
+} from '@backstage/core-plugin-api';
+
+import { TechDocsAddonLocations, TechDocsAddonOptions } from './types';
+
+export const TECHDOCS_ADDONS_KEY = 'techdocs.addons.addon.v1';
+
+/**
+ * Marks the registry component.
+ * @alpha
+ */
+export const TECHDOCS_ADDONS_WRAPPER_KEY = 'techdocs.addons.wrapper.v1';
+
+/**
+ * TechDocs Addon registry.
+ * @alpha
+ */
+export const TechDocsAddons: React.ComponentType = () => null;
+
+attachComponentData(TechDocsAddons, TECHDOCS_ADDONS_WRAPPER_KEY, true);
+
+const getDataKeyByName = (name: string) => {
+ return `${TECHDOCS_ADDONS_KEY}.${name.toLocaleLowerCase('en-US')}`;
+};
+
+/**
+ * Create a TechDocs addon.
+ * @alpha
+ */
+export function createTechDocsAddonExtension(
+ options: TechDocsAddonOptions,
+): Extension> {
+ const { name, component: TechDocsAddon } = options;
+ return createReactExtension({
+ name,
+ component: {
+ sync: (props: TComponentProps) => ,
+ },
+ data: {
+ [TECHDOCS_ADDONS_KEY]: options,
+ [getDataKeyByName(name)]: true,
+ },
+ });
+}
+
+const getTechDocsAddonByName = (
+ collection: ElementCollection,
+ key: string,
+): JSX.Element | undefined => {
+ return collection.selectByComponentData({ key }).getElements()[0];
+};
+
+const getAllTechDocsAddons = (collection: ElementCollection) => {
+ return collection
+ .selectByComponentData({
+ key: TECHDOCS_ADDONS_WRAPPER_KEY,
+ })
+ .selectByComponentData({
+ key: TECHDOCS_ADDONS_KEY,
+ });
+};
+
+const getAllTechDocsAddonsData = (collection: ElementCollection) => {
+ return collection
+ .selectByComponentData({
+ key: TECHDOCS_ADDONS_WRAPPER_KEY,
+ })
+ .findComponentData({
+ key: TECHDOCS_ADDONS_KEY,
+ });
+};
+
+/**
+ * hook to use addons in components
+ * @alpha
+ */
+export const useTechDocsAddons = () => {
+ const node = useOutlet();
+ const collection = useElementFilter(node, getAllTechDocsAddons);
+ const options = useElementFilter(node, getAllTechDocsAddonsData);
+
+ const findAddonByData = useCallback(
+ (data: TechDocsAddonOptions | undefined) => {
+ if (!collection || !data) return null;
+ const nameKey = getDataKeyByName(data.name);
+ return getTechDocsAddonByName(collection, nameKey) ?? null;
+ },
+ [collection],
+ );
+
+ const renderComponentByName = useCallback(
+ (name: string) => {
+ const data = options.find(option => option.name === name);
+ return data ? findAddonByData(data) : null;
+ },
+ [options, findAddonByData],
+ );
+
+ const renderComponentsByLocation = useCallback(
+ (location: keyof typeof TechDocsAddonLocations) => {
+ const data = options.filter(option => option.location === location);
+ return data.length ? data.map(findAddonByData) : null;
+ },
+ [options, findAddonByData],
+ );
+
+ return { renderComponentByName, renderComponentsByLocation };
+};
diff --git a/plugins/techdocs-react/src/api.ts b/plugins/techdocs-react/src/api.ts
new file mode 100644
index 0000000000..0d3c0b931a
--- /dev/null
+++ b/plugins/techdocs-react/src/api.ts
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2022 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 { CompoundEntityRef } from '@backstage/catalog-model';
+import { createApiRef } from '@backstage/core-plugin-api';
+import { TechDocsEntityMetadata, TechDocsMetadata } from './types';
+
+/**
+ * API to talk to techdocs-backend.
+ *
+ * @public
+ */
+export interface TechDocsApi {
+ getApiOrigin(): Promise;
+ getTechDocsMetadata(entityId: CompoundEntityRef): Promise;
+ getEntityMetadata(
+ entityId: CompoundEntityRef,
+ ): Promise;
+}
+
+/**
+ * Utility API reference for the {@link TechDocsApi}.
+ *
+ * @public
+ */
+export const techdocsApiRef = createApiRef({
+ id: 'plugin.techdocs.service',
+});
diff --git a/plugins/techdocs-react/src/context.test.tsx b/plugins/techdocs-react/src/context.test.tsx
new file mode 100644
index 0000000000..14669dc336
--- /dev/null
+++ b/plugins/techdocs-react/src/context.test.tsx
@@ -0,0 +1,126 @@
+/*
+ * Copyright 2022 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 React from 'react';
+import { renderHook, act } from '@testing-library/react-hooks';
+
+import { ThemeProvider } from '@material-ui/core';
+
+import { lightTheme } from '@backstage/theme';
+import { TestApiProvider } from '@backstage/test-utils';
+import { Entity, CompoundEntityRef } from '@backstage/catalog-model';
+
+import { techdocsApiRef } from './api';
+import { useTechDocsReaderPage, TechDocsReaderPageProvider } from './context';
+import { TechDocsMetadata } from './types';
+
+const mockShadowRoot = () => {
+ const div = document.createElement('div');
+ const shadowRoot = div.attachShadow({ mode: 'open' });
+ shadowRoot.innerHTML = 'Shadow DOM Mock
';
+ return shadowRoot;
+};
+
+const mockEntityMetadata: Entity = {
+ apiVersion: 'v1',
+ kind: 'Component',
+ metadata: {
+ name: 'test',
+ namespace: 'default',
+ },
+ spec: {
+ owner: 'test',
+ },
+};
+
+const mockTechDocsMetadata: TechDocsMetadata = {
+ site_name: 'test-componnet',
+ site_description: 'this is a test component',
+};
+
+const techdocsApiMock = {
+ getEntityMetadata: jest.fn().mockResolvedValue(mockEntityMetadata),
+ getTechDocsMetadata: jest.fn().mockResolvedValue(mockTechDocsMetadata),
+};
+
+const wrapper = ({
+ entityRef = {
+ kind: mockEntityMetadata.kind,
+ name: mockEntityMetadata.metadata.name,
+ namespace: mockEntityMetadata.metadata.namespace!!,
+ },
+ children,
+}: {
+ entityRef?: CompoundEntityRef;
+ children: React.ReactNode;
+}) => (
+
+
+
+ {children}
+
+
+
+);
+
+describe('useTechDocsReaderPage', () => {
+ it('should set title', async () => {
+ const { result, waitForNextUpdate } = renderHook(
+ () => useTechDocsReaderPage(),
+ { wrapper },
+ );
+
+ expect(result.current.title).toBe('');
+
+ act(() => result.current.setTitle('test site title'));
+
+ await waitForNextUpdate();
+
+ expect(result.current.title).toBe('test site title');
+ });
+
+ it('should set subtitle', async () => {
+ const { result, waitForNextUpdate } = renderHook(
+ () => useTechDocsReaderPage(),
+ { wrapper },
+ );
+
+ expect(result.current.subtitle).toBe('');
+
+ act(() => result.current.setSubtitle('test site subtitle'));
+
+ await waitForNextUpdate();
+
+ expect(result.current.subtitle).toBe('test site subtitle');
+ });
+
+ it('should set shadow root', async () => {
+ const { result, waitForNextUpdate } = renderHook(
+ () => useTechDocsReaderPage(),
+ { wrapper },
+ );
+
+ // mock shadowroot
+ const shadowRoot = mockShadowRoot();
+
+ act(() => result.current.setShadowRoot(shadowRoot));
+
+ await waitForNextUpdate();
+
+ expect(result.current.shadowRoot?.innerHTML).toBe(
+ 'Shadow DOM Mock
',
+ );
+ });
+});
diff --git a/plugins/techdocs-react/src/context.tsx b/plugins/techdocs-react/src/context.tsx
new file mode 100644
index 0000000000..67bf416818
--- /dev/null
+++ b/plugins/techdocs-react/src/context.tsx
@@ -0,0 +1,171 @@
+/*
+ * Copyright 2022 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 React, {
+ Dispatch,
+ SetStateAction,
+ useContext,
+ useState,
+ memo,
+ ReactNode,
+} from 'react';
+import useAsync, { AsyncState } from 'react-use/lib/useAsync';
+
+import {
+ CompoundEntityRef,
+ stringifyEntityRef,
+} from '@backstage/catalog-model';
+import {
+ createVersionedContext,
+ createVersionedValueMap,
+} from '@backstage/version-bridge';
+
+import { useApi } from '@backstage/core-plugin-api';
+
+import { techdocsApiRef } from './api';
+import { TechDocsEntityMetadata, TechDocsMetadata } from './types';
+
+const areEntityRefsEqual = (
+ prevEntityRef: CompoundEntityRef,
+ nextEntityRef: CompoundEntityRef,
+) => {
+ return (
+ stringifyEntityRef(prevEntityRef) === stringifyEntityRef(nextEntityRef)
+ );
+};
+
+/**
+ * @public type for the value of the TechDocsReaderPageContext
+ */
+export type TechDocsReaderPageValue = {
+ metadata: AsyncState;
+ entityRef: CompoundEntityRef;
+ entityMetadata: AsyncState;
+ shadowRoot?: ShadowRoot;
+ setShadowRoot: Dispatch>;
+ title: string;
+ setTitle: Dispatch>;
+ subtitle: string;
+ setSubtitle: Dispatch>;
+ /**
+ * @deprecated property can be passed down directly to the `TechDocsReaderPageContent` instead.
+ */
+ onReady?: () => void;
+};
+
+/**
+ * @alpha
+ */
+export const defaultTechDocsReaderPageValue: TechDocsReaderPageValue = {
+ title: '',
+ subtitle: '',
+ setTitle: () => {},
+ setSubtitle: () => {},
+ setShadowRoot: () => {},
+ metadata: { loading: true },
+ entityMetadata: { loading: true },
+ entityRef: { kind: '', name: '', namespace: '' },
+};
+
+const TechDocsReaderPageContext = createVersionedContext<{
+ 1: TechDocsReaderPageValue;
+}>('techdocs-reader-page-context');
+
+/**
+ * render function for {@link TechDocsReaderPageProvider}
+ *
+ * @public
+ */
+export type TechDocsReaderPageProviderRenderFunction = (
+ value: TechDocsReaderPageValue,
+) => JSX.Element;
+
+/**
+ * Props for {@link TechDocsReaderPageProvider}
+ *
+ * @public
+ */
+export type TechDocsReaderPageProviderProps = {
+ entityRef: CompoundEntityRef;
+ children: TechDocsReaderPageProviderRenderFunction | ReactNode;
+};
+
+/**
+ * A context to store the reader page state
+ * @public
+ */
+export const TechDocsReaderPageProvider = memo(
+ ({ entityRef, children }: TechDocsReaderPageProviderProps) => {
+ const techdocsApi = useApi(techdocsApiRef);
+
+ const metadata = useAsync(async () => {
+ return techdocsApi.getTechDocsMetadata(entityRef);
+ }, [entityRef]);
+
+ const entityMetadata = useAsync(async () => {
+ return techdocsApi.getEntityMetadata(entityRef);
+ }, [entityRef]);
+
+ const [title, setTitle] = useState(defaultTechDocsReaderPageValue.title);
+ const [subtitle, setSubtitle] = useState(
+ defaultTechDocsReaderPageValue.subtitle,
+ );
+ const [shadowRoot, setShadowRoot] = useState(
+ defaultTechDocsReaderPageValue.shadowRoot,
+ );
+
+ const value = {
+ metadata,
+ entityRef,
+ entityMetadata,
+ shadowRoot,
+ setShadowRoot,
+ title,
+ setTitle,
+ subtitle,
+ setSubtitle,
+ };
+ const versionedValue = createVersionedValueMap({ 1: value });
+
+ return (
+
+ {children instanceof Function ? children(value) : children}
+
+ );
+ },
+ (prevProps, nextProps) => {
+ return areEntityRefsEqual(prevProps.entityRef, nextProps.entityRef);
+ },
+);
+
+/**
+ * Hook used to get access to shared state between reader page components.
+ * @alpha
+ */
+export const useTechDocsReaderPage = () => {
+ const versionedContext = useContext(TechDocsReaderPageContext);
+
+ if (versionedContext === undefined) {
+ return defaultTechDocsReaderPageValue;
+ }
+
+ const context = versionedContext.atVersion(1);
+ if (context === undefined) {
+ throw new Error('No context found for version 1.');
+ }
+
+ return context;
+};
diff --git a/plugins/techdocs-react/src/hooks.test.ts b/plugins/techdocs-react/src/hooks.test.ts
new file mode 100644
index 0000000000..2aa53b32d5
--- /dev/null
+++ b/plugins/techdocs-react/src/hooks.test.ts
@@ -0,0 +1,103 @@
+/*
+ * Copyright 2022 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 {
+ useShadowRoot,
+ useShadowRootElements,
+ useShadowRootSelection,
+} from './hooks';
+import { renderHook } from '@testing-library/react-hooks';
+import { fireEvent, waitFor } from '@testing-library/react';
+
+const fireSelectionChangeEvent = (window: Window) => {
+ const selectionChangeEvent = window.document.createEvent('Event');
+ selectionChangeEvent.initEvent('selectionchange', true, true);
+ window.document.addEventListener('selectionchange', () => {}, false);
+ fireEvent(window.document, selectionChangeEvent);
+};
+
+const getSelection = jest.fn();
+
+const mockShadowRoot = () => {
+ const div = document.createElement('div');
+ const shadowRoot = div.attachShadow({ mode: 'open' });
+ shadowRoot.innerHTML = 'Shadow DOM Mock
';
+ (shadowRoot as ShadowRoot & Pick).getSelection =
+ getSelection;
+ return shadowRoot;
+};
+
+const shadowRoot = mockShadowRoot();
+
+jest.mock('./context', () => {
+ return {
+ useTechDocsReaderPage: () => ({ shadowRoot }),
+ };
+});
+
+const selection = {
+ type: 'Range',
+ rangeCount: 1,
+ isCollapsed: true,
+ getRangeAt: () => ({
+ startContainer: 'this is a sentence',
+ endContainer: 'this is a sentence',
+ startOffset: 1,
+ endOffset: 3,
+ getBoundingClientRect: () => ({
+ right: 100,
+ top: 100,
+ width: 100,
+ height: 100,
+ }),
+ }),
+ toString: () => 'his ',
+ containsNode: () => true,
+} as unknown as Selection;
+
+getSelection.mockReturnValue(selection);
+
+describe('hooks', () => {
+ describe('useShadowRoot', () => {
+ it('should return shadow root', async () => {
+ const { result } = renderHook(() => useShadowRoot());
+
+ expect(result.current?.innerHTML).toBe(shadowRoot.innerHTML);
+ });
+ });
+
+ describe('useShadowRootElements', () => {
+ it('should return shadow root elements based on selector', () => {
+ const { result } = renderHook(() => useShadowRootElements(['h1']));
+
+ expect(result.current).toHaveLength(1);
+ });
+ });
+
+ describe('useShadowRootSelection', () => {
+ it('should return shadow root selection', async () => {
+ const { result } = renderHook(() => useShadowRootSelection(0));
+
+ expect(result.current).toBeNull();
+
+ fireSelectionChangeEvent(window);
+
+ await waitFor(() => {
+ expect(result.current?.toString()).toEqual('his ');
+ });
+ });
+ });
+});
diff --git a/plugins/techdocs-react/src/hooks.ts b/plugins/techdocs-react/src/hooks.ts
new file mode 100644
index 0000000000..cc141c9e15
--- /dev/null
+++ b/plugins/techdocs-react/src/hooks.ts
@@ -0,0 +1,95 @@
+/*
+ * Copyright 2022 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 { useState, useEffect, useMemo } from 'react';
+import debounce from 'lodash/debounce';
+import { useTechDocsReaderPage } from './context';
+
+/**
+ * Hook for use within TechDocs addons that provides access to the underlying
+ * shadow root of the current page, allowing the DOM within to be mutated.
+ * @alpha
+ */
+export const useShadowRoot = () => {
+ const { shadowRoot } = useTechDocsReaderPage();
+ return shadowRoot;
+};
+
+/**
+ * Convenience hook for use within TechDocs addons that provides access to
+ * elements that match a given selector within the shadow root.
+ *
+ * @alpha
+ */
+export const useShadowRootElements = <
+ TReturnedElement extends HTMLElement = HTMLElement,
+>(
+ selectors: string[],
+): TReturnedElement[] => {
+ const shadowRoot = useShadowRoot();
+ if (!shadowRoot) return [];
+ return selectors
+ .map(selector => shadowRoot?.querySelectorAll(selector))
+ .filter(nodeList => nodeList.length)
+ .map(nodeList => Array.from(nodeList))
+ .flat();
+};
+
+const isValidSelection = (newSelection: Selection) => {
+ // Safari sets the selection rect to top zero
+ return (
+ newSelection.toString() &&
+ newSelection.rangeCount &&
+ newSelection.getRangeAt(0).getBoundingClientRect().top
+ );
+};
+
+/**
+ * Hook for retreiving a selection within the ShadowRoot.
+ * @alpha
+ */
+export const useShadowRootSelection = (wait: number = 0) => {
+ const shadowRoot = useShadowRoot();
+ const [selection, setSelection] = useState(null);
+ const handleSelectionChange = useMemo(
+ () =>
+ debounce(() => {
+ const shadowDocument = shadowRoot as ShadowRoot &
+ Pick;
+ // Firefox and Safari don't implement getSelection for Shadow DOM
+ const newSelection = shadowDocument.getSelection
+ ? shadowDocument.getSelection()
+ : document.getSelection();
+
+ if (newSelection && isValidSelection(newSelection)) {
+ setSelection(newSelection);
+ } else {
+ setSelection(null);
+ }
+ }, wait),
+ [shadowRoot, setSelection, wait],
+ );
+
+ useEffect(() => {
+ window.document.addEventListener('selectionchange', handleSelectionChange);
+ return () =>
+ window.document.removeEventListener(
+ 'selectionchange',
+ handleSelectionChange,
+ );
+ }, [handleSelectionChange]);
+
+ return selection;
+};
diff --git a/plugins/techdocs-react/src/index.ts b/plugins/techdocs-react/src/index.ts
new file mode 100644
index 0000000000..8b46c9d717
--- /dev/null
+++ b/plugins/techdocs-react/src/index.ts
@@ -0,0 +1,51 @@
+/*
+ * Copyright 2022 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.
+ */
+
+/**
+ * Package encapsulating utilities to be shared by frontend TechDocs plugins.
+ *
+ * @packageDocumentation
+ */
+
+export {
+ useTechDocsAddons,
+ createTechDocsAddonExtension,
+ TechDocsAddons,
+ TECHDOCS_ADDONS_WRAPPER_KEY,
+} from './addons';
+export { techdocsApiRef } from './api';
+export type { TechDocsApi } from './api';
+export {
+ defaultTechDocsReaderPageValue,
+ TechDocsReaderPageProvider,
+ useTechDocsReaderPage,
+} from './context';
+export type {
+ TechDocsReaderPageProviderProps,
+ TechDocsReaderPageProviderRenderFunction,
+ TechDocsReaderPageValue,
+} from './context';
+export {
+ useShadowRoot,
+ useShadowRootElements,
+ useShadowRootSelection,
+} from './hooks';
+export { TechDocsAddonLocations } from './types';
+export type {
+ TechDocsEntityMetadata,
+ TechDocsMetadata,
+ TechDocsAddonOptions,
+} from './types';
diff --git a/plugins/techdocs-react/src/types.ts b/plugins/techdocs-react/src/types.ts
new file mode 100644
index 0000000000..de74d4f284
--- /dev/null
+++ b/plugins/techdocs-react/src/types.ts
@@ -0,0 +1,111 @@
+/*
+ * Copyright 2022 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 { ComponentType } from 'react';
+import { Entity } from '@backstage/catalog-model';
+
+/**
+ * Metadata for TechDocs page
+ *
+ * @public
+ */
+export type TechDocsMetadata = {
+ site_name: string;
+ site_description: string;
+};
+
+/**
+ * Metadata for TechDocs Entity
+ *
+ * @public
+ */
+export type TechDocsEntityMetadata = Entity & {
+ locationMetadata?: { type: string; target: string };
+};
+
+/**
+ * Locations for which TechDocs addons may be declared and rendered.
+ * @alpha
+ */
+export const TechDocsAddonLocations = Object.freeze({
+ /**
+ * These addons fill up the header from the right, on the same line as the
+ * title.
+ */
+ Header: 'Header',
+
+ /**
+ * These addons appear below the header and above all content; tooling addons
+ * can be inserted for convenience.
+ */
+ Subheader: 'Subheader',
+
+ /**
+ * These addons appear left of the content and above the navigation.
+ */
+ PrimarySidebar: 'PrimarySidebar',
+
+ /**
+ * These addons appear right of the content and above the table of contents.
+ */
+ SecondarySidebar: 'SecondarySidebar',
+
+ /**
+ * A virtual location which allows mutation of all content within the shadow
+ * root by transforming DOM nodes. These addons should return null on render.
+ */
+ Content: 'Content',
+
+ /**
+ * todo(backstage/community): This is a proposed virtual location which would
+ * help implement a common addon pattern in which many instances of a given
+ * element in markdown would be dynamically replaced at render-time based on
+ * attributes provided on that element, for example:
+ *
+ * ```md
+ * ## For Fun
+ * CatGif
+ *
+ * ## Component Metadata
+ * CatalogEntityCard
+ *
+ * ## System Metadata
+ * CatalogEntityCard
+ * ```
+ *
+ * Could correspond to a TechDocs addon named `CatalogEntityCard` with
+ * location `TechDocsAddonLocations.COMPONENT`, whose `component` would be
+ * the react component that would be rendered in place of all instances of
+ * the markdown illustrated above.
+ *
+ * The `@backstage/plugin-techdocs-react` package would need to be updated to, in
+ * cases where such addons had been registered, find all instances of the
+ * the `` tag whose `textContent` corresponded with the name of the
+ * addon, then replace them with component instances of the addon component,
+ * passing any attributes from the tag as props to the component.
+ */
+ // Component: 'Component',
+} as const);
+
+/**
+ * Options for creating a TechDocs addon.
+ * @alpha
+ */
+export type TechDocsAddonOptions = {
+ name: string;
+ location: keyof typeof TechDocsAddonLocations;
+ component: ComponentType;
+};
diff --git a/plugins/techdocs/api-report.md b/plugins/techdocs/api-report.md
index ecab23e225..4575ef7b28 100644
--- a/plugins/techdocs/api-report.md
+++ b/plugins/techdocs/api-report.md
@@ -16,11 +16,33 @@ import { FetchApi } from '@backstage/core-plugin-api';
import { IdentityApi } from '@backstage/core-plugin-api';
import { PropsWithChildren } from 'react';
import { default as React_2 } from 'react';
+import { ReactNode } from 'react';
import { RouteRef } from '@backstage/core-plugin-api';
+import { StyledComponentProps } from '@material-ui/core';
import { TableColumn } from '@backstage/core-components';
import { TableProps } from '@backstage/core-components';
+import { TechDocsEntityMetadata as TechDocsEntityMetadata_2 } from '@backstage/plugin-techdocs-react';
+import { TechDocsMetadata as TechDocsMetadata_2 } from '@backstage/plugin-techdocs-react';
+import { ToolbarProps } from '@material-ui/core';
import { UserListFilterKind } from '@backstage/plugin-catalog-react';
+// @public
+export type ContentStateTypes =
+ /** There is nothing to display but a loading indicator */
+ | 'CHECKING'
+ /** There is no content yet -> present a full screen loading page */
+ | 'INITIAL_BUILD'
+ /** There is content, but the backend is about to update it */
+ | 'CONTENT_STALE_REFRESHING'
+ /** There is content, but after a reload, the content will be different */
+ | 'CONTENT_STALE_READY'
+ /** There is content, the backend tried to update it, but failed */
+ | 'CONTENT_STALE_ERROR'
+ /** There is nothing to see but a "not found" page. Is also shown on page load errors */
+ | 'CONTENT_NOT_FOUND'
+ /** There is only the latest and greatest content */
+ | 'CONTENT_FRESH';
+
// @public
export const DefaultTechDocsHome: (
props: DefaultTechDocsHomeProps,
@@ -89,7 +111,7 @@ export type DocsTableRow = {
};
// @public
-export const EmbeddedDocsRouter: () => JSX.Element;
+export const EmbeddedDocsRouter: (props: PropsWithChildren<{}>) => JSX.Element;
// @public
export const EntityListDocsGrid: () => JSX.Element;
@@ -129,7 +151,9 @@ export type EntityListDocsTableProps = {
};
// @public
-export const EntityTechdocsContent: () => JSX.Element;
+export const EntityTechdocsContent: (props: {
+ children?: ReactNode;
+}) => JSX.Element;
// @public
export const isTechDocsAvailable: (entity: Entity) => boolean;
@@ -151,14 +175,18 @@ export interface PanelConfig {
// @public
export type PanelType = 'DocsCardGrid' | 'DocsTable';
-// @public
-export const Reader: (props: ReaderProps) => JSX.Element;
+// @public @deprecated
+export const Reader: (props: TechDocsReaderPageContentProps) => JSX.Element;
// @public
-export type ReaderProps = {
- entityRef: CompoundEntityRef;
- withSearch?: boolean;
- onReady?: () => void;
+export type ReaderState = {
+ state: ContentStateTypes;
+ path: string;
+ contentReload: () => void;
+ content?: string;
+ contentErrorMessage?: string;
+ syncErrorMessage?: string;
+ buildLog: string[];
};
// @public
@@ -178,19 +206,19 @@ export interface TabConfig {
// @public
export type TabsConfig = TabConfig[];
-// @public
+// @public @deprecated
export interface TechDocsApi {
// (undocumented)
getApiOrigin(): Promise;
// (undocumented)
getEntityMetadata(
entityId: CompoundEntityRef,
- ): Promise;
+ ): Promise;
// (undocumented)
- getTechDocsMetadata(entityId: CompoundEntityRef): Promise;
+ getTechDocsMetadata(entityId: CompoundEntityRef): Promise;
}
-// @public
+// @public @deprecated
export const techdocsApiRef: ApiRef;
// @public
@@ -208,8 +236,8 @@ export class TechDocsClient implements TechDocsApi {
getApiOrigin(): Promise;
getEntityMetadata(
entityId: CompoundEntityRef,
- ): Promise;
- getTechDocsMetadata(entityId: CompoundEntityRef): Promise;
+ ): Promise;
+ getTechDocsMetadata(entityId: CompoundEntityRef): Promise;
}
// @public
@@ -222,22 +250,14 @@ export type TechDocsCustomHomeProps = {
tabsConfig: TabsConfig;
};
-// @public
-export type TechDocsEntityMetadata = Entity & {
- locationMetadata?: {
- type: string;
- target: string;
- };
-};
+// @public @deprecated (undocumented)
+export type TechDocsEntityMetadata = TechDocsEntityMetadata_2;
// @public
export const TechDocsIndexPage: () => JSX.Element;
-// @public
-export type TechDocsMetadata = {
- site_name: string;
- site_description: string;
-};
+// @public @deprecated (undocumented)
+export type TechDocsMetadata = TechDocsMetadata_2;
// @public
export const TechdocsPage: () => JSX.Element;
@@ -271,26 +291,51 @@ const techdocsPlugin: BackstagePlugin<
export { techdocsPlugin as plugin };
export { techdocsPlugin };
+// @public
+export const TechDocsReaderLayout: ({
+ withSearch,
+ withHeader,
+}: TechDocsReaderLayoutProps) => JSX.Element;
+
+// @public
+export type TechDocsReaderLayoutProps = {
+ withHeader?: boolean;
+ withSearch?: boolean;
+};
+
// @public
export const TechDocsReaderPage: (
props: TechDocsReaderPageProps,
) => JSX.Element;
+// @public
+export const TechDocsReaderPageContent: (
+ props: TechDocsReaderPageContentProps,
+) => JSX.Element;
+
+// @public
+export type TechDocsReaderPageContentProps = {
+ entityRef?: CompoundEntityRef;
+ withSearch?: boolean;
+ onReady?: () => void;
+};
+
// @public
export const TechDocsReaderPageHeader: (
props: TechDocsReaderPageHeaderProps,
) => JSX.Element;
-// @public
+// @public @deprecated
export type TechDocsReaderPageHeaderProps = PropsWithChildren<{
- entityRef: CompoundEntityRef;
- entityMetadata?: TechDocsEntityMetadata;
- techDocsMetadata?: TechDocsMetadata;
+ entityRef?: CompoundEntityRef;
+ entityMetadata?: TechDocsEntityMetadata_2;
+ techDocsMetadata?: TechDocsMetadata_2;
}>;
-// @public
+// @public (undocumented)
export type TechDocsReaderPageProps = {
- children?: TechDocsReaderPageRenderFunction | React_2.ReactNode;
+ entityRef?: CompoundEntityRef;
+ children?: TechDocsReaderPageRenderFunction | ReactNode;
};
// @public
@@ -299,12 +344,38 @@ export type TechDocsReaderPageRenderFunction = ({
entityMetadataValue,
entityRef,
}: {
- techdocsMetadataValue?: TechDocsMetadata | undefined;
- entityMetadataValue?: TechDocsEntityMetadata | undefined;
+ techdocsMetadataValue?: TechDocsMetadata_2 | undefined;
+ entityMetadataValue?: TechDocsEntityMetadata_2 | undefined;
entityRef: CompoundEntityRef;
- onReady: () => void;
+ onReady?: () => void;
}) => JSX.Element;
+// @public
+export const TechDocsReaderPageSubheader: React_2.ComponentType<
+ Pick<
+ {
+ toolbarProps?: ToolbarProps<'div', {}> | undefined;
+ },
+ 'toolbarProps'
+ > &
+ StyledComponentProps<'root'>
+>;
+
+// @public
+export const TechDocsReaderProvider: ({
+ children,
+}: TechDocsReaderProviderProps) => JSX.Element;
+
+// @public
+export type TechDocsReaderProviderProps = {
+ children: TechDocsReaderProviderRenderFunction | ReactNode;
+};
+
+// @public
+export type TechDocsReaderProviderRenderFunction = (
+ value: ReaderState,
+) => JSX.Element;
+
// @public
export const TechDocsSearch: (props: TechDocsSearchProps) => JSX.Element;
diff --git a/plugins/techdocs/dev/index.tsx b/plugins/techdocs/dev/index.tsx
index d08e914736..9cf0364c63 100644
--- a/plugins/techdocs/dev/index.tsx
+++ b/plugins/techdocs/dev/index.tsx
@@ -19,7 +19,7 @@ import { NotFoundError } from '@backstage/errors';
import React from 'react';
import { CompoundEntityRef } from '@backstage/catalog-model';
import {
- Reader,
+ TechDocsReaderPageContent,
SyncResult,
TechDocsStorageApi,
techdocsStorageApiRef,
@@ -30,6 +30,7 @@ import {
discoveryApiRef,
identityApiRef,
} from '@backstage/core-plugin-api';
+import { TechDocsReaderPageProvider } from '@backstage/plugin-techdocs-react';
import { Header, Page, TabbedLayout } from '@backstage/core-components';
// used so each route can provide it's own implementation in the constructor of the react component
@@ -112,13 +113,15 @@ function createPage({
render() {
return (
-
+ >
+
+
);
}
}
diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json
index b6901e8edd..f9f2f61351 100644
--- a/plugins/techdocs/package.json
+++ b/plugins/techdocs/package.json
@@ -38,21 +38,25 @@
"@backstage/catalog-model": "^1.0.1-next.1",
"@backstage/config": "^1.0.0",
"@backstage/core-components": "^0.9.3-next.1",
+ "@backstage/core-app-api": "^1.0.1-next.0",
"@backstage/core-plugin-api": "^1.0.0",
"@backstage/errors": "^1.0.0",
"@backstage/integration": "^1.1.0-next.1",
"@backstage/integration-react": "^1.0.1-next.1",
"@backstage/plugin-catalog-react": "^1.0.1-next.2",
"@backstage/plugin-search-react": "^0.0.0",
+ "@backstage/plugin-techdocs-react": "^0.0.0",
"@backstage/theme": "^0.2.15",
"@material-ui/core": "^4.12.2",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.57",
"@material-ui/styles": "^4.10.0",
"dompurify": "^2.2.9",
- "event-source-polyfill": "1.0.25",
+ "event-source-polyfill": "^1.0.25",
"git-url-parse": "^11.6.0",
+ "jss": "~10.8.2",
"lodash": "^4.17.21",
+ "react-helmet": "6.1.0",
"react-router": "6.0.0-beta.0",
"react-router-dom": "6.0.0-beta.0",
"react-text-truncate": "^0.18.0",
@@ -72,6 +76,7 @@
"@testing-library/react": "^12.1.3",
"@testing-library/react-hooks": "^7.0.2",
"@testing-library/user-event": "^14.0.0",
+ "@types/event-source-polyfill": "^1.0.0",
"@types/dompurify": "^2.2.2",
"@types/jest": "^26.0.7",
"@types/node": "^16.11.26",
diff --git a/plugins/techdocs/src/EntityPageDocs.tsx b/plugins/techdocs/src/EntityPageDocs.tsx
index b10fba6d2e..b0e529f9a5 100644
--- a/plugins/techdocs/src/EntityPageDocs.tsx
+++ b/plugins/techdocs/src/EntityPageDocs.tsx
@@ -15,21 +15,22 @@
*/
import React from 'react';
-import { Entity } from '@backstage/catalog-model';
-import { Reader } from './reader';
-import { toLowerMaybe } from './helpers';
-import { configApiRef, useApi } from '@backstage/core-plugin-api';
-export const EntityPageDocs = ({ entity }: { entity: Entity }) => {
- const config = useApi(configApiRef);
+import { Entity, getCompoundEntityRef } from '@backstage/catalog-model';
+
+import { TechDocsReaderPage } from './plugin';
+import { TechDocsReaderPageSubheader } from './reader/components/TechDocsReaderPageSubheader';
+import { TechDocsReaderPageContent } from './reader/components/TechDocsReaderPageContent';
+
+type EntityPageDocsProps = { entity: Entity };
+
+export const EntityPageDocs = ({ entity }: EntityPageDocsProps) => {
+ const entityRef = getCompoundEntityRef(entity);
+
return (
-
+
+
+
+
);
};
diff --git a/plugins/techdocs/src/Router.tsx b/plugins/techdocs/src/Router.tsx
index 3a49f6edf9..1908e6cad0 100644
--- a/plugins/techdocs/src/Router.tsx
+++ b/plugins/techdocs/src/Router.tsx
@@ -14,14 +14,17 @@
* limitations under the License.
*/
-import React from 'react';
-import { Entity } from '@backstage/catalog-model';
-import { useEntity } from '@backstage/plugin-catalog-react';
+import React, { PropsWithChildren } from 'react';
import { Route, Routes } from 'react-router-dom';
+
+import { Entity } from '@backstage/catalog-model';
+import { FlatRoutes } from '@backstage/core-app-api';
+import { useEntity } from '@backstage/plugin-catalog-react';
+import { MissingAnnotationEmptyState } from '@backstage/core-components';
+
+import { EntityPageDocs } from './EntityPageDocs';
import { TechDocsIndexPage } from './home/components/TechDocsIndexPage';
import { TechDocsReaderPage } from './reader/components/TechDocsReaderPage';
-import { EntityPageDocs } from './EntityPageDocs';
-import { MissingAnnotationEmptyState } from '@backstage/core-components';
const TECHDOCS_ANNOTATION = 'backstage.io/techdocs-ref';
@@ -55,7 +58,8 @@ export const Router = () => {
*
* @public
*/
-export const EmbeddedDocsRouter = () => {
+export const EmbeddedDocsRouter = (props: PropsWithChildren<{}>) => {
+ const { children } = props;
const { entity } = useEntity();
const projectId = entity.metadata.annotations?.[TECHDOCS_ANNOTATION];
@@ -65,8 +69,10 @@ export const EmbeddedDocsRouter = () => {
}
return (
-
- } />
-
+
+ }>
+ {children}
+
+
);
};
diff --git a/plugins/techdocs/src/api.ts b/plugins/techdocs/src/api.ts
index 51dd82e643..13bd654b4d 100644
--- a/plugins/techdocs/src/api.ts
+++ b/plugins/techdocs/src/api.ts
@@ -15,7 +15,10 @@
*/
import { CompoundEntityRef } from '@backstage/catalog-model';
-import { TechDocsEntityMetadata, TechDocsMetadata } from './types';
+import {
+ TechDocsEntityMetadata,
+ TechDocsMetadata,
+} from '@backstage/plugin-techdocs-react';
import { createApiRef } from '@backstage/core-plugin-api';
/**
@@ -31,6 +34,7 @@ export const techdocsStorageApiRef = createApiRef({
* Utility API reference for the {@link TechDocsApi}.
*
* @public
+ * @deprecated Import from `@backstage/plugin-techdocs-react` instead
*/
export const techdocsApiRef = createApiRef({
id: 'plugin.techdocs.service',
@@ -68,6 +72,7 @@ export interface TechDocsStorageApi {
* API to talk to techdocs-backend.
*
* @public
+ * @deprecated Import from `@backstage/plugin-techdocs-react` instead
*/
export interface TechDocsApi {
getApiOrigin(): Promise;
diff --git a/plugins/techdocs/src/client.ts b/plugins/techdocs/src/client.ts
index 69a5a91efc..499a56580c 100644
--- a/plugins/techdocs/src/client.ts
+++ b/plugins/techdocs/src/client.ts
@@ -22,9 +22,12 @@ import {
IdentityApi,
} from '@backstage/core-plugin-api';
import { NotFoundError, ResponseError } from '@backstage/errors';
+import {
+ TechDocsEntityMetadata,
+ TechDocsMetadata,
+} from '@backstage/plugin-techdocs-react';
import { EventSourcePolyfill } from 'event-source-polyfill';
import { SyncResult, TechDocsApi, TechDocsStorageApi } from './api';
-import { TechDocsEntityMetadata, TechDocsMetadata } from './types';
/**
* API to talk to `techdocs-backend`.
diff --git a/plugins/techdocs/src/index.ts b/plugins/techdocs/src/index.ts
index c31f8b7370..13b71d49ed 100644
--- a/plugins/techdocs/src/index.ts
+++ b/plugins/techdocs/src/index.ts
@@ -20,6 +20,11 @@
* @packageDocumentation
*/
+import {
+ TechDocsEntityMetadata,
+ TechDocsMetadata,
+} from '@backstage/plugin-techdocs-react';
+
export * from './types';
export * from './api';
export * from './client';
@@ -36,3 +41,22 @@ export {
techdocsPlugin,
} from './plugin';
export * from './Router';
+
+/**
+ * @deprecated Import from `@backstage/plugin-techdocs-react` instead
+ *
+ * @public
+ */
+type DeprecatedTechDocsMetadata = TechDocsMetadata;
+
+/**
+ * @deprecated Import from `@backstage/plugin-techdocs-react` instead
+ *
+ * @public
+ */
+type DeprecatedTechDocsEntityMetadata = TechDocsEntityMetadata;
+
+export type {
+ DeprecatedTechDocsEntityMetadata as TechDocsEntityMetadata,
+ DeprecatedTechDocsMetadata as TechDocsMetadata,
+};
diff --git a/plugins/techdocs/src/reader/components/Reader.test.tsx b/plugins/techdocs/src/reader/components/Reader.test.tsx
deleted file mode 100644
index 09cd33e286..0000000000
--- a/plugins/techdocs/src/reader/components/Reader.test.tsx
+++ /dev/null
@@ -1,85 +0,0 @@
-/*
- * Copyright 2020 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 { ConfigReader } from '@backstage/config';
-import {
- ScmIntegrationsApi,
- scmIntegrationsApiRef,
-} from '@backstage/integration-react';
-import { TestApiRegistry, wrapInTestApp } from '@backstage/test-utils';
-import { act, render } from '@testing-library/react';
-import React from 'react';
-import { TechDocsStorageApi, techdocsStorageApiRef } from '../../api';
-import { Reader } from './Reader';
-import { ApiProvider } from '@backstage/core-app-api';
-import { searchApiRef } from '@backstage/plugin-search-react';
-
-jest.mock('react-router-dom', () => {
- const actual = jest.requireActual('react-router-dom');
- return {
- ...actual,
- useParams: jest.fn(),
- };
-});
-
-const { useParams }: { useParams: jest.Mock } =
- jest.requireMock('react-router-dom');
-
-describe('', () => {
- it('should render Reader content', async () => {
- useParams.mockReturnValue({
- entityRef: 'Component::backstage',
- });
-
- const scmIntegrationsApi: ScmIntegrationsApi =
- ScmIntegrationsApi.fromConfig(
- new ConfigReader({
- integrations: {},
- }),
- );
- const techdocsStorageApi: Partial = {};
- const searchApi = {
- query: () =>
- Promise.resolve({
- results: [],
- }),
- };
- const apiRegistry = TestApiRegistry.from(
- [scmIntegrationsApiRef, scmIntegrationsApi],
- [techdocsStorageApiRef, techdocsStorageApi],
- [searchApiRef, searchApi],
- );
-
- await act(async () => {
- const rendered = render(
- wrapInTestApp(
-
-
- ,
- ),
- );
- expect(
- rendered.getByTestId('techdocs-content-shadowroot'),
- ).toBeInTheDocument();
- });
- });
-});
diff --git a/plugins/techdocs/src/reader/components/Reader.tsx b/plugins/techdocs/src/reader/components/Reader.tsx
deleted file mode 100644
index 13157459f4..0000000000
--- a/plugins/techdocs/src/reader/components/Reader.tsx
+++ /dev/null
@@ -1,953 +0,0 @@
-/*
- * Copyright 2020 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 React, {
- PropsWithChildren,
- ComponentType,
- createContext,
- useContext,
- useCallback,
- useEffect,
- useRef,
- useState,
-} from 'react';
-import { useNavigate, useParams } from 'react-router-dom';
-import {
- Grid,
- makeStyles,
- useTheme,
- Theme,
- lighten,
- alpha,
-} from '@material-ui/core';
-
-import { CompoundEntityRef } from '@backstage/catalog-model';
-import { useApi, configApiRef } from '@backstage/core-plugin-api';
-import { scmIntegrationsApiRef } from '@backstage/integration-react';
-import { BackstageTheme } from '@backstage/theme';
-import {
- sidebarConfig,
- SidebarPinStateContext,
-} from '@backstage/core-components';
-
-import { techdocsStorageApiRef } from '../../api';
-
-import {
- addBaseUrl,
- addGitFeedbackLink,
- addLinkClickListener,
- addSidebarToggle,
- injectCss,
- onCssReady,
- removeMkdocsHeader,
- rewriteDocLinks,
- sanitizeDOM,
- simplifyMkdocsFooter,
- scrollIntoAnchor,
- transform as transformer,
- copyToClipboard,
-} from '../transformers';
-
-import { TechDocsSearch } from '../../search';
-import { TechDocsStateIndicator } from './TechDocsStateIndicator';
-import { useReaderState } from './useReaderState';
-
-/**
- * Props for {@link Reader}
- *
- * @public
- */
-export type ReaderProps = {
- entityRef: CompoundEntityRef;
- withSearch?: boolean;
- onReady?: () => void;
-};
-
-const useStyles = makeStyles(theme => ({
- searchBar: {
- maxWidth: 'calc(100% - 16rem * 2 - 2.4rem)',
- marginTop: 0,
- marginBottom: theme.spacing(1),
- marginLeft: 'calc(16rem + 1.2rem)',
- '@media screen and (max-width: 76.1875em)': {
- marginLeft: '0',
- maxWidth: '100%',
- },
- },
-}));
-
-type TechDocsReaderValue = ReturnType;
-
-const TechDocsReaderContext = createContext(
- {} as TechDocsReaderValue,
-);
-
-const TechDocsReaderProvider = ({
- children,
- entityRef,
-}: PropsWithChildren<{ entityRef: CompoundEntityRef }>) => {
- const { '*': path } = useParams();
- const { kind, namespace, name } = entityRef;
- const value = useReaderState(kind, namespace, name, path);
- return (
-
- {children}
-
- );
-};
-
-/**
- * Note: this HOC is currently being exported so that we can rapidly
- * iterate on alternative implementations that extend core
- * functionality. There is no guarantee that this HOC will continue to be
- * exported by the package in the future!
- *
- * todo: Make public or stop exporting (ctrl+f "altReaderExperiments")
- * @internal
- */
-export const withTechDocsReaderProvider =
- (Component: ComponentType, entityRef: CompoundEntityRef) =>
- (props: T) =>
- (
-
-
-
- );
-
-/**
- * Note: this hook is currently being exported so that we can rapidly
- * iterate on alternative implementations that extend core
- * functionality. There is no guarantee that this hook will continue to be
- * exported by the package in the future!
- *
- * todo: Make public or stop exporting (ctrl+f "altReaderExperiments")
- * @internal
- */
-export const useTechDocsReader = () => useContext(TechDocsReaderContext);
-
-type TypographyHeadings = Pick<
- Theme['typography'],
- 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
->;
-
-type TypographyHeadingsKeys = keyof TypographyHeadings;
-
-const headings: TypographyHeadingsKeys[] = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'];
-
-/**
- * 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
- * Backstage UI.
- *
- * Note: this hook is currently being exported so that we can rapidly iterate
- * on alternative implementations that extend core functionality.
- * There is no guarantee that this hook will continue to be exported by the
- * package in the future!
- *
- * todo: Make public or stop exporting (see others: "altReaderExperiments")
- * @internal
- */
-export const useTechDocsReaderDom = (
- entityRef: CompoundEntityRef,
-): Element | null => {
- const navigate = useNavigate();
- const theme = useTheme();
- const techdocsStorageApi = useApi(techdocsStorageApiRef);
- const scmIntegrationsApi = useApi(scmIntegrationsApiRef);
- const techdocsSanitizer = useApi(configApiRef);
- const { namespace = '', kind = '', name = '' } = entityRef;
- const { state, path, content: rawPage } = useTechDocsReader();
- const isDarkTheme = theme.palette.type === 'dark';
-
- const [sidebars, setSidebars] = useState();
- const [dom, setDom] = useState(null);
-
- // sidebar pinned status to be used in computing CSS style injections
- const { isPinned } = useContext(SidebarPinStateContext);
-
- const updateSidebarPosition = useCallback(() => {
- if (!dom || !sidebars) return;
- // set sidebar height so they don't initially render in wrong position
- const mdTabs = dom.querySelector('.md-container > .md-tabs');
- const sidebarsCollapsed = window.matchMedia(
- 'screen and (max-width: 76.1875em)',
- ).matches;
- const newTop = Math.max(dom.getBoundingClientRect().top, 0);
- sidebars.forEach(sidebar => {
- if (sidebarsCollapsed) {
- sidebar.style.top = '0px';
- } else if (mdTabs) {
- sidebar.style.top = `${
- newTop + mdTabs.getBoundingClientRect().height
- }px`;
- } else {
- sidebar.style.top = `${newTop}px`;
- }
- });
- }, [dom, sidebars]);
-
- useEffect(() => {
- updateSidebarPosition();
- window.addEventListener('scroll', updateSidebarPosition, true);
- window.addEventListener('resize', updateSidebarPosition);
- return () => {
- window.removeEventListener('scroll', updateSidebarPosition, true);
- window.removeEventListener('resize', updateSidebarPosition);
- };
- // an update to "state" might lead to an updated UI so we include it as a trigger
- }, [updateSidebarPosition, state]);
-
- // dynamically set width of footer to accommodate for pinning of the sidebar
- const updateFooterWidth = useCallback(() => {
- if (!dom) return;
- const footer = dom.querySelector('.md-footer') as HTMLElement;
- if (footer) {
- footer.style.width = `${dom.getBoundingClientRect().width}px`;
- }
- }, [dom]);
-
- useEffect(() => {
- updateFooterWidth();
- window.addEventListener('resize', updateFooterWidth);
- return () => {
- window.removeEventListener('resize', updateFooterWidth);
- };
- });
-
- // a function that performs transformations that are executed prior to adding it to the DOM
- const preRender = useCallback(
- (rawContent: string, contentPath: string) =>
- transformer(rawContent, [
- sanitizeDOM(techdocsSanitizer.getOptionalConfig('techdocs.sanitizer')),
- addBaseUrl({
- techdocsStorageApi,
- entityId: {
- kind,
- name,
- namespace,
- },
- path: contentPath,
- }),
- rewriteDocLinks(),
- addSidebarToggle(),
- removeMkdocsHeader(),
- simplifyMkdocsFooter(),
- addGitFeedbackLink(scmIntegrationsApi),
- injectCss({
- // Variables
- css: `
- /*
- As the MkDocs output is rendered in shadow DOM, the CSS variable definitions on the root selector are not applied. Instead, they have to be applied on :host.
- As there is no way to transform the served main*.css yet (for example in the backend), we have to copy from main*.css and modify them.
- */
- :host {
- /* FONT */
- --md-default-fg-color: ${theme.palette.text.primary};
- --md-default-fg-color--light: ${theme.palette.text.secondary};
- --md-default-fg-color--lighter: ${lighten(
- theme.palette.text.secondary,
- 0.7,
- )};
- --md-default-fg-color--lightest: ${lighten(
- theme.palette.text.secondary,
- 0.3,
- )};
-
- /* BACKGROUND */
- --md-default-bg-color:${theme.palette.background.default};
- --md-default-bg-color--light: ${theme.palette.background.paper};
- --md-default-bg-color--lighter: ${lighten(
- theme.palette.background.paper,
- 0.7,
- )};
- --md-default-bg-color--lightest: ${lighten(
- theme.palette.background.paper,
- 0.3,
- )};
-
- /* PRIMARY */
- --md-primary-fg-color: ${theme.palette.primary.main};
- --md-primary-fg-color--light: ${theme.palette.primary.light};
- --md-primary-fg-color--dark: ${theme.palette.primary.dark};
- --md-primary-bg-color: ${theme.palette.primary.contrastText};
- --md-primary-bg-color--light: ${lighten(
- theme.palette.primary.contrastText,
- 0.7,
- )};
-
- /* ACCENT */
- --md-accent-fg-color: var(--md-primary-fg-color);
-
- /* SHADOW */
- --md-shadow-z1: ${theme.shadows[1]};
- --md-shadow-z2: ${theme.shadows[2]};
- --md-shadow-z3: ${theme.shadows[3]};
-
- /* EXTENSIONS */
- --md-admonition-fg-color: var(--md-default-fg-color);
- --md-admonition-bg-color: var(--md-default-bg-color);
- /* Admonitions and others are using SVG masks to define icons. These masks are defined as CSS variables. */
- --md-admonition-icon--note: url('data:image/svg+xml;charset=utf-8,');
- --md-admonition-icon--abstract: url('data:image/svg+xml;charset=utf-8,');
- --md-admonition-icon--info: url('data:image/svg+xml;charset=utf-8,');
- --md-admonition-icon--tip: url('data:image/svg+xml;charset=utf-8,');
- --md-admonition-icon--success: url('data:image/svg+xml;charset=utf-8,');
- --md-admonition-icon--question: url('data:image/svg+xml;charset=utf-8,');
- --md-admonition-icon--warning: url('data:image/svg+xml;charset=utf-8,');
- --md-admonition-icon--failure: url('data:image/svg+xml;charset=utf-8,');
- --md-admonition-icon--danger: url('data:image/svg+xml;charset=utf-8,');
- --md-admonition-icon--bug: url('data:image/svg+xml;charset=utf-8,');
- --md-admonition-icon--example: url('data:image/svg+xml;charset=utf-8,');
- --md-admonition-icon--quote: url('data:image/svg+xml;charset=utf-8,');
- --md-footnotes-icon: url('data:image/svg+xml;charset=utf-8,');
- --md-details-icon: url('data:image/svg+xml;charset=utf-8,');
- --md-tasklist-icon: url('data:image/svg+xml;charset=utf-8,');
- --md-tasklist-icon--checked: url('data:image/svg+xml;charset=utf-8,');
- --md-nav-icon--prev: url('data:image/svg+xml;charset=utf-8,');
- --md-nav-icon--next: url('data:image/svg+xml;charset=utf-8,');
- --md-toc-icon: url('data:image/svg+xml;charset=utf-8,');
- --md-clipboard-icon: url('data:image/svg+xml;charset=utf-8,');
- --md-search-result-icon: url('data:image/svg+xml;charset=utf-8,');
- --md-source-forks-icon: url('data:image/svg+xml;charset=utf-8,');
- --md-source-repositories-icon: url('data:image/svg+xml;charset=utf-8,');
- --md-source-stars-icon: url('data:image/svg+xml;charset=utf-8,');
- --md-source-version-icon: url('data:image/svg+xml;charset=utf-8,');
- --md-version-icon: url('data:image/svg+xml;charset=utf-8,');
- }
-
- :host > * {
- /* CODE */
- --md-code-fg-color: ${theme.palette.text.primary};
- --md-code-bg-color: ${theme.palette.background.paper};
- --md-code-hl-color: ${alpha(theme.palette.warning.main, 0.5)};
- --md-code-hl-keyword-color: ${
- isDarkTheme
- ? theme.palette.primary.light
- : theme.palette.primary.dark
- };
- --md-code-hl-function-color: ${
- isDarkTheme
- ? theme.palette.secondary.light
- : theme.palette.secondary.dark
- };
- --md-code-hl-string-color: ${
- isDarkTheme
- ? theme.palette.success.light
- : theme.palette.success.dark
- };
- --md-code-hl-number-color: ${
- isDarkTheme ? theme.palette.error.light : theme.palette.error.dark
- };
- --md-code-hl-constant-color: var(--md-code-hl-function-color);
- --md-code-hl-special-color: var(--md-code-hl-function-color);
- --md-code-hl-name-color: var(--md-code-fg-color);
- --md-code-hl-comment-color: var(--md-default-fg-color--light);
- --md-code-hl-generic-color: var(--md-default-fg-color--light);
- --md-code-hl-variable-color: var(--md-default-fg-color--light);
- --md-code-hl-operator-color: var(--md-default-fg-color--light);
- --md-code-hl-punctuation-color: var(--md-default-fg-color--light);
-
- /* TYPESET */
- --md-typeset-font-size: 1rem;
- --md-typeset-color: var(--md-default-fg-color);
- --md-typeset-a-color: var(--md-accent-fg-color);
- --md-typeset-table-color: ${theme.palette.text.primary};
- --md-typeset-del-color: ${
- isDarkTheme
- ? alpha(theme.palette.error.dark, 0.5)
- : alpha(theme.palette.error.light, 0.5)
- };
- --md-typeset-ins-color: ${
- isDarkTheme
- ? alpha(theme.palette.success.dark, 0.5)
- : alpha(theme.palette.success.light, 0.5)
- };
- --md-typeset-mark-color: ${
- isDarkTheme
- ? alpha(theme.palette.warning.dark, 0.5)
- : alpha(theme.palette.warning.light, 0.5)
- };
- }
-
- @media screen and (max-width: 76.1875em) {
- :host > * {
- /* TYPESET */
- --md-typeset-font-size: .9rem;
- }
- }
-
- @media screen and (max-width: 600px) {
- :host > * {
- /* TYPESET */
- --md-typeset-font-size: .7rem;
- }
- }
- `,
- }),
- injectCss({
- // Reset
- css: `
- body {
- --md-text-color: var(--md-default-fg-color);
- --md-text-link-color: var(--md-accent-fg-color);
- --md-text-font-family: ${theme.typography.fontFamily};
- font-family: var(--md-text-font-family);
- background-color: unset;
- }
- `,
- }),
- injectCss({
- // Layout
- css: `
- .md-grid {
- max-width: 100%;
- margin: 0;
- }
-
- .md-nav {
- font-size: calc(var(--md-typeset-font-size) * 0.9);
- }
- .md-nav__link {
- display: flex;
- align-items: center;
- justify-content: space-between;
- }
- .md-nav__icon {
- height: 20px !important;
- width: 20px !important;
- margin-left:${theme.spacing(1)}px;
- }
- .md-nav__icon svg {
- margin: 0;
- width: 20px !important;
- height: 20px !important;
- }
- .md-nav__icon:after {
- width: 20px !important;
- height: 20px !important;
- }
-
- .md-main__inner {
- margin-top: 0;
- }
-
- .md-sidebar {
- bottom: 75px;
- position: fixed;
- width: 16rem;
- overflow-y: auto;
- overflow-x: hidden;
- scrollbar-color: rgb(193, 193, 193) #eee;
- scrollbar-width: thin;
- }
- .md-sidebar::-webkit-scrollbar {
- width: 5px;
- }
- .md-sidebar::-webkit-scrollbar-button {
- width: 5px;
- height: 5px;
- }
- .md-sidebar::-webkit-scrollbar-track {
- background: #eee;
- border: 1 px solid rgb(250, 250, 250);
- box-shadow: 0px 0px 3px #dfdfdf inset;
- border-radius: 3px;
- }
- .md-sidebar::-webkit-scrollbar-thumb {
- width: 5px;
- background: rgb(193, 193, 193);
- border: transparent;
- border-radius: 3px;
- }
- .md-sidebar::-webkit-scrollbar-thumb:hover {
- background: rgb(125, 125, 125);
- }
- .md-sidebar--secondary {
- right: ${theme.spacing(3)}px;
- }
- .md-sidebar__scrollwrap {
- overflow: unset !important;
- }
-
- .md-content {
- max-width: calc(100% - 16rem * 2);
- margin-left: 16rem;
- margin-bottom: 50px;
- }
-
- .md-footer {
- position: fixed;
- bottom: 0px;
- }
- .md-footer__title {
- background-color: unset;
- }
- .md-footer__link, .md-footer-nav__link {
- width: 16rem;
- }
-
- .md-dialog {
- background-color: unset;
- }
-
- @media screen and (min-width: 76.25em) {
- .md-sidebar {
- height: auto;
- }
- }
-
- @media screen and (max-width: 76.1875em) {
- .md-nav {
- transition: none !important;
- background-color: var(--md-default-bg-color)
- }
- .md-nav--primary .md-nav__title {
- cursor: auto;
- color: var(--md-default-fg-color);
- font-weight: 700;
- white-space: normal;
- line-height: 1rem;
- height: auto;
- display: flex;
- flex-flow: column;
- row-gap: 1.6rem;
- padding: 1.2rem .8rem .8rem;
- background-color: var(--md-default-bg-color);
- }
- .md-nav--primary .md-nav__title~.md-nav__list {
- box-shadow: none;
- }
- .md-nav--primary .md-nav__title ~ .md-nav__list > :first-child {
- border-top: none;
- }
- .md-nav--primary .md-nav__title .md-nav__button {
- display: none;
- }
- .md-nav--primary .md-nav__title .md-nav__icon {
- color: var(--md-default-fg-color);
- position: static;
- height: auto;
- margin: 0 0 0 -0.2rem;
- }
- .md-nav--primary > .md-nav__title [for="none"] {
- padding-top: 0;
- }
- .md-nav--primary .md-nav__item {
- border-top: none;
- }
- .md-nav--primary :is(.md-nav__title,.md-nav__item) {
- font-size : var(--md-typeset-font-size);
- }
- .md-nav .md-source {
- display: none;
- }
-
- .md-sidebar {
- height: 100%;
- }
- .md-sidebar--primary {
- width: 16rem !important;
- z-index: 200;
- left: ${
- isPinned
- ? `calc(-16rem + ${sidebarConfig.drawerWidthOpen}px)`
- : `calc(-16rem + ${sidebarConfig.drawerWidthClosed}px)`
- } !important;
- }
- .md-sidebar--secondary:not([hidden]) {
- display: none;
- }
- [data-md-toggle=drawer]:checked~.md-container .md-sidebar--primary {
- transform: translateX(16rem);
- }
-
- .md-content {
- max-width: 100%;
- margin-left: 0;
- }
- .md-content__inner {
- margin: 0;
- }
- .md-content__inner .highlighttable {
- max-width: 100%;
- margin: 1em 0;
- }
-
- .md-header__button {
- margin: 0.4rem 0;
- margin-left: 0.4rem;
- padding: 0;
- }
-
- .md-overlay {
- left: 0;
- }
-
- .md-footer {
- position: static;
- padding-left: 0;
- }
- .md-footer__link, .md-footer-nav__link {
- /* footer links begin to overlap at small sizes without setting width */
- width: 50%;
- }
- }
-
- @media screen and (max-width: 600px) {
- .md-sidebar--primary {
- left: -16rem !important;
- width: 16rem;
- }
- .md-sidebar--primary .md-sidebar__scrollwrap {
- bottom: ${sidebarConfig.mobileSidebarHeight}px;
- }
- }
- `,
- }),
- injectCss({
- // Typeset
- css: `
- .md-typeset {
- font-size: var(--md-typeset-font-size);
- }
-
- ${headings.reduce((style, heading) => {
- const styles = theme.typography[heading];
- const { lineHeight, fontFamily, fontWeight, fontSize } = styles;
- const calculate = (value: typeof fontSize) => {
- let factor: number | string = 1;
- if (typeof value === 'number') {
- // 60% of the size defined because it is too big
- factor = (value / 16) * 0.6;
- }
- if (typeof value === 'string') {
- factor = value.replace('rem', '');
- }
- return `calc(${factor} * var(--md-typeset-font-size))`;
- };
- return style.concat(`
- .md-typeset ${heading} {
- color: var(--md-default-fg-color);
- line-height: ${lineHeight};
- font-family: ${fontFamily};
- font-weight: ${fontWeight};
- font-size: ${calculate(fontSize)};
- }
- `);
- }, '')}
-
- .md-typeset .md-content__button {
- color: var(--md-default-fg-color);
- }
-
- .md-typeset hr {
- border-bottom: 0.05rem dotted ${theme.palette.divider};
- }
-
- .md-typeset details {
- font-size: var(--md-typeset-font-size) !important;
- }
- .md-typeset details summary {
- padding-left: 2.5rem !important;
- }
- .md-typeset details summary:before,
- .md-typeset details summary:after {
- top: 50% !important;
- width: 20px !important;
- height: 20px !important;
- transform: rotate(0deg) translateY(-50%) !important;
- }
- .md-typeset details[open] > summary:after {
- transform: rotate(90deg) translateX(-50%) !important;
- }
-
- .md-typeset blockquote {
- color: var(--md-default-fg-color--light);
- border-left: 0.2rem solid var(--md-default-fg-color--light);
- }
-
- .md-typeset table:not([class]) {
- font-size: var(--md-typeset-font-size);
- border: 1px solid var(--md-default-fg-color);
- border-bottom: none;
- border-collapse: collapse;
- }
- .md-typeset table:not([class]) th {
- font-weight: bold;
- }
- .md-typeset table:not([class]) td, .md-typeset table:not([class]) th {
- border-bottom: 1px solid var(--md-default-fg-color);
- }
-
- .md-typeset pre > code::-webkit-scrollbar-thumb {
- background-color: hsla(0, 0%, 0%, 0.32);
- }
- .md-typeset pre > code::-webkit-scrollbar-thumb:hover {
- background-color: hsla(0, 0%, 0%, 0.87);
- }
- `,
- }),
- injectCss({
- // Animations
- css: `
- /*
- Disable CSS animations on link colors as they lead to issues in dark mode.
- The dark mode color theme is applied later and theirfore there is always an animation from light to dark mode when navigation between pages.
- */
- .md-dialog, .md-nav__link, .md-footer__link, .md-typeset a, .md-typeset a::before, .md-typeset .headerlink {
- transition: none;
- }
- `,
- }),
- injectCss({
- // Extensions
- css: `
- /* HIGHLIGHT */
- .highlight .md-clipboard:after {
- content: unset;
- }
-
- .highlight .nx {
- color: ${isDarkTheme ? '#ff53a3' : '#ec407a'};
- }
-
- /* CODE HILITE */
- .codehilite .gd {
- background-color: ${
- isDarkTheme ? 'rgba(248,81,73,0.65)' : '#fdd'
- };
- }
-
- .codehilite .gi {
- background-color: ${
- isDarkTheme ? 'rgba(46,160,67,0.65)' : '#dfd'
- };
- }
-
- /* TABBED */
- .tabbed-set>input:nth-child(1):checked~.tabbed-labels>:nth-child(1),
- .tabbed-set>input:nth-child(2):checked~.tabbed-labels>:nth-child(2),
- .tabbed-set>input:nth-child(3):checked~.tabbed-labels>:nth-child(3),
- .tabbed-set>input:nth-child(4):checked~.tabbed-labels>:nth-child(4),
- .tabbed-set>input:nth-child(5):checked~.tabbed-labels>:nth-child(5),
- .tabbed-set>input:nth-child(6):checked~.tabbed-labels>:nth-child(6),
- .tabbed-set>input:nth-child(7):checked~.tabbed-labels>:nth-child(7),
- .tabbed-set>input:nth-child(8):checked~.tabbed-labels>:nth-child(8),
- .tabbed-set>input:nth-child(9):checked~.tabbed-labels>:nth-child(9),
- .tabbed-set>input:nth-child(10):checked~.tabbed-labels>:nth-child(10),
- .tabbed-set>input:nth-child(11):checked~.tabbed-labels>:nth-child(11),
- .tabbed-set>input:nth-child(12):checked~.tabbed-labels>:nth-child(12),
- .tabbed-set>input:nth-child(13):checked~.tabbed-labels>:nth-child(13),
- .tabbed-set>input:nth-child(14):checked~.tabbed-labels>:nth-child(14),
- .tabbed-set>input:nth-child(15):checked~.tabbed-labels>:nth-child(15),
- .tabbed-set>input:nth-child(16):checked~.tabbed-labels>:nth-child(16),
- .tabbed-set>input:nth-child(17):checked~.tabbed-labels>:nth-child(17),
- .tabbed-set>input:nth-child(18):checked~.tabbed-labels>:nth-child(18),
- .tabbed-set>input:nth-child(19):checked~.tabbed-labels>:nth-child(19),
- .tabbed-set>input:nth-child(20):checked~.tabbed-labels>:nth-child(20) {
- color: var(--md-accent-fg-color);
- border-color: var(--md-accent-fg-color);
- }
-
- /* TASK-LIST */
- .task-list-control .task-list-indicator::before {
- background-color: ${theme.palette.action.disabledBackground};
- }
- .task-list-control [type="checkbox"]:checked + .task-list-indicator:before {
- background-color: ${theme.palette.success.main};
- }
-
- /* ADMONITION */
- .admonition {
- font-size: var(--md-typeset-font-size) !important;
- }
- .admonition .admonition-title {
- padding-left: 2.5rem !important;
- }
-
- .admonition .admonition-title:before {
- top: 50% !important;
- width: 20px !important;
- height: 20px !important;
- transform: translateY(-50%) !important;
- }
- `,
- }),
- ]),
- [
- kind,
- name,
- namespace,
- scmIntegrationsApi,
- techdocsSanitizer,
- techdocsStorageApi,
- theme,
- isDarkTheme,
- isPinned,
- ],
- );
-
- // a function that performs transformations that are executed after adding it to the DOM
- const postRender = useCallback(
- async (transformedElement: Element) =>
- transformer(transformedElement, [
- scrollIntoAnchor(),
- copyToClipboard(theme),
- addLinkClickListener({
- baseUrl: window.location.origin,
- onClick: (event: MouseEvent, url: string) => {
- // detect if CTRL or META keys are pressed so that links can be opened in a new tab with `window.open`
- const modifierActive = event.ctrlKey || event.metaKey;
- const parsedUrl = new URL(url);
-
- // hash exists when anchor is clicked on secondary sidebar
- if (parsedUrl.hash) {
- if (modifierActive) {
- window.open(`${parsedUrl.pathname}${parsedUrl.hash}`, '_blank');
- } else {
- navigate(`${parsedUrl.pathname}${parsedUrl.hash}`);
- // Scroll to hash if it's on the current page
- transformedElement
- ?.querySelector(`[id='${parsedUrl.hash.slice(1)}']`)
- ?.scrollIntoView();
- }
- } else {
- if (modifierActive) {
- window.open(parsedUrl.pathname, '_blank');
- } else {
- navigate(parsedUrl.pathname);
- // Scroll to top of reader if primary sidebar link is clicked
- transformedElement
- ?.querySelector('.md-content__inner')
- ?.scrollIntoView();
- }
- }
- },
- }),
- onCssReady({
- docStorageUrl: await techdocsStorageApi.getApiOrigin(),
- onLoading: (renderedElement: Element) => {
- (renderedElement as HTMLElement).style.setProperty('opacity', '0');
- },
- onLoaded: (renderedElement: Element) => {
- (renderedElement as HTMLElement).style.removeProperty('opacity');
- // disable MkDocs drawer toggling ('for' attribute => checkbox mechanism)
- renderedElement
- .querySelector('.md-nav__title')
- ?.removeAttribute('for');
- setSidebars(
- Array.from(renderedElement.querySelectorAll('.md-sidebar')),
- );
- },
- }),
- ]),
- [theme, navigate, techdocsStorageApi],
- );
-
- useEffect(() => {
- if (!rawPage) return () => {};
-
- // if false, there is already a newer execution of this effect
- let shouldReplaceContent = true;
-
- // Pre-render
- preRender(rawPage, path).then(async preTransformedDomElement => {
- if (!preTransformedDomElement?.innerHTML) {
- return; // An unexpected error occurred
- }
-
- // don't manipulate the shadow dom if this isn't the latest effect execution
- if (!shouldReplaceContent) {
- return;
- }
-
- // Scroll to top after render
- window.scroll({ top: 0 });
-
- // Post-render
- const postTransformedDomElement = await postRender(
- preTransformedDomElement,
- );
- setDom(postTransformedDomElement as HTMLElement);
- });
-
- // cancel this execution
- return () => {
- shouldReplaceContent = false;
- };
- }, [rawPage, path, preRender, postRender]);
-
- return dom;
-};
-
-const TheReader = ({
- entityRef,
- onReady = () => {},
- withSearch = true,
-}: ReaderProps) => {
- const classes = useStyles();
- const dom = useTechDocsReaderDom(entityRef);
- const shadowDomRef = useRef(null);
-
- const onReadyRef = useRef<() => void>(onReady);
- useEffect(() => {
- onReadyRef.current = onReady;
- }, [onReady]);
-
- useEffect(() => {
- if (!dom || !shadowDomRef.current) return;
- const shadowDiv = shadowDomRef.current;
- const shadowRoot =
- shadowDiv.shadowRoot || shadowDiv.attachShadow({ mode: 'open' });
- Array.from(shadowRoot.children).forEach(child =>
- shadowRoot.removeChild(child),
- );
- shadowRoot.appendChild(dom);
- onReadyRef.current();
-
- // this hook must ONLY be triggered by a changed dom
- }, [dom]);
-
- return (
- <>
-
- {withSearch && shadowDomRef?.current?.shadowRoot?.innerHTML && (
-
-
-
- )}
-
- >
- );
-};
-
-/**
- * Component responsible for rendering TechDocs documentation
- *
- * @public
- */
-export const Reader = (props: ReaderProps) => {
- const { entityRef, onReady = () => {}, withSearch = true } = props;
- return (
-
-
-
- );
-};
diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage.test.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPage.test.tsx
deleted file mode 100644
index 9fbf5c2195..0000000000
--- a/plugins/techdocs/src/reader/components/TechDocsReaderPage.test.tsx
+++ /dev/null
@@ -1,176 +0,0 @@
-/*
- * Copyright 2020 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 React from 'react';
-import { TechDocsReaderPage } from './TechDocsReaderPage';
-import { render, act } from '@testing-library/react';
-import { ConfigReader } from '@backstage/config';
-import {
- ScmIntegrationsApi,
- scmIntegrationsApiRef,
-} from '@backstage/integration-react';
-import { TestApiRegistry, wrapInTestApp } from '@backstage/test-utils';
-import { Header } from '@backstage/core-components';
-import {
- techdocsApiRef,
- TechDocsApi,
- techdocsStorageApiRef,
- TechDocsStorageApi,
-} from '../../api';
-import { ApiProvider } from '@backstage/core-app-api';
-import { searchApiRef } from '@backstage/plugin-search-react';
-
-jest.mock('react-router-dom', () => {
- const actual = jest.requireActual('react-router-dom');
- return {
- ...actual,
- useParams: jest.fn(),
- };
-});
-
-jest.mock('./TechDocsReaderPageHeader', () => {
- return {
- __esModule: true,
- TechDocsReaderPageHeader: () => ,
- };
-});
-
-const { useParams }: { useParams: jest.Mock } =
- jest.requireMock('react-router-dom');
-global.scroll = jest.fn();
-
-describe('', () => {
- it('should render techdocs page', async () => {
- useParams.mockReturnValue({
- entityRef: 'Component::backstage',
- });
-
- const scmIntegrationsApi: ScmIntegrationsApi =
- ScmIntegrationsApi.fromConfig(
- new ConfigReader({
- integrations: {},
- }),
- );
- const techdocsApi: Partial = {
- getEntityMetadata: () =>
- Promise.resolve({
- apiVersion: 'v1',
- kind: 'Component',
- metadata: {
- name: 'backstage',
- },
- }),
- getTechDocsMetadata: () =>
- Promise.resolve({
- site_name: 'string',
- site_description: 'string',
- }),
- };
-
- const techdocsStorageApi: Partial = {
- getEntityDocs: (): Promise => Promise.resolve('String'),
- getBaseUrl: (): Promise => Promise.resolve('String'),
- getApiOrigin: (): Promise => Promise.resolve('String'),
- };
- const searchApi = {
- query: () =>
- Promise.resolve({
- results: [],
- }),
- };
- const apiRegistry = TestApiRegistry.from(
- [scmIntegrationsApiRef, scmIntegrationsApi],
- [techdocsApiRef, techdocsApi],
- [techdocsStorageApiRef, techdocsStorageApi],
- [searchApiRef, searchApi],
- );
-
- await act(async () => {
- const rendered = render(
- wrapInTestApp(
-
-
- ,
- ),
- );
- expect(rendered.getByTestId('techdocs-content')).toBeInTheDocument();
- });
- });
-
- it('should render techdocs page with custom header', async () => {
- useParams.mockReturnValue({
- entityRef: 'Component::backstage',
- });
-
- const scmIntegrationsApi: ScmIntegrationsApi =
- ScmIntegrationsApi.fromConfig(
- new ConfigReader({
- integrations: {},
- }),
- );
- const techdocsApi: Partial = {
- getEntityMetadata: () =>
- Promise.resolve({
- apiVersion: 'v1',
- kind: 'Component',
- metadata: {
- name: 'backstage',
- },
- }),
- getTechDocsMetadata: () =>
- Promise.resolve({
- site_name: 'string',
- site_description: 'string',
- }),
- };
-
- const techdocsStorageApi: Partial = {
- getEntityDocs: (): Promise => Promise.resolve('String'),
- getBaseUrl: (): Promise => Promise.resolve('String'),
- getApiOrigin: (): Promise => Promise.resolve('String'),
- };
- const searchApi = {
- query: () =>
- Promise.resolve({
- results: [],
- }),
- };
- const apiRegistry = TestApiRegistry.from(
- [scmIntegrationsApiRef, scmIntegrationsApi],
- [techdocsApiRef, techdocsApi],
- [techdocsStorageApiRef, techdocsStorageApi],
- [searchApiRef, searchApi],
- );
-
- await act(async () => {
- const rendered = render(
- wrapInTestApp(
-
-
- {({ techdocsMetadataValue }) => (
-
- )}
-
- ,
- ),
- );
- expect(rendered.getByText('A custom header')).toBeInTheDocument();
- });
- });
-});
diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPage.tsx
deleted file mode 100644
index 1bfd0e6f51..0000000000
--- a/plugins/techdocs/src/reader/components/TechDocsReaderPage.tsx
+++ /dev/null
@@ -1,122 +0,0 @@
-/*
- * Copyright 2020 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 React, { useCallback, useState } from 'react';
-import { useOutlet } from 'react-router';
-import { useParams } from 'react-router-dom';
-import useAsync from 'react-use/lib/useAsync';
-import { Reader } from './Reader';
-import { TechDocsReaderPageHeader } from './TechDocsReaderPageHeader';
-import { techdocsApiRef } from '../../api';
-import { TechDocsEntityMetadata, TechDocsMetadata } from '../../types';
-import { CompoundEntityRef } from '@backstage/catalog-model';
-import { useApi, useApp } from '@backstage/core-plugin-api';
-import { Page, Content } from '@backstage/core-components';
-
-/**
- * Helper function that gives the children of {@link TechDocsReaderPage} access to techdocs and entity metadata
- *
- * @public
- */
-export type TechDocsReaderPageRenderFunction = ({
- techdocsMetadataValue,
- entityMetadataValue,
- entityRef,
-}: {
- techdocsMetadataValue?: TechDocsMetadata | undefined;
- entityMetadataValue?: TechDocsEntityMetadata | undefined;
- entityRef: CompoundEntityRef;
- onReady: () => void;
-}) => JSX.Element;
-
-/**
- * Props for {@link TechDocsReaderPage}
- *
- * @public
- */
-export type TechDocsReaderPageProps = {
- children?: TechDocsReaderPageRenderFunction | React.ReactNode;
-};
-
-export const TechDocsReaderPage = (props: TechDocsReaderPageProps) => {
- const { children } = props;
- const { NotFoundErrorPage } = useApp().getComponents();
- const outlet = useOutlet();
-
- const [documentReady, setDocumentReady] = useState(false);
- const { namespace, kind, name } = useParams();
-
- const techdocsApi = useApi(techdocsApiRef);
-
- const { value: techdocsMetadataValue } = useAsync(() => {
- if (documentReady) {
- return techdocsApi.getTechDocsMetadata({ kind, namespace, name });
- }
-
- return Promise.resolve(undefined);
- }, [kind, namespace, name, techdocsApi, documentReady]);
-
- const { value: entityMetadataValue, error: entityMetadataError } =
- useAsync(() => {
- return techdocsApi.getEntityMetadata({ kind, namespace, name });
- }, [kind, namespace, name, techdocsApi]);
-
- const onReady = useCallback(() => {
- setDocumentReady(true);
- }, [setDocumentReady]);
-
- if (entityMetadataError) return ;
-
- if (!children)
- return (
- outlet || (
-
-
-
-
-
-
- )
- );
-
- return (
-
- {children instanceof Function
- ? children({
- techdocsMetadataValue,
- entityMetadataValue,
- entityRef: { kind, namespace, name },
- onReady,
- })
- : children}
-
- );
-};
diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.test.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.test.tsx
new file mode 100644
index 0000000000..4b23075908
--- /dev/null
+++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.test.tsx
@@ -0,0 +1,149 @@
+/*
+ * Copyright 2020 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 React from 'react';
+import { act } from '@testing-library/react';
+import { ThemeProvider } from '@material-ui/core';
+import { scmIntegrationsApiRef } from '@backstage/integration-react';
+
+import { lightTheme } from '@backstage/theme';
+import { entityRouteRef } from '@backstage/plugin-catalog-react';
+import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
+
+import { techdocsApiRef, techdocsStorageApiRef } from '../../../api';
+
+import { rootRouteRef, rootDocsRouteRef } from '../../../routes';
+
+import { TechDocsReaderPage } from './TechDocsReaderPage';
+
+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 techdocsStorageApiMock: jest.Mocked = {
+ getApiOrigin: jest.fn(),
+ getBaseUrl: jest.fn(),
+ getBuilder: jest.fn(),
+ getEntityDocs: jest.fn(),
+ getStorageUrl: jest.fn(),
+ syncEntityDocs: jest.fn(),
+};
+
+const Wrapper = ({ children }: { children: React.ReactNode }) => {
+ return (
+
+
+ {children}
+
+
+ );
+};
+
+const mountedRoutes = {
+ '/catalog/:namespace/:kind/:name/*': entityRouteRef,
+ '/docs': rootRouteRef,
+ '/docs/:namespace/:kind/:name/*': rootDocsRouteRef,
+};
+
+describe('', () => {
+ beforeEach(() => {
+ getEntityMetadata.mockResolvedValue(mockEntityMetadata);
+ getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata);
+ });
+
+ afterEach(() => {
+ jest.resetAllMocks();
+ });
+ it('should render a techdocs reader page without children', async () => {
+ const rendered = await renderInTestApp(
+
+
+ ,
+ {
+ mountedRoutes,
+ },
+ );
+
+ // TechDocsReaderPageHeader
+ expect(rendered.container.querySelector('header')).toBeInTheDocument();
+ // TechDocsReaderPageContent
+ expect(rendered.container.querySelector('article')).toBeInTheDocument();
+ });
+
+ it('should render a techdocs reader page with children', async () => {
+ await act(async () => {
+ const rendered = await renderInTestApp(
+
+
+ techdocs reader page
+
+ ,
+ {
+ mountedRoutes,
+ },
+ );
+ expect(
+ rendered.container.querySelector('header'),
+ ).not.toBeInTheDocument();
+ expect(
+ rendered.container.querySelector('article'),
+ ).not.toBeInTheDocument();
+ expect(rendered.getByText('techdocs reader page')).toBeInTheDocument();
+ });
+ });
+});
diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx
new file mode 100644
index 0000000000..a84c0a8122
--- /dev/null
+++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx
@@ -0,0 +1,124 @@
+/*
+ * Copyright 2022 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 React, { ReactNode, ReactChild, Children } from 'react';
+import { useOutlet, useParams } from 'react-router-dom';
+
+import { Page } from '@backstage/core-components';
+import { CompoundEntityRef } from '@backstage/catalog-model';
+import {
+ TECHDOCS_ADDONS_WRAPPER_KEY,
+ TechDocsReaderPageProvider,
+} from '@backstage/plugin-techdocs-react';
+
+import { TechDocsReaderPageRenderFunction } from '../../../types';
+
+import { TechDocsReaderPageContent } from '../TechDocsReaderPageContent';
+import { TechDocsReaderPageHeader } from '../TechDocsReaderPageHeader';
+import { TechDocsReaderPageSubheader } from '../TechDocsReaderPageSubheader';
+
+type Extension = ReactChild & {
+ type: {
+ __backstage_data: {
+ map: Map;
+ };
+ };
+};
+
+/**
+ * Props for {@link TechDocsReaderLayout}
+ * @public
+ */
+export type TechDocsReaderLayoutProps = {
+ /**
+ * Show or hide the header, defaults to true.
+ */
+ withHeader?: boolean;
+ /**
+ * Show or hide the content search bar, defaults to true.
+ */
+ withSearch?: boolean;
+};
+
+/**
+ * Default TechDocs reader page structure composed with a header and content
+ * @public
+ */
+export const TechDocsReaderLayout = ({
+ withSearch,
+ withHeader = true,
+}: TechDocsReaderLayoutProps) => {
+ return (
+
+ {withHeader && }
+
+
+
+ );
+};
+
+/**
+ * @public
+ */
+export type TechDocsReaderPageProps = {
+ entityRef?: CompoundEntityRef;
+ children?: TechDocsReaderPageRenderFunction | ReactNode;
+};
+
+/**
+ * An addon-aware implementation of the TechDocsReaderPage.
+ * @public
+ */
+export const TechDocsReaderPage = (props: TechDocsReaderPageProps) => {
+ const { kind, name, namespace } = useParams();
+ const { children, entityRef = { kind, name, namespace } } = props;
+
+ const outlet = useOutlet();
+
+ if (!children) {
+ const childrenList = outlet ? Children.toArray(outlet.props.children) : [];
+
+ const page = childrenList.find(child => {
+ const { type } = child as Extension;
+ return !type?.__backstage_data?.map?.get(TECHDOCS_ADDONS_WRAPPER_KEY);
+ });
+
+ return (
+ (page as JSX.Element) || (
+
+
+
+ )
+ );
+ }
+
+ return (
+
+ {({ metadata, entityMetadata, onReady }) => (
+
+ {children instanceof Function
+ ? children({
+ entityRef,
+ techdocsMetadataValue: metadata.value,
+ entityMetadataValue: entityMetadata.value,
+ onReady,
+ })
+ : children}
+
+ )}
+
+ );
+};
diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage/context.test.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPage/context.test.tsx
new file mode 100644
index 0000000000..148b740205
--- /dev/null
+++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage/context.test.tsx
@@ -0,0 +1,121 @@
+/*
+ * Copyright 2022 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 React from 'react';
+import { renderHook } from '@testing-library/react-hooks';
+
+import { ThemeProvider } from '@material-ui/core';
+
+import { lightTheme } from '@backstage/theme';
+import { TestApiProvider } from '@backstage/test-utils';
+import { Entity, CompoundEntityRef } from '@backstage/catalog-model';
+import {
+ techdocsApiRef,
+ TechDocsMetadata,
+ TechDocsReaderPageProvider,
+} from '@backstage/plugin-techdocs-react';
+
+import { useEntityMetadata, useTechDocsMetadata } from './context';
+
+const mockEntityMetadata: Entity = {
+ apiVersion: 'v1',
+ kind: 'Component',
+ metadata: {
+ name: 'test',
+ namespace: 'default',
+ },
+ spec: {
+ owner: 'test',
+ },
+};
+
+const mockTechDocsMetadata: TechDocsMetadata = {
+ site_name: 'test-componnet',
+ site_description: 'this is a test component',
+};
+
+const techdocsApiMock = {
+ getEntityMetadata: jest.fn().mockResolvedValue(mockEntityMetadata),
+ getTechDocsMetadata: jest.fn().mockResolvedValue(mockTechDocsMetadata),
+};
+
+const wrapper = ({
+ entityRef = {
+ kind: mockEntityMetadata.kind,
+ name: mockEntityMetadata.metadata.name,
+ namespace: mockEntityMetadata.metadata.namespace!!,
+ },
+ children,
+}: {
+ entityRef?: CompoundEntityRef;
+ children: React.ReactNode;
+}) => (
+
+
+
+ {children}
+
+
+
+);
+
+describe('context', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('useEntityMetadata', () => {
+ it('should return loading state', async () => {
+ const { result } = renderHook(() => useEntityMetadata());
+
+ await expect(result.current.loading).toEqual(true);
+ });
+
+ it('should return expected entity values', async () => {
+ const { result, waitForNextUpdate } = renderHook(
+ () => useEntityMetadata(),
+ { wrapper },
+ );
+
+ await waitForNextUpdate();
+
+ expect(result.current.value).toBeDefined();
+ expect(result.current.error).toBeUndefined();
+ expect(result.current.value).toMatchObject(mockEntityMetadata);
+ });
+ });
+
+ describe('useTechDocsMetadata', () => {
+ it('should return loading state', async () => {
+ const { result } = renderHook(() => useTechDocsMetadata());
+
+ await expect(result.current.loading).toEqual(true);
+ });
+
+ it('should return expected techdocs metadata values', async () => {
+ const { result, waitForNextUpdate } = renderHook(
+ () => useTechDocsMetadata(),
+ { wrapper },
+ );
+
+ await waitForNextUpdate();
+
+ expect(result.current.value).toBeDefined();
+ expect(result.current.error).toBeUndefined();
+ expect(result.current.value).toMatchObject(mockTechDocsMetadata);
+ });
+ });
+});
diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage/context.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPage/context.tsx
new file mode 100644
index 0000000000..899eb8475a
--- /dev/null
+++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage/context.tsx
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2022 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';
+
+/**
+ * Hook for sub-components to retrieve Entity Metadata for the current TechDocs
+ * site.
+ * @internal
+ */
+export const useEntityMetadata = () => {
+ const { entityMetadata } = useTechDocsReaderPage();
+ return entityMetadata;
+};
+
+/**
+ * Hook for sub-components to retrieve TechDocs Metadata for the current
+ * TechDocs site.
+ * @internal
+ */
+export const useTechDocsMetadata = () => {
+ const { metadata } = useTechDocsReaderPage();
+ return metadata;
+};
diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage/index.ts b/plugins/techdocs/src/reader/components/TechDocsReaderPage/index.ts
new file mode 100644
index 0000000000..8defb196f6
--- /dev/null
+++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage/index.ts
@@ -0,0 +1,21 @@
+/*
+ * Copyright 2022 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.
+ */
+
+export { TechDocsReaderPage, TechDocsReaderLayout } from './TechDocsReaderPage';
+export type {
+ TechDocsReaderPageProps,
+ TechDocsReaderLayoutProps,
+} from './TechDocsReaderPage';
diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.tsx
new file mode 100644
index 0000000000..c34b04ab8c
--- /dev/null
+++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.tsx
@@ -0,0 +1,178 @@
+/*
+ * Copyright 2022 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 React, { useState, useCallback } from 'react';
+import { create } from 'jss';
+
+import { makeStyles, Grid, Portal } from '@material-ui/core';
+import { StylesProvider, jssPreset } from '@material-ui/styles';
+
+import {
+ useTechDocsAddons,
+ TechDocsAddonLocations as locations,
+ useTechDocsReaderPage,
+} 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 { withTechDocsReaderProvider } from './context';
+
+const useStyles = makeStyles({
+ search: {
+ width: '100%',
+ '@media (min-width: 76.1875em)': {
+ width: 'calc(100% - 34.4rem)',
+ margin: '0 auto',
+ },
+ },
+});
+
+/**
+ * Props for {@link TechDocsReaderPageContent}
+ * @public
+ */
+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;
+ /**
+ * Show or hide the search bar, defaults to true.
+ */
+ withSearch?: boolean;
+ /**
+ * Callback called when the content is rendered.
+ */
+ onReady?: () => void;
+};
+
+/**
+ * Renders the reader page content
+ * @public
+ */
+export const TechDocsReaderPageContent = withTechDocsReaderProvider(
+ (props: TechDocsReaderPageContentProps) => {
+ const { withSearch = true, onReady } = props;
+ const classes = useStyles();
+ const addons = useTechDocsAddons();
+ const { entityRef, shadowRoot, setShadowRoot } = useTechDocsReaderPage();
+ const dom = useTechDocsReaderDom(entityRef);
+
+ const [jss, setJss] = useState(
+ create({
+ ...jssPreset(),
+ insertionPoint: undefined,
+ }),
+ );
+
+ const ref = useCallback(
+ (shadowHost: HTMLDivElement) => {
+ if (!dom || !shadowHost) return;
+
+ setJss(
+ create({
+ ...jssPreset(),
+ insertionPoint: dom.querySelector('head') || undefined,
+ }),
+ );
+
+ const newShadowRoot =
+ shadowHost.shadowRoot ?? shadowHost.attachShadow({ mode: 'open' });
+ newShadowRoot.innerHTML = '';
+ newShadowRoot.appendChild(dom);
+ setShadowRoot(newShadowRoot);
+ if (onReady instanceof Function) {
+ onReady();
+ }
+ },
+ [dom, setShadowRoot, onReady],
+ );
+
+ const contentElement = shadowRoot?.querySelector(
+ '[data-md-component="content"]',
+ );
+ const primarySidebarElement = shadowRoot?.querySelector(
+ 'div[data-md-component="sidebar"][data-md-type="navigation"], div[data-md-component="navigation"]',
+ );
+ const secondarySidebarElement = shadowRoot?.querySelector(
+ 'div[data-md-component="sidebar"][data-md-type="toc"], div[data-md-component="toc"]',
+ );
+
+ const primarySidebarAddonLocation = document.createElement('div');
+ primarySidebarElement?.prepend(primarySidebarAddonLocation);
+
+ const secondarySidebarAddonLocation = document.createElement('div');
+ secondarySidebarElement?.prepend(secondarySidebarAddonLocation);
+
+ // do not return content until dom is ready
+ if (!dom) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {withSearch && (
+
+
+
+ )}
+
+ {/* sheetsManager={new Map()} is needed in order to deduplicate the injection of CSS in the page. */}
+
+
+
+ {addons.renderComponentsByLocation(locations.PrimarySidebar)}
+
+
+ {addons.renderComponentsByLocation(locations.Content)}
+
+
+ {addons.renderComponentsByLocation(locations.SecondarySidebar)}
+
+
+
+
+
+ );
+ },
+);
+
+/**
+ * Props for {@link Reader}
+ *
+ * @public
+ * @deprecated use `TechDocsReaderPageContentProps` instead.
+ */
+export type ReaderProps = TechDocsReaderPageContentProps;
+
+/**
+ * Component responsible for rendering TechDocs documentation
+ * @public
+ * @deprecated use `TechDocsReaderPageContent` component instead.
+ */
+export const Reader = TechDocsReaderPageContent;
diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/context.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/context.tsx
new file mode 100644
index 0000000000..5779ade90c
--- /dev/null
+++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/context.tsx
@@ -0,0 +1,92 @@
+/*
+ * Copyright 2020 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 React, {
+ ComponentType,
+ createContext,
+ useContext,
+ ReactNode,
+} from 'react';
+import { useParams } from 'react-router-dom';
+import { useTechDocsReaderPage } from '@backstage/plugin-techdocs-react';
+
+import { useReaderState, ReaderState } from '../useReaderState';
+
+const TechDocsReaderContext = createContext({} as ReaderState);
+
+/**
+ * Note: this hook is currently being exported so that we can rapidly
+ * iterate on alternative implementations that extend core
+ * functionality. There is no guarantee that this hook will continue to be
+ * exported by the package in the future!
+ *
+ * todo: Make public or stop exporting (ctrl+f "altReaderExperiments")
+ * @internal
+ */
+
+export const useTechDocsReader = () => useContext(TechDocsReaderContext);
+
+/**
+ * @public Render function for {@link TechDocsReaderProvider}
+ */
+export type TechDocsReaderProviderRenderFunction = (
+ value: ReaderState,
+) => JSX.Element;
+
+/**
+ * @public Props for {@link TechDocsReaderProvider}
+ */
+export type TechDocsReaderProviderProps = {
+ children: TechDocsReaderProviderRenderFunction | ReactNode;
+};
+
+/**
+ * Provides shared building process state to the reader page components.
+ *
+ * @public
+ */
+export const TechDocsReaderProvider = ({
+ children,
+}: TechDocsReaderProviderProps) => {
+ const { '*': path = '' } = useParams();
+ const { entityRef } = useTechDocsReaderPage();
+ const { kind, namespace, name } = entityRef;
+ const value = useReaderState(kind, namespace, name, path);
+
+ return (
+
+ {children instanceof Function ? children(value) : children}
+
+ );
+};
+
+/**
+ * Note: this HOC is currently being exported so that we can rapidly
+ * iterate on alternative implementations that extend core
+ * functionality. There is no guarantee that this HOC will continue to be
+ * exported by the package in the future!
+ *
+ * todo: Make public or stop exporting (ctrl+f "altReaderExperiments")
+ * @internal
+ */
+export const withTechDocsReaderProvider =
+ (Component: ComponentType) =>
+ (props: T) =>
+ (
+
+
+
+ );
diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/dom.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/dom.tsx
new file mode 100644
index 0000000000..e8d15e5231
--- /dev/null
+++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/dom.tsx
@@ -0,0 +1,786 @@
+/*
+ * Copyright 2022 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 { useContext, useCallback, useEffect, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+
+import { useTheme, Theme } from '@material-ui/core';
+import { lighten, alpha } from '@material-ui/core/styles';
+
+import { BackstageTheme } from '@backstage/theme';
+import { CompoundEntityRef } from '@backstage/catalog-model';
+import { useApi, configApiRef } from '@backstage/core-plugin-api';
+import { SidebarPinStateContext } from '@backstage/core-components';
+import { scmIntegrationsApiRef } from '@backstage/integration-react';
+
+import { techdocsStorageApiRef } from '../../../api';
+
+import { useTechDocsReader } from './context';
+
+import {
+ addBaseUrl,
+ addGitFeedbackLink,
+ addLinkClickListener,
+ addSidebarToggle,
+ injectCss,
+ onCssReady,
+ removeMkdocsHeader,
+ rewriteDocLinks,
+ sanitizeDOM,
+ simplifyMkdocsFooter,
+ scrollIntoAnchor,
+ transform as transformer,
+ copyToClipboard,
+} from '../../transformers';
+
+type TypographyHeadings = Pick<
+ Theme['typography'],
+ 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
+>;
+type TypographyHeadingsKeys = keyof TypographyHeadings;
+
+const headings: TypographyHeadingsKeys[] = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'];
+
+/**
+ * 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
+ * Backstage UI.
+ *
+ * Note: this hook is currently being exported so that we can rapidly iterate
+ * on alternative implementations that extend core functionality.
+ * There is no guarantee that this hook will continue to be exported by the
+ * package in the future!
+ *
+ * todo: Make public or stop exporting (see others: "altReaderExperiments")
+ * @internal
+ */
+export const useTechDocsReaderDom = (
+ entityRef: CompoundEntityRef,
+): Element | null => {
+ const navigate = useNavigate();
+ const theme = useTheme();
+ const techdocsStorageApi = useApi(techdocsStorageApiRef);
+ const scmIntegrationsApi = useApi(scmIntegrationsApiRef);
+ const techdocsSanitizer = useApi(configApiRef);
+ const { namespace, kind, name } = entityRef;
+ const { state, path, content: rawPage } = useTechDocsReader();
+ const isDarkTheme = theme.palette.type === 'dark';
+
+ const [sidebars, setSidebars] = useState();
+ const [dom, setDom] = useState(null);
+
+ // sidebar pinned status to be used in computing CSS style injections
+ const { isPinned } = useContext(SidebarPinStateContext);
+
+ const updateSidebarPosition = useCallback(() => {
+ if (!dom || !sidebars) return;
+ // set sidebar height so they don't initially render in wrong position
+ const mdTabs = dom.querySelector('.md-container > .md-tabs');
+ const sidebarsCollapsed = window.matchMedia(
+ 'screen and (max-width: 76.1875em)',
+ ).matches;
+ const newTop = Math.max(dom.getBoundingClientRect().top, 0);
+ sidebars.forEach(sidebar => {
+ if (sidebarsCollapsed) {
+ sidebar.style.top = '0px';
+ } else if (mdTabs) {
+ sidebar.style.top = `${
+ newTop + mdTabs.getBoundingClientRect().height
+ }px`;
+ } else {
+ sidebar.style.top = `${newTop}px`;
+ }
+ });
+ }, [dom, sidebars]);
+
+ useEffect(() => {
+ updateSidebarPosition();
+ window.addEventListener('scroll', updateSidebarPosition, true);
+ window.addEventListener('resize', updateSidebarPosition);
+ return () => {
+ window.removeEventListener('scroll', updateSidebarPosition, true);
+ window.removeEventListener('resize', updateSidebarPosition);
+ };
+ // an update to "state" might lead to an updated UI so we include it as a trigger
+ }, [updateSidebarPosition, state]);
+
+ // dynamically set width of footer to accommodate for pinning of the sidebar
+ const updateFooterWidth = useCallback(() => {
+ if (!dom) return;
+ const footer = dom.querySelector('.md-footer') as HTMLElement;
+ if (footer) {
+ footer.style.width = `${dom.getBoundingClientRect().width}px`;
+ }
+ }, [dom]);
+
+ useEffect(() => {
+ updateFooterWidth();
+ window.addEventListener('resize', updateFooterWidth);
+ return () => {
+ window.removeEventListener('resize', updateFooterWidth);
+ };
+ });
+
+ // a function that performs transformations that are executed prior to adding it to the DOM
+ const preRender = useCallback(
+ (rawContent: string, contentPath: string) =>
+ transformer(rawContent, [
+ sanitizeDOM(techdocsSanitizer.getOptionalConfig('techdocs.sanitizer')),
+ addBaseUrl({
+ techdocsStorageApi,
+ entityId: {
+ kind,
+ name,
+ namespace,
+ },
+ path: contentPath,
+ }),
+ rewriteDocLinks(),
+ addSidebarToggle(),
+ removeMkdocsHeader(),
+ simplifyMkdocsFooter(),
+ addGitFeedbackLink(scmIntegrationsApi),
+ injectCss({
+ // Variables
+ css: `
+ /*
+ As the MkDocs output is rendered in shadow DOM, the CSS variable definitions on the root selector are not applied. Instead, they have to be applied on :host.
+ As there is no way to transform the served main*.css yet (for example in the backend), we have to copy from main*.css and modify them.
+ */
+ :host {
+ /* FONT */
+ --md-default-fg-color: ${theme.palette.text.primary};
+ --md-default-fg-color--light: ${theme.palette.text.secondary};
+ --md-default-fg-color--lighter: ${lighten(
+ theme.palette.text.secondary,
+ 0.7,
+ )};
+ --md-default-fg-color--lightest: ${lighten(
+ theme.palette.text.secondary,
+ 0.3,
+ )};
+
+ /* BACKGROUND */
+ --md-default-bg-color:${theme.palette.background.default};
+ --md-default-bg-color--light: ${theme.palette.background.paper};
+ --md-default-bg-color--lighter: ${lighten(
+ theme.palette.background.paper,
+ 0.7,
+ )};
+ --md-default-bg-color--lightest: ${lighten(
+ theme.palette.background.paper,
+ 0.3,
+ )};
+
+ /* PRIMARY */
+ --md-primary-fg-color: ${theme.palette.primary.main};
+ --md-primary-fg-color--light: ${theme.palette.primary.light};
+ --md-primary-fg-color--dark: ${theme.palette.primary.dark};
+ --md-primary-bg-color: ${theme.palette.primary.contrastText};
+ --md-primary-bg-color--light: ${lighten(
+ theme.palette.primary.contrastText,
+ 0.7,
+ )};
+
+ /* ACCENT */
+ --md-accent-fg-color: var(--md-primary-fg-color);
+
+ /* SHADOW */
+ --md-shadow-z1: ${theme.shadows[1]};
+ --md-shadow-z2: ${theme.shadows[2]};
+ --md-shadow-z3: ${theme.shadows[3]};
+
+ /* EXTENSIONS */
+ --md-admonition-fg-color: var(--md-default-fg-color);
+ --md-admonition-bg-color: var(--md-default-bg-color);
+ /* Admonitions and others are using SVG masks to define icons. These masks are defined as CSS variables. */
+ --md-admonition-icon--note: url('data:image/svg+xml;charset=utf-8,');
+ --md-admonition-icon--abstract: url('data:image/svg+xml;charset=utf-8,');
+ --md-admonition-icon--info: url('data:image/svg+xml;charset=utf-8,');
+ --md-admonition-icon--tip: url('data:image/svg+xml;charset=utf-8,');
+ --md-admonition-icon--success: url('data:image/svg+xml;charset=utf-8,');
+ --md-admonition-icon--question: url('data:image/svg+xml;charset=utf-8,');
+ --md-admonition-icon--warning: url('data:image/svg+xml;charset=utf-8,');
+ --md-admonition-icon--failure: url('data:image/svg+xml;charset=utf-8,');
+ --md-admonition-icon--danger: url('data:image/svg+xml;charset=utf-8,');
+ --md-admonition-icon--bug: url('data:image/svg+xml;charset=utf-8,');
+ --md-admonition-icon--example: url('data:image/svg+xml;charset=utf-8,');
+ --md-admonition-icon--quote: url('data:image/svg+xml;charset=utf-8,');
+ --md-footnotes-icon: url('data:image/svg+xml;charset=utf-8,');
+ --md-details-icon: url('data:image/svg+xml;charset=utf-8,');
+ --md-tasklist-icon: url('data:image/svg+xml;charset=utf-8,');
+ --md-tasklist-icon--checked: url('data:image/svg+xml;charset=utf-8,');
+ --md-nav-icon--prev: url('data:image/svg+xml;charset=utf-8,');
+ --md-nav-icon--next: url('data:image/svg+xml;charset=utf-8,');
+ --md-toc-icon: url('data:image/svg+xml;charset=utf-8,');
+ --md-clipboard-icon: url('data:image/svg+xml;charset=utf-8,');
+ --md-search-result-icon: url('data:image/svg+xml;charset=utf-8,');
+ --md-source-forks-icon: url('data:image/svg+xml;charset=utf-8,');
+ --md-source-repositories-icon: url('data:image/svg+xml;charset=utf-8,');
+ --md-source-stars-icon: url('data:image/svg+xml;charset=utf-8,');
+ --md-source-version-icon: url('data:image/svg+xml;charset=utf-8,');
+ --md-version-icon: url('data:image/svg+xml;charset=utf-8,');
+ }
+
+ :host > * {
+ /* CODE */
+ --md-code-fg-color: ${theme.palette.text.primary};
+ --md-code-bg-color: ${theme.palette.background.paper};
+ --md-code-hl-color: ${alpha(theme.palette.warning.main, 0.5)};
+ --md-code-hl-keyword-color: ${
+ isDarkTheme
+ ? theme.palette.primary.light
+ : theme.palette.primary.dark
+ };
+ --md-code-hl-function-color: ${
+ isDarkTheme
+ ? theme.palette.secondary.light
+ : theme.palette.secondary.dark
+ };
+ --md-code-hl-string-color: ${
+ isDarkTheme
+ ? theme.palette.success.light
+ : theme.palette.success.dark
+ };
+ --md-code-hl-number-color: ${
+ isDarkTheme
+ ? theme.palette.error.light
+ : theme.palette.error.dark
+ };
+ --md-code-hl-constant-color: var(--md-code-hl-function-color);
+ --md-code-hl-special-color: var(--md-code-hl-function-color);
+ --md-code-hl-name-color: var(--md-code-fg-color);
+ --md-code-hl-comment-color: var(--md-default-fg-color--light);
+ --md-code-hl-generic-color: var(--md-default-fg-color--light);
+ --md-code-hl-variable-color: var(--md-default-fg-color--light);
+ --md-code-hl-operator-color: var(--md-default-fg-color--light);
+ --md-code-hl-punctuation-color: var(--md-default-fg-color--light);
+
+ /* TYPESET */
+ --md-typeset-font-size: 1rem;
+ --md-typeset-color: var(--md-default-fg-color);
+ --md-typeset-a-color: var(--md-accent-fg-color);
+ --md-typeset-table-color: ${theme.palette.text.primary};
+ --md-typeset-del-color: ${
+ isDarkTheme
+ ? alpha(theme.palette.error.dark, 0.5)
+ : alpha(theme.palette.error.light, 0.5)
+ };
+ --md-typeset-ins-color: ${
+ isDarkTheme
+ ? alpha(theme.palette.success.dark, 0.5)
+ : alpha(theme.palette.success.light, 0.5)
+ };
+ --md-typeset-mark-color: ${
+ isDarkTheme
+ ? alpha(theme.palette.warning.dark, 0.5)
+ : alpha(theme.palette.warning.light, 0.5)
+ };
+ }
+
+ @media screen and (max-width: 76.1875em) {
+ :host > * {
+ /* TYPESET */
+ --md-typeset-font-size: .9rem;
+ }
+ }
+
+ @media screen and (max-width: 600px) {
+ :host > * {
+ /* TYPESET */
+ --md-typeset-font-size: .7rem;
+ }
+ }
+ `,
+ }),
+ injectCss({
+ // Reset
+ css: `
+ body {
+ --md-text-color: var(--md-default-fg-color);
+ --md-text-link-color: var(--md-accent-fg-color);
+ --md-text-font-family: ${theme.typography.fontFamily};
+ font-family: var(--md-text-font-family);
+ background-color: unset;
+ }
+ `,
+ }),
+ injectCss({
+ // Layout
+ css: `
+ .md-grid {
+ max-width: 100%;
+ margin: 0;
+ }
+
+ .md-nav {
+ font-size: calc(var(--md-typeset-font-size) * 0.9);
+ }
+ .md-nav__link {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ }
+ .md-nav__icon {
+ height: 20px !important;
+ width: 20px !important;
+ margin-left:${theme.spacing(1)}px;
+ }
+ .md-nav__icon svg {
+ margin: 0;
+ width: 20px !important;
+ height: 20px !important;
+ }
+ .md-nav__icon:after {
+ width: 20px !important;
+ height: 20px !important;
+ }
+
+ .md-main__inner {
+ margin-top: 0;
+ }
+
+ .md-sidebar {
+ bottom: 75px;
+ position: fixed;
+ width: 16rem;
+ overflow-y: auto;
+ overflow-x: hidden;
+ scrollbar-color: rgb(193, 193, 193) #eee;
+ scrollbar-width: thin;
+ }
+ .md-sidebar .md-sidebar__scrollwrap {
+ width: calc(16rem - 10px);
+ }
+ .md-sidebar--secondary {
+ right: ${theme.spacing(3)}px;
+ }
+ .md-sidebar::-webkit-scrollbar {
+ width: 5px;
+ }
+ .md-sidebar::-webkit-scrollbar-button {
+ width: 5px;
+ height: 5px;
+ }
+ .md-sidebar::-webkit-scrollbar-track {
+ background: #eee;
+ border: 1 px solid rgb(250, 250, 250);
+ box-shadow: 0px 0px 3px #dfdfdf inset;
+ border-radius: 3px;
+ }
+ .md-sidebar::-webkit-scrollbar-thumb {
+ width: 5px;
+ background: rgb(193, 193, 193);
+ border: transparent;
+ border-radius: 3px;
+ }
+ .md-sidebar::-webkit-scrollbar-thumb:hover {
+ background: rgb(125, 125, 125);
+ }
+
+ .md-content {
+ max-width: calc(100% - 16rem * 2);
+ margin-left: 16rem;
+ margin-bottom: 50px;
+ }
+
+ .md-footer {
+ position: fixed;
+ bottom: 0px;
+ }
+ .md-footer__title {
+ background-color: unset;
+ }
+ .md-footer-nav__link {
+ width: 16rem;
+ }
+
+ .md-dialog {
+ background-color: unset;
+ }
+
+ @media screen and (min-width: 76.25em) {
+ .md-sidebar {
+ height: auto;
+ }
+ }
+
+ @media screen and (max-width: 76.1875em) {
+ .md-nav {
+ transition: none !important;
+ background-color: var(--md-default-bg-color)
+ }
+ .md-nav--primary .md-nav__title {
+ cursor: auto;
+ color: var(--md-default-fg-color);
+ font-weight: 700;
+ white-space: normal;
+ line-height: 1rem;
+ height: auto;
+ display: flex;
+ flex-flow: column;
+ row-gap: 1.6rem;
+ padding: 1.2rem .8rem .8rem;
+ background-color: var(--md-default-bg-color);
+ }
+ .md-nav--primary .md-nav__title~.md-nav__list {
+ box-shadow: none;
+ }
+ .md-nav--primary .md-nav__title ~ .md-nav__list > :first-child {
+ border-top: none;
+ }
+ .md-nav--primary .md-nav__title .md-nav__button {
+ display: none;
+ }
+ .md-nav--primary .md-nav__title .md-nav__icon {
+ color: var(--md-default-fg-color);
+ position: static;
+ height: auto;
+ margin: 0 0 0 -0.2rem;
+ }
+ .md-nav--primary > .md-nav__title [for="none"] {
+ padding-top: 0;
+ }
+ .md-nav--primary .md-nav__item {
+ border-top: none;
+ }
+ .md-nav--primary :is(.md-nav__title,.md-nav__item) {
+ font-size : var(--md-typeset-font-size);
+ }
+ .md-nav .md-source {
+ display: none;
+ }
+
+ .md-sidebar {
+ height: 100%;
+ }
+ .md-sidebar--primary {
+ width: 12.1rem !important;
+ z-index: 200;
+ left: ${
+ isPinned
+ ? 'calc(-12.1rem + 242px)'
+ : 'calc(-12.1rem + 72px)'
+ } !important;
+ }
+ .md-sidebar--secondary:not([hidden]) {
+ display: none;
+ }
+
+ .md-content {
+ max-width: 100%;
+ margin-left: 0;
+ }
+
+ .md-header__button {
+ margin: 0.4rem 0;
+ margin-left: 0.4rem;
+ padding: 0;
+ }
+
+ .md-overlay {
+ left: 0;
+ }
+
+ .md-footer {
+ position: static;
+ padding-left: 0;
+ }
+ .md-footer-nav__link {
+ /* footer links begin to overlap at small sizes without setting width */
+ width: 50%;
+ }
+ }
+
+ @media screen and (max-width: 600px) {
+ .md-sidebar--primary {
+ left: -12.1rem !important;
+ width: 12.1rem;
+ }
+ }
+ `,
+ }),
+ injectCss({
+ // Typeset
+ css: `
+ .md-typeset {
+ font-size: var(--md-typeset-font-size);
+ }
+
+ ${headings.reduce((style, heading) => {
+ const styles = theme.typography[heading];
+ const { lineHeight, fontFamily, fontWeight, fontSize } = styles;
+ const calculate = (value: typeof fontSize) => {
+ let factor: number | string = 1;
+ if (typeof value === 'number') {
+ // 60% of the size defined because it is too big
+ factor = (value / 16) * 0.6;
+ }
+ if (typeof value === 'string') {
+ factor = value.replace('rem', '');
+ }
+ return `calc(${factor} * var(--md-typeset-font-size))`;
+ };
+ return style.concat(`
+ .md-typeset ${heading} {
+ color: var(--md-default-fg-color);
+ line-height: ${lineHeight};
+ font-family: ${fontFamily};
+ font-weight: ${fontWeight};
+ font-size: ${calculate(fontSize)};
+ }
+ `);
+ }, '')}
+
+ .md-typeset .md-content__button {
+ color: var(--md-default-fg-color);
+ }
+
+ .md-typeset hr {
+ border-bottom: 0.05rem dotted ${theme.palette.divider};
+ }
+
+ .md-typeset details {
+ font-size: var(--md-typeset-font-size) !important;
+ }
+ .md-typeset details summary {
+ padding-left: 2.5rem !important;
+ }
+ .md-typeset details summary:before,
+ .md-typeset details summary:after {
+ top: 50% !important;
+ width: 20px !important;
+ height: 20px !important;
+ transform: rotate(0deg) translateY(-50%) !important;
+ }
+ .md-typeset details[open] > summary:after {
+ transform: rotate(90deg) translateX(-50%) !important;
+ }
+
+ .md-typeset blockquote {
+ color: var(--md-default-fg-color--light);
+ border-left: 0.2rem solid var(--md-default-fg-color--light);
+ }
+
+ .md-typeset table:not([class]) {
+ font-size: var(--md-typeset-font-size);
+ border: 1px solid var(--md-default-fg-color);
+ border-bottom: none;
+ border-collapse: collapse;
+ }
+ .md-typeset table:not([class]) th {
+ font-weight: bold;
+ }
+ .md-typeset table:not([class]) td, .md-typeset table:not([class]) th {
+ border-bottom: 1px solid var(--md-default-fg-color);
+ }
+
+ .md-typeset pre > code::-webkit-scrollbar-thumb {
+ background-color: hsla(0, 0%, 0%, 0.32);
+ }
+ .md-typeset pre > code::-webkit-scrollbar-thumb:hover {
+ background-color: hsla(0, 0%, 0%, 0.87);
+ }
+ `,
+ }),
+ injectCss({
+ // Animations
+ css: `
+ /*
+ Disable CSS animations on link colors as they lead to issues in dark mode.
+ The dark mode color theme is applied later and theirfore there is always an animation from light to dark mode when navigation between pages.
+ */
+ .md-dialog, .md-nav__link, .md-footer__link, .md-typeset a, .md-typeset a::before, .md-typeset .headerlink {
+ transition: none;
+ }
+ `,
+ }),
+ injectCss({
+ // Extensions
+ css: `
+ /* HIGHLIGHT */
+ .highlight .md-clipboard:after {
+ content: unset;
+ }
+
+ .highlight .nx {
+ color: ${isDarkTheme ? '#ff53a3' : '#ec407a'};
+ }
+
+ /* CODE HILITE */
+ .codehilite .gd {
+ background-color: ${
+ isDarkTheme ? 'rgba(248,81,73,0.65)' : '#fdd'
+ };
+ }
+
+ .codehilite .gi {
+ background-color: ${
+ isDarkTheme ? 'rgba(46,160,67,0.65)' : '#dfd'
+ };
+ }
+
+ /* TABBED */
+ .tabbed-set>input:nth-child(1):checked~.tabbed-labels>:nth-child(1),
+ .tabbed-set>input:nth-child(2):checked~.tabbed-labels>:nth-child(2),
+ .tabbed-set>input:nth-child(3):checked~.tabbed-labels>:nth-child(3),
+ .tabbed-set>input:nth-child(4):checked~.tabbed-labels>:nth-child(4),
+ .tabbed-set>input:nth-child(5):checked~.tabbed-labels>:nth-child(5),
+ .tabbed-set>input:nth-child(6):checked~.tabbed-labels>:nth-child(6),
+ .tabbed-set>input:nth-child(7):checked~.tabbed-labels>:nth-child(7),
+ .tabbed-set>input:nth-child(8):checked~.tabbed-labels>:nth-child(8),
+ .tabbed-set>input:nth-child(9):checked~.tabbed-labels>:nth-child(9),
+ .tabbed-set>input:nth-child(10):checked~.tabbed-labels>:nth-child(10),
+ .tabbed-set>input:nth-child(11):checked~.tabbed-labels>:nth-child(11),
+ .tabbed-set>input:nth-child(12):checked~.tabbed-labels>:nth-child(12),
+ .tabbed-set>input:nth-child(13):checked~.tabbed-labels>:nth-child(13),
+ .tabbed-set>input:nth-child(14):checked~.tabbed-labels>:nth-child(14),
+ .tabbed-set>input:nth-child(15):checked~.tabbed-labels>:nth-child(15),
+ .tabbed-set>input:nth-child(16):checked~.tabbed-labels>:nth-child(16),
+ .tabbed-set>input:nth-child(17):checked~.tabbed-labels>:nth-child(17),
+ .tabbed-set>input:nth-child(18):checked~.tabbed-labels>:nth-child(18),
+ .tabbed-set>input:nth-child(19):checked~.tabbed-labels>:nth-child(19),
+ .tabbed-set>input:nth-child(20):checked~.tabbed-labels>:nth-child(20) {
+ color: var(--md-accent-fg-color);
+ border-color: var(--md-accent-fg-color);
+ }
+
+ /* TASK-LIST */
+ .task-list-control .task-list-indicator::before {
+ background-color: ${theme.palette.action.disabledBackground};
+ }
+ .task-list-control [type="checkbox"]:checked + .task-list-indicator:before {
+ background-color: ${theme.palette.success.main};
+ }
+
+ /* ADMONITION */
+ .admonition {
+ font-size: var(--md-typeset-font-size) !important;
+ }
+ .admonition .admonition-title {
+ padding-left: 2.5rem !important;
+ }
+
+ .admonition .admonition-title:before {
+ top: 50% !important;
+ width: 20px !important;
+ height: 20px !important;
+ transform: translateY(-50%) !important;
+ }
+ `,
+ }),
+ ]),
+ [
+ kind,
+ name,
+ namespace,
+ scmIntegrationsApi,
+ techdocsSanitizer,
+ techdocsStorageApi,
+ theme,
+ isDarkTheme,
+ isPinned,
+ ],
+ );
+
+ // a function that performs transformations that are executed after adding it to the DOM
+ const postRender = useCallback(
+ async (transformedElement: Element) =>
+ transformer(transformedElement, [
+ scrollIntoAnchor(),
+ copyToClipboard(theme),
+ addLinkClickListener({
+ baseUrl: window.location.origin,
+ onClick: (event: MouseEvent, url: string) => {
+ // detect if CTRL or META keys are pressed so that links can be opened in a new tab with `window.open`
+ const modifierActive = event.ctrlKey || event.metaKey;
+ const parsedUrl = new URL(url);
+
+ // hash exists when anchor is clicked on secondary sidebar
+ if (parsedUrl.hash) {
+ if (modifierActive) {
+ window.open(`${parsedUrl.pathname}${parsedUrl.hash}`, '_blank');
+ } else {
+ navigate(`${parsedUrl.pathname}${parsedUrl.hash}`);
+ // Scroll to hash if it's on the current page
+ transformedElement
+ ?.querySelector(`#${parsedUrl.hash.slice(1)}`)
+ ?.scrollIntoView();
+ }
+ } else {
+ if (modifierActive) {
+ window.open(parsedUrl.pathname, '_blank');
+ } else {
+ navigate(parsedUrl.pathname);
+ }
+ }
+ },
+ }),
+ onCssReady({
+ docStorageUrl: await techdocsStorageApi.getApiOrigin(),
+ onLoading: (renderedElement: Element) => {
+ (renderedElement as HTMLElement).style.setProperty('opacity', '0');
+ },
+ onLoaded: (renderedElement: Element) => {
+ (renderedElement as HTMLElement).style.removeProperty('opacity');
+ // disable MkDocs drawer toggling ('for' attribute => checkbox mechanism)
+ renderedElement
+ .querySelector('.md-nav__title')
+ ?.removeAttribute('for');
+ setSidebars(
+ Array.from(renderedElement.querySelectorAll('.md-sidebar')),
+ );
+ },
+ }),
+ ]),
+ [theme, navigate, techdocsStorageApi],
+ );
+
+ useEffect(() => {
+ if (!rawPage) return () => {};
+
+ // if false, there is already a newer execution of this effect
+ let shouldReplaceContent = true;
+
+ // Pre-render
+ preRender(rawPage, path).then(async preTransformedDomElement => {
+ if (!preTransformedDomElement?.innerHTML) {
+ return; // An unexpected error occurred
+ }
+
+ // don't manipulate the shadow dom if this isn't the latest effect execution
+ if (!shouldReplaceContent) {
+ return;
+ }
+
+ // Scroll to top after render
+ window.scroll({ top: 0 });
+
+ // Post-render
+ const postTransformedDomElement = await postRender(
+ preTransformedDomElement,
+ );
+ setDom(postTransformedDomElement as HTMLElement);
+ });
+
+ // cancel this execution
+ return () => {
+ shouldReplaceContent = false;
+ };
+ }, [rawPage, path, preRender, postRender]);
+
+ return dom;
+};
diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/index.ts b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/index.ts
new file mode 100644
index 0000000000..a7ea76a5d6
--- /dev/null
+++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/index.ts
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2022 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.
+ */
+
+export { TechDocsReaderPageContent, Reader } from './TechDocsReaderPageContent';
+export type { TechDocsReaderPageContentProps } from './TechDocsReaderPageContent';
+export * from './context';
+export * from './dom';
diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader.test.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader.test.tsx
deleted file mode 100644
index 9245bb66c5..0000000000
--- a/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader.test.tsx
+++ /dev/null
@@ -1,115 +0,0 @@
-/*
- * Copyright 2020 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 React from 'react';
-import { TechDocsReaderPageHeader } from './TechDocsReaderPageHeader';
-import { act } from '@testing-library/react';
-import { renderInTestApp } from '@backstage/test-utils';
-import { entityRouteRef } from '@backstage/plugin-catalog-react';
-import { rootRouteRef } from '../../routes';
-
-describe('', () => {
- it('should render a techdocs page header', async () => {
- await act(async () => {
- const rendered = await renderInTestApp(
- ,
- {
- mountedRoutes: {
- '/catalog/:namespace/:kind/:name/*': entityRouteRef,
- '/docs': rootRouteRef,
- },
- },
- );
-
- expect(rendered.container.innerHTML).toContain('header');
- expect(rendered.getAllByText('test-site-name')).toHaveLength(2);
- expect(rendered.getByText('test-site-desc')).toBeDefined();
- });
- });
-
- it('should render a techdocs page header even if metadata is missing', async () => {
- await act(async () => {
- const rendered = await renderInTestApp(
- ,
- {
- mountedRoutes: {
- '/catalog/:namespace/:kind/:name/*': entityRouteRef,
- '/docs': rootRouteRef,
- },
- },
- );
-
- expect(rendered.container.innerHTML).toContain('header');
- });
- });
-
- it('should render a link back to the component page', async () => {
- await act(async () => {
- const rendered = await renderInTestApp(
- ,
- {
- mountedRoutes: {
- '/catalog/:namespace/:kind/:name/*': entityRouteRef,
- '/docs': rootRouteRef,
- },
- },
- );
-
- expect(rendered.container.innerHTML).toContain(
- '/catalog/test-namespace/test/test-name',
- );
- });
- });
-});
diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.test.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.test.tsx
new file mode 100644
index 0000000000..288e3bec82
--- /dev/null
+++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.test.tsx
@@ -0,0 +1,152 @@
+/*
+ * Copyright 2020 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 React from 'react';
+import { act, waitFor } from '@testing-library/react';
+
+import { ThemeProvider } from '@material-ui/core';
+
+import { lightTheme } from '@backstage/theme';
+import { CompoundEntityRef } from '@backstage/catalog-model';
+import { entityRouteRef } from '@backstage/plugin-catalog-react';
+import {
+ techdocsApiRef,
+ TechDocsReaderPageProvider,
+} from '@backstage/plugin-techdocs-react';
+import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
+
+import { rootRouteRef } from '../../../routes';
+
+import { TechDocsReaderPageHeader } from './TechDocsReaderPageHeader';
+
+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: React.ReactNode;
+}) => (
+
+
+
+ {children}
+
+
+
+);
+
+describe('', () => {
+ it('should render a techdocs page header', async () => {
+ getEntityMetadata.mockResolvedValue(mockEntityMetadata);
+ getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata);
+
+ await act(async () => {
+ const rendered = await renderInTestApp(
+
+
+ ,
+ {
+ mountedRoutes: {
+ '/catalog/:namespace/:kind/:name/*': entityRouteRef,
+ '/docs': rootRouteRef,
+ },
+ },
+ );
+
+ expect(rendered.container.innerHTML).toContain('header');
+
+ await waitFor(() => {
+ expect(rendered.getAllByText('test-site-name')).toHaveLength(2);
+ });
+
+ expect(rendered.getByText('test-site-desc')).toBeDefined();
+ });
+ });
+
+ it('should render a techdocs page header even if metadata is missing', async () => {
+ await act(async () => {
+ const rendered = await renderInTestApp(
+
+
+ ,
+ {
+ mountedRoutes: {
+ '/catalog/:namespace/:kind/:name/*': entityRouteRef,
+ '/docs': rootRouteRef,
+ },
+ },
+ );
+
+ expect(rendered.container.innerHTML).toContain('header');
+ });
+ });
+
+ it('should render a link back to the component page', async () => {
+ getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata);
+
+ await act(async () => {
+ const rendered = await renderInTestApp(
+
+
+ ,
+ {
+ mountedRoutes: {
+ '/catalog/:namespace/:kind/:name/*': entityRouteRef,
+ '/docs': rootRouteRef,
+ },
+ },
+ );
+
+ await waitFor(() => {
+ expect(
+ rendered.getByRole('link', { name: 'test:test-namespace/test-name' }),
+ ).toHaveAttribute('href', '/catalog/test-namespace/test/test-name');
+ });
+ });
+ });
+});
diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.tsx
similarity index 54%
rename from plugins/techdocs/src/reader/components/TechDocsReaderPageHeader.tsx
rename to plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.tsx
index f6a3352e7d..5835121d2e 100644
--- a/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader.tsx
+++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.tsx
@@ -1,5 +1,5 @@
/*
- * Copyright 2020 The Backstage Authors
+ * Copyright 2022 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.
@@ -14,45 +14,84 @@
* limitations under the License.
*/
-import React, { PropsWithChildren } from 'react';
+import React, { PropsWithChildren, useEffect } from 'react';
+import Helmet from 'react-helmet';
+
+import { Skeleton } from '@material-ui/lab';
import CodeIcon from '@material-ui/icons/Code';
-import { useRouteRef } from '@backstage/core-plugin-api';
-import { Header, HeaderLabel } from '@backstage/core-components';
-import { CompoundEntityRef, RELATION_OWNED_BY } from '@backstage/catalog-model';
+import {
+ TechDocsAddonLocations as locations,
+ useTechDocsAddons,
+ useTechDocsReaderPage,
+ TechDocsEntityMetadata,
+ TechDocsMetadata,
+} from '@backstage/plugin-techdocs-react';
import {
EntityRefLink,
EntityRefLinks,
getEntityRelations,
} from '@backstage/plugin-catalog-react';
+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 { rootRouteRef } from '../../routes';
-import { TechDocsEntityMetadata, TechDocsMetadata } from '../../types';
+import { rootRouteRef } from '../../../routes';
+
+const skeleton = ;
/**
* Props for {@link TechDocsReaderPageHeader}
*
* @public
+ * @deprecated No need to pass down properties anymore. The component consumes data from `TechDocsReaderPageContext` instead. Use the {@link @backstage/plugin-techdocs-react#useTechDocsReaderPage} hook for custom header.
*/
export type TechDocsReaderPageHeaderProps = PropsWithChildren<{
- entityRef: CompoundEntityRef;
+ entityRef?: CompoundEntityRef;
entityMetadata?: TechDocsEntityMetadata;
techDocsMetadata?: TechDocsMetadata;
}>;
/**
- * Component responsible for rendering a Header with metadata on TechDocs reader page.
- *
+ * Renders the reader page header.
+ * This component does not accept props, please use
+ * the Tech Docs add-ons to customize it
* @public
*/
export const TechDocsReaderPageHeader = (
props: TechDocsReaderPageHeaderProps,
) => {
- const { entityRef, entityMetadata, techDocsMetadata, children } = props;
- const { name } = entityRef;
+ const { children } = props;
+ const addons = useTechDocsAddons();
+ const configApi = useApi(configApiRef);
- const { site_name: siteName, site_description: siteDescription } =
- techDocsMetadata || {};
+ const {
+ title,
+ setTitle,
+ subtitle,
+ setSubtitle,
+ entityRef,
+ metadata: { value: metadata },
+ entityMetadata: { value: entityMetadata },
+ } = useTechDocsReaderPage();
+
+ useEffect(() => {
+ if (!metadata) return;
+ setTitle(prevTitle => {
+ const { site_name } = metadata;
+ return prevTitle || site_name;
+ });
+ setSubtitle(prevSubtitle => {
+ let { site_description } = metadata;
+ if (!site_description || site_description === 'None') {
+ site_description = 'Home';
+ }
+ return prevSubtitle || site_description;
+ });
+ }, [metadata, setTitle, setSubtitle]);
+
+ const appTitle = configApi.getOptional('app.title') || 'Backstage';
+ const tabTitle = [subtitle, title, appTitle].filter(Boolean).join(' | ');
const { locationMetadata, spec } = entityMetadata || {};
const lifecycle = spec?.lifecycle;
@@ -109,16 +148,17 @@ export const TechDocsReaderPageHeader = (
return (
+
+ {tabTitle}
+
{labels}
{children}
+ {addons.renderComponentsByLocation(locations.Header)}
);
};
diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/index.ts b/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/index.ts
new file mode 100644
index 0000000000..e733d9f3b7
--- /dev/null
+++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/index.ts
@@ -0,0 +1,18 @@
+/*
+ * Copyright 2022 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.
+ */
+
+export { TechDocsReaderPageHeader } from './TechDocsReaderPageHeader';
+export type { TechDocsReaderPageHeaderProps } from './TechDocsReaderPageHeader';
diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageSubheader/TechDocsReaderPageSubheader.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPageSubheader/TechDocsReaderPageSubheader.tsx
new file mode 100644
index 0000000000..62d64fa858
--- /dev/null
+++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageSubheader/TechDocsReaderPageSubheader.tsx
@@ -0,0 +1,60 @@
+/*
+ * Copyright 2022 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 React from 'react';
+
+import { Box, Toolbar, ToolbarProps, withStyles } from '@material-ui/core';
+
+import {
+ TechDocsAddonLocations as locations,
+ useTechDocsAddons,
+} from '@backstage/plugin-techdocs-react';
+
+/**
+ * Renders the reader page subheader.
+ * Please use the Tech Docs add-ons to customize it
+ * @public
+ */
+export const TechDocsReaderPageSubheader = withStyles(theme => ({
+ root: {
+ gridArea: 'pageSubheader',
+ flexDirection: 'column',
+ minHeight: 'auto',
+ padding: theme.spacing(3, 3, 0),
+ },
+}))(({ toolbarProps }: { toolbarProps?: ToolbarProps }) => {
+ const addons = useTechDocsAddons();
+ const subheaderAddons = addons.renderComponentsByLocation(
+ locations.Subheader,
+ );
+
+ if (!subheaderAddons) return null;
+
+ return (
+
+ {subheaderAddons && (
+
+ {subheaderAddons}
+
+ )}
+
+ );
+});
diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageSubheader/index.ts b/plugins/techdocs/src/reader/components/TechDocsReaderPageSubheader/index.ts
new file mode 100644
index 0000000000..78f270e191
--- /dev/null
+++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageSubheader/index.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2022 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.
+ */
+
+export { TechDocsReaderPageSubheader } from './TechDocsReaderPageSubheader';
diff --git a/plugins/techdocs/src/reader/components/TechDocsStateIndicator.tsx b/plugins/techdocs/src/reader/components/TechDocsStateIndicator.tsx
index 417dd5727c..93694280d7 100644
--- a/plugins/techdocs/src/reader/components/TechDocsStateIndicator.tsx
+++ b/plugins/techdocs/src/reader/components/TechDocsStateIndicator.tsx
@@ -21,7 +21,7 @@ import { Alert } from '@material-ui/lab';
import { TechDocsBuildLogs } from './TechDocsBuildLogs';
import { TechDocsNotFound } from './TechDocsNotFound';
-import { useTechDocsReader } from './Reader';
+import { useTechDocsReader } from './TechDocsReaderPageContent';
const useStyles = makeStyles(theme => ({
root: {
diff --git a/plugins/techdocs/src/reader/components/index.ts b/plugins/techdocs/src/reader/components/index.ts
index 8e660767ad..406d623bda 100644
--- a/plugins/techdocs/src/reader/components/index.ts
+++ b/plugins/techdocs/src/reader/components/index.ts
@@ -14,23 +14,13 @@
* limitations under the License.
*/
-export * from './Reader';
export type {
TechDocsReaderPageProps,
- TechDocsReaderPageRenderFunction,
+ TechDocsReaderLayoutProps,
} from './TechDocsReaderPage';
+export { TechDocsReaderLayout } from './TechDocsReaderPage';
export * from './TechDocsReaderPageHeader';
+export * from './TechDocsReaderPageContent';
+export * from './TechDocsReaderPageSubheader';
export * from './TechDocsStateIndicator';
-
-/**
- * Note: this component is currently being exported so that we can rapidly
- * iterate on alternative implementations that extend core
- * functionality. There is no guarantee that this component will continue to be
- * exported by the package in the future!
- *
- * Why is this comment here instead of above the component itself? It's a
- * workaround for some kind of bug in @microsoft/api-extractor.
- *
- * todo: Make public or stop exporting (ctrl+f "altReaderExperiments")
- * @internal
- */
+export type { ReaderState, ContentStateTypes } from './useReaderState';
diff --git a/plugins/techdocs/src/reader/components/useReaderState.ts b/plugins/techdocs/src/reader/components/useReaderState.ts
index 8bccb3cdb8..39f09af264 100644
--- a/plugins/techdocs/src/reader/components/useReaderState.ts
+++ b/plugins/techdocs/src/reader/components/useReaderState.ts
@@ -21,9 +21,10 @@ import useAsyncRetry from 'react-use/lib/useAsyncRetry';
import { techdocsStorageApiRef } from '../../api';
/**
+ * @public
* A state representation that is used to configure the UI of
*/
-type ContentStateTypes =
+export type ContentStateTypes =
/** There is nothing to display but a loading indicator */
| 'CHECKING'
@@ -224,13 +225,10 @@ export function reducer(
return newState;
}
-
-export function useReaderState(
- kind: string,
- namespace: string,
- name: string,
- path: string,
-): {
+/**
+ * @public shared reader state
+ */
+export type ReaderState = {
state: ContentStateTypes;
path: string;
contentReload: () => void;
@@ -238,7 +236,14 @@ export function useReaderState(
contentErrorMessage?: string;
syncErrorMessage?: string;
buildLog: string[];
-} {
+};
+
+export function useReaderState(
+ kind: string,
+ namespace: string,
+ name: string,
+ path: string,
+): ReaderState {
const [state, dispatch] = useReducer(reducer, {
activeSyncState: 'CHECKING',
path,
diff --git a/plugins/techdocs/src/types.ts b/plugins/techdocs/src/types.ts
index ee6b88756c..014bf0d090 100644
--- a/plugins/techdocs/src/types.ts
+++ b/plugins/techdocs/src/types.ts
@@ -14,23 +14,27 @@
* limitations under the License.
*/
-import { Entity } from '@backstage/catalog-model';
+import { CompoundEntityRef } from '@backstage/catalog-model';
+import {
+ TechDocsEntityMetadata,
+ TechDocsMetadata,
+} from '@backstage/plugin-techdocs-react';
/**
- * Metadata for TechDocs page
+ * Helper function that gives the children of {@link TechDocsReaderPage} access to techdocs and entity metadata
*
* @public
*/
-export type TechDocsMetadata = {
- site_name: string;
- site_description: string;
-};
-
-/**
- * Metadata for TechDocs Entity
- *
- * @public
- */
-export type TechDocsEntityMetadata = Entity & {
- locationMetadata?: { type: string; target: string };
-};
+export type TechDocsReaderPageRenderFunction = ({
+ techdocsMetadataValue,
+ entityMetadataValue,
+ entityRef,
+}: {
+ techdocsMetadataValue?: TechDocsMetadata | undefined;
+ entityMetadataValue?: TechDocsEntityMetadata | undefined;
+ entityRef: CompoundEntityRef;
+ /**
+ * @deprecated You can continue pass this property, but directly to the `TechDocsReaderPageContent` component.
+ */
+ onReady?: () => void;
+}) => JSX.Element;
diff --git a/scripts/api-extractor.ts b/scripts/api-extractor.ts
index 79f1f1eeb4..946968c759 100644
--- a/scripts/api-extractor.ts
+++ b/scripts/api-extractor.ts
@@ -257,14 +257,15 @@ const NO_WARNING_PACKAGES = [
'plugins/scaffolder-common',
'plugins/search-backend-node',
'plugins/search-common',
+ 'plugins/techdocs',
'plugins/techdocs-backend',
'plugins/techdocs-node',
+ 'plugins/techdocs-react',
'plugins/tech-insights',
'plugins/tech-insights-backend',
'plugins/tech-insights-backend-module-jsonfc',
'plugins/tech-insights-common',
'plugins/tech-insights-node',
- 'plugins/techdocs',
'plugins/todo',
'plugins/todo-backend',
];
diff --git a/yarn.lock b/yarn.lock
index b3e4fbc869..6f83e61369 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -6002,6 +6002,11 @@
resolved "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f"
integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==
+"@types/event-source-polyfill@^1.0.0":
+ version "1.0.0"
+ resolved "https://registry.npmjs.org/@types/event-source-polyfill/-/event-source-polyfill-1.0.0.tgz#f93f13433f750c8ea0e3cfa69c72e3c7393e0585"
+ integrity sha512-b8O8/rg7NIW0iJ8i9MNDBZqPljHA+b7AjC3QFqH3dSyW6vgrl3oBgyIv5dw2fibh5enHHDkkPZG5PHza7U4NRw==
+
"@types/expect@^1.20.4":
version "1.20.4"
resolved "https://registry.npmjs.org/@types/expect/-/expect-1.20.4.tgz#8288e51737bf7e3ab5d7c77bfa695883745264e5"
@@ -7837,7 +7842,7 @@ array-ify@^1.0.0:
resolved "https://registry.npmjs.org/array-ify/-/array-ify-1.0.0.tgz#9e528762b4a9066ad163a6962a364418e9626ece"
integrity sha1-nlKHYrSpBmrRY6aWKjZEGOlibs4=
-array-includes@^3.1.3, array-includes@^3.1.4:
+array-includes@^3.1.2, array-includes@^3.1.3, array-includes@^3.1.4:
version "3.1.4"
resolved "https://registry.npmjs.org/array-includes/-/array-includes-3.1.4.tgz#f5b493162c760f3539631f005ba2bb46acb45ba9"
integrity sha512-ZTNSQkmWumEbiHO2GF4GmWxYVTiQyJy2XOTa15sdQSrvKn7l+180egQMqlrMOUMCyLMD7pmyQe4mMDUT6Behrw==
@@ -12064,6 +12069,11 @@ event-source-polyfill@1.0.25:
resolved "https://registry.npmjs.org/event-source-polyfill/-/event-source-polyfill-1.0.25.tgz#d8bb7f99cb6f8119c2baf086d9f6ee0514b6d9c8"
integrity sha512-hQxu6sN1Eq4JjoI7ITdQeGGUN193A2ra83qC0Ltm9I2UJVAten3OFVN6k5RX4YWeCS0BoC8xg/5czOCIHVosQg==
+event-source-polyfill@^1.0.25:
+ version "1.0.26"
+ resolved "https://registry.npmjs.org/event-source-polyfill/-/event-source-polyfill-1.0.26.tgz#86c04d088ef078279168eefa028f928fec5059a4"
+ integrity sha512-IwDLs9fUTcGAyacHBeS53T8wcEkDyDn0UP4tfQqJ4wQP8AyH0mszuQf2ULTylnpI0sMquzJ4usrNV7+uztwI9A==
+
event-stream@=3.3.4:
version "3.3.4"
resolved "https://registry.npmjs.org/event-stream/-/event-stream-3.3.4.tgz#4ab4c9a0f5a54db9338b4c34d86bfce8f4b35571"
@@ -16184,7 +16194,25 @@ jss@10.6.0, jss@^10.5.1:
is-in-browser "^1.1.3"
tiny-warning "^1.0.2"
-"jsx-ast-utils@^2.4.1 || ^3.0.0", jsx-ast-utils@^3.2.1:
+jss@~10.8.2:
+ version "10.8.2"
+ resolved "https://registry.npmjs.org/jss/-/jss-10.8.2.tgz#4b2a30b094b924629a64928236017a52c7c97505"
+ integrity sha512-FkoUNxI329CKQ9OQC8L72MBF9KPf5q8mIupAJ5twU7G7XREW7ahb+7jFfrjZ4iy1qvhx1HwIWUIvkZBDnKkEdQ==
+ dependencies:
+ "@babel/runtime" "^7.3.1"
+ csstype "^3.0.2"
+ is-in-browser "^1.1.3"
+ tiny-warning "^1.0.2"
+
+"jsx-ast-utils@^2.4.1 || ^3.0.0":
+ version "3.2.0"
+ resolved "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.2.0.tgz#41108d2cec408c3453c1bbe8a4aae9e1e2bd8f82"
+ integrity sha512-EIsmt3O3ljsU6sot/J4E1zDRxfBNrhjyf/OKjlydwgEimQuznlM4Wv7U+ueONJMyEn1WRE0K8dhi3dVAXYT24Q==
+ dependencies:
+ array-includes "^3.1.2"
+ object.assign "^4.1.2"
+
+jsx-ast-utils@^3.2.1:
version "3.2.1"
resolved "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.2.1.tgz#720b97bfe7d901b927d87c3773637ae8ea48781b"
integrity sha512-uP5vu8xfy2F9A6LGC22KO7e2/vGTS1MhP+18f++ZNlf0Ohaxbc9nIEwHAsejlJKyzfZzU5UIhe5ItYkitcZnZA==
@@ -23708,6 +23736,7 @@ tdigest@^0.1.1:
"@backstage/integration-react" "^1.0.1-next.1"
"@backstage/plugin-catalog" "^1.1.0-next.1"
"@backstage/plugin-techdocs" "^1.0.1-next.1"
+ "@backstage/plugin-techdocs-react" "^0.0.0"
"@backstage/test-utils" "^1.0.1-next.1"
"@backstage/theme" "^0.2.15"
"@material-ui/core" "^4.11.0"