Merge pull request #10178 from backstage/techdocs/addon-integration

[TechDocs] Addon Framework
This commit is contained in:
Eric Peterson
2022-04-12 16:27:02 +02:00
committed by GitHub
60 changed files with 3275 additions and 1696 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/integration': patch
---
Exported `replaceGitLabUrlType` from package
+9
View File
@@ -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.
+56
View File
@@ -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';
// ...
<Route path="/docs" element={<TechDocsIndexPage />} />
<Route
path="/docs/:namespace/:kind/:name/*"
element={<TechDocsReaderPage />}
>
+ <TechDocsAddons>
+ <SomeAddon />
+ </TechDocsAddons>
</Route>
// ...
```
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';
// ...
<EntityLayoutWrapper>
<EntityLayout.Route path="/" title="Overview">
{overviewContent}
</EntityLayout.Route>
<EntityLayout.Route path="/docs" title="Docs">
- <EntityTechDocsContent />
+ <EntityTechdocsContent>
+ <TechDocsAddons>
+ <SomeAddon />
+ </TechDocsAddons>
+ </EntityTechdocsContent>
</EntityLayout.Route>
</EntityLayoutWrapper>
// ...
```
If you do not wish to customize your TechDocs reader experience in this way at this time, no changes are necessary!
+3
View File
@@ -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
+2
View File
@@ -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
+2 -1
View File
@@ -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';
@@ -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 (
<TechDocsReaderPage>
{({ techdocsMetadataValue, entityMetadataValue, entityRef, onReady }) => (
<>
<TechDocsReaderPageHeader
techDocsMetadata={techdocsMetadataValue}
entityMetadata={entityMetadataValue}
entityRef={entityRef}
/>
<Content data-testid="techdocs-content">
<Reader onReady={onReady} entityRef={entityRef} />
</Content>
</>
)}
<TechDocsReaderPageHeader />
<TechDocsReaderPageSubheader />
<TechDocsReaderPageContent />
</TechDocsReaderPage>
);
};
+6
View File
@@ -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;
@@ -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',
),
@@ -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 {
+1 -1
View File
@@ -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';
@@ -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",
+23 -5
View File
@@ -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 = (
<FlatRoutes>
<Navigate key="/" to="/docs/default/component/local/" />
@@ -56,6 +71,9 @@ const routes = (
element={<TechDocsReaderPage />}
>
{techDocsPage}
<TechDocsAddons>
<ThemeToggleAddon />
</TechDocsAddons>
</Route>
</FlatRoutes>
);
@@ -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<TechDocsThemeValue>({
theme: Themes.LIGHT,
toggleTheme: () => {},
});
const TechdocsThemeProvider: FC = ({ children }) => {
const [theme, setTheme] = useState<Themes>(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 (
<TechDocsThemeContext.Provider value={value}>
<ThemeProvider theme={themes[theme]}>{children}</ThemeProvider>
</TechDocsThemeContext.Provider>
);
};
const useTechDocsTheme = () => useContext(TechDocsThemeContext);
const TechDocsThemeToggle = () => {
export const TechDocsThemeToggle = () => {
const appThemeApi = useApi(appThemeApiRef);
const classes = useStyles();
const { theme, toggleTheme } = useTechDocsTheme();
const [theme, setTheme] = useState<Themes>(
(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 (
<Box display="flex" alignItems="center" mr={2}>
<Tooltip title={title} arrow>
<IconButton size="small" onClick={toggleTheme}>
<IconButton size="small" onClick={handleSetTheme}>
<Icon className={classes.headerIcon} />
</IconButton>
</Tooltip>
@@ -123,47 +90,13 @@ const TechDocsThemeToggle = () => {
);
};
const TechDocsPageContent = ({
onReady,
entityRef,
}: {
entityRef: CompoundEntityRef;
onReady: () => void;
}) => {
const classes = useStyles();
return (
<Content className={classes.content} data-testid="techdocs-content">
<Reader onReady={onReady} entityRef={entityRef} withSearch={false} />
</Content>
);
};
const DefaultTechDocsPage = () => {
const techDocsMetadata = {
site_name: 'Live preview environment',
site_description: '',
};
return (
<TechDocsReaderPage>
{({ entityRef, onReady }) => (
<>
<TechDocsReaderPageHeader
entityRef={entityRef}
techDocsMetadata={techDocsMetadata}
>
<TechDocsThemeToggle />
</TechDocsReaderPageHeader>
<TechDocsPageContent entityRef={entityRef} onReady={onReady} />
</>
)}
<TechDocsReaderPageHeader />
<TechDocsReaderPageContent withSearch={false} />
</TechDocsReaderPage>
);
};
export const techDocsPage = (
<TechdocsThemeProvider>
<DefaultTechDocsPage />
</TechdocsThemeProvider>
);
export const techDocsPage = <DefaultTechDocsPage />;
@@ -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
+1
View File
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
+9
View File
@@ -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
```
+129
View File
@@ -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<TComponentProps>(
options: TechDocsAddonOptions<TComponentProps>,
): Extension<ComponentType<TComponentProps>>;
// @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<TAddonProps = {}> = {
name: string;
location: keyof typeof TechDocsAddonLocations;
component: ComponentType<TAddonProps>;
};
// @alpha
export const TechDocsAddons: React_2.ComponentType;
// @public
export interface TechDocsApi {
// (undocumented)
getApiOrigin(): Promise<string>;
// (undocumented)
getEntityMetadata(
entityId: CompoundEntityRef,
): Promise<TechDocsEntityMetadata>;
// (undocumented)
getTechDocsMetadata(entityId: CompoundEntityRef): Promise<TechDocsMetadata>;
}
// @public
export const techdocsApiRef: ApiRef<TechDocsApi>;
// @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<TechDocsMetadata>;
entityRef: CompoundEntityRef;
entityMetadata: AsyncState<TechDocsEntityMetadata>;
shadowRoot?: ShadowRoot;
setShadowRoot: Dispatch<SetStateAction<ShadowRoot | undefined>>;
title: string;
setTitle: Dispatch<SetStateAction<string>>;
subtitle: string;
setSubtitle: Dispatch<SetStateAction<string>>;
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;
```
+65
View File
@@ -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"
]
}
+132
View File
@@ -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 <TechDocsAddons> 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<TComponentProps>(
options: TechDocsAddonOptions<TComponentProps>,
): Extension<ComponentType<TComponentProps>> {
const { name, component: TechDocsAddon } = options;
return createReactExtension({
name,
component: {
sync: (props: TComponentProps) => <TechDocsAddon {...props} />,
},
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<TechDocsAddonOptions>({
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 };
};
+41
View File
@@ -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<string>;
getTechDocsMetadata(entityId: CompoundEntityRef): Promise<TechDocsMetadata>;
getEntityMetadata(
entityId: CompoundEntityRef,
): Promise<TechDocsEntityMetadata>;
}
/**
* Utility API reference for the {@link TechDocsApi}.
*
* @public
*/
export const techdocsApiRef = createApiRef<TechDocsApi>({
id: 'plugin.techdocs.service',
});
+126
View File
@@ -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 = '<h1>Shadow DOM Mock</h1>';
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;
}) => (
<ThemeProvider theme={lightTheme}>
<TestApiProvider apis={[[techdocsApiRef, techdocsApiMock]]}>
<TechDocsReaderPageProvider entityRef={entityRef}>
{children}
</TechDocsReaderPageProvider>
</TestApiProvider>
</ThemeProvider>
);
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(
'<h1>Shadow DOM Mock</h1>',
);
});
});
+171
View File
@@ -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<TechDocsMetadata>;
entityRef: CompoundEntityRef;
entityMetadata: AsyncState<TechDocsEntityMetadata>;
shadowRoot?: ShadowRoot;
setShadowRoot: Dispatch<SetStateAction<ShadowRoot | undefined>>;
title: string;
setTitle: Dispatch<SetStateAction<string>>;
subtitle: string;
setSubtitle: Dispatch<SetStateAction<string>>;
/**
* @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<ShadowRoot | undefined>(
defaultTechDocsReaderPageValue.shadowRoot,
);
const value = {
metadata,
entityRef,
entityMetadata,
shadowRoot,
setShadowRoot,
title,
setTitle,
subtitle,
setSubtitle,
};
const versionedValue = createVersionedValueMap({ 1: value });
return (
<TechDocsReaderPageContext.Provider value={versionedValue}>
{children instanceof Function ? children(value) : children}
</TechDocsReaderPageContext.Provider>
);
},
(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;
};
+103
View File
@@ -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 = '<h1>Shadow DOM Mock</h1>';
(shadowRoot as ShadowRoot & Pick<Document, 'getSelection'>).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 ');
});
});
});
});
+95
View File
@@ -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<TReturnedElement>(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<Selection | null>(null);
const handleSelectionChange = useMemo(
() =>
debounce(() => {
const shadowDocument = shadowRoot as ShadowRoot &
Pick<Document, 'getSelection'>;
// 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;
};
+51
View File
@@ -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';
+111
View File
@@ -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
* <TechDocsAddon>CatGif</TechDocsAddon>
*
* ## Component Metadata
* <TechDocsAddon entityRef="default:component/some-component-name">CatalogEntityCard</TechDocsAddon>
*
* ## System Metadata
* <TechDocsAddon entityRef="default:system/some-system-name">CatalogEntityCard</TechDocsAddon>
* ```
*
* 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 `<TechDocsAddon>` 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<TAddonProps = {}> = {
name: string;
location: keyof typeof TechDocsAddonLocations;
component: ComponentType<TAddonProps>;
};
+106 -35
View File
@@ -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<string>;
// (undocumented)
getEntityMetadata(
entityId: CompoundEntityRef,
): Promise<TechDocsEntityMetadata>;
): Promise<TechDocsEntityMetadata_2>;
// (undocumented)
getTechDocsMetadata(entityId: CompoundEntityRef): Promise<TechDocsMetadata>;
getTechDocsMetadata(entityId: CompoundEntityRef): Promise<TechDocsMetadata_2>;
}
// @public
// @public @deprecated
export const techdocsApiRef: ApiRef<TechDocsApi>;
// @public
@@ -208,8 +236,8 @@ export class TechDocsClient implements TechDocsApi {
getApiOrigin(): Promise<string>;
getEntityMetadata(
entityId: CompoundEntityRef,
): Promise<TechDocsEntityMetadata>;
getTechDocsMetadata(entityId: CompoundEntityRef): Promise<TechDocsMetadata>;
): Promise<TechDocsEntityMetadata_2>;
getTechDocsMetadata(entityId: CompoundEntityRef): Promise<TechDocsMetadata_2>;
}
// @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;
+6 -3
View File
@@ -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 (
<Reader
<TechDocsReaderPageProvider
entityRef={{
kind: 'Component',
namespace: 'default',
name: 'my-docs',
}}
/>
>
<TechDocsReaderPageContent />
</TechDocsReaderPageProvider>
);
}
}
+6 -1
View File
@@ -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",
+15 -14
View File
@@ -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 (
<Reader
withSearch={false}
entityRef={{
namespace: toLowerMaybe(entity.metadata.namespace ?? 'default', config),
kind: toLowerMaybe(entity.kind, config),
name: toLowerMaybe(entity.metadata.name, config),
}}
/>
<TechDocsReaderPage entityRef={entityRef}>
<TechDocsReaderPageSubheader />
<TechDocsReaderPageContent withSearch={false} />
</TechDocsReaderPage>
);
};
+15 -9
View File
@@ -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 (
<Routes>
<Route path="/*" element={<EntityPageDocs entity={entity} />} />
</Routes>
<FlatRoutes>
<Route path="/*" element={<EntityPageDocs entity={entity} />}>
{children}
</Route>
</FlatRoutes>
);
};
+6 -1
View File
@@ -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<TechDocsStorageApi>({
* Utility API reference for the {@link TechDocsApi}.
*
* @public
* @deprecated Import from `@backstage/plugin-techdocs-react` instead
*/
export const techdocsApiRef = createApiRef<TechDocsApi>({
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<string>;
+4 -1
View File
@@ -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`.
+24
View File
@@ -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,
};
@@ -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('<Reader />', () => {
it('should render Reader content', async () => {
useParams.mockReturnValue({
entityRef: 'Component::backstage',
});
const scmIntegrationsApi: ScmIntegrationsApi =
ScmIntegrationsApi.fromConfig(
new ConfigReader({
integrations: {},
}),
);
const techdocsStorageApi: Partial<TechDocsStorageApi> = {};
const searchApi = {
query: () =>
Promise.resolve({
results: [],
}),
};
const apiRegistry = TestApiRegistry.from(
[scmIntegrationsApiRef, scmIntegrationsApi],
[techdocsStorageApiRef, techdocsStorageApi],
[searchApiRef, searchApi],
);
await act(async () => {
const rendered = render(
wrapInTestApp(
<ApiProvider apis={apiRegistry}>
<Reader
entityRef={{
kind: 'Component',
namespace: 'default',
name: 'example',
}}
/>
</ApiProvider>,
),
);
expect(
rendered.getByTestId('techdocs-content-shadowroot'),
).toBeInTheDocument();
});
});
});
@@ -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<BackstageTheme>(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<typeof useReaderState>;
const TechDocsReaderContext = createContext<TechDocsReaderValue>(
{} 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 (
<TechDocsReaderContext.Provider value={value}>
{children}
</TechDocsReaderContext.Provider>
);
};
/**
* Note: this HOC is currently being exported so that we can rapidly
* iterate on alternative <Reader /> 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 =
<T extends {}>(Component: ComponentType<T>, entityRef: CompoundEntityRef) =>
(props: T) =>
(
<TechDocsReaderProvider entityRef={entityRef}>
<Component {...props} />
</TechDocsReaderProvider>
);
/**
* Note: this hook is currently being exported so that we can rapidly
* iterate on alternative <Reader /> 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 <Reader /> 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<BackstageTheme>();
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<HTMLElement[]>();
const [dom, setDom] = useState<HTMLElement | null>(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,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M20.71 7.04c.39-.39.39-1.04 0-1.41l-2.34-2.34c-.37-.39-1.02-.39-1.41 0l-1.84 1.83 3.75 3.75M3 17.25V21h3.75L17.81 9.93l-3.75-3.75L3 17.25z"/></svg>');
--md-admonition-icon--abstract: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M4 5h16v2H4V5m0 4h16v2H4V9m0 4h16v2H4v-2m0 4h10v2H4v-2z"/></svg>');
--md-admonition-icon--info: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M13 9h-2V7h2m0 10h-2v-6h2m-1-9A10 10 0 002 12a10 10 0 0010 10 10 10 0 0010-10A10 10 0 0012 2z"/></svg>');
--md-admonition-icon--tip: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M17.55 11.2c-.23-.3-.5-.56-.76-.82-.65-.6-1.4-1.03-2.03-1.66C13.3 7.26 13 4.85 13.91 3c-.91.23-1.75.75-2.45 1.32-2.54 2.08-3.54 5.75-2.34 8.9.04.1.08.2.08.33 0 .22-.15.42-.35.5-.22.1-.46.04-.64-.12a.83.83 0 01-.15-.17c-1.1-1.43-1.28-3.48-.53-5.12C5.89 10 5 12.3 5.14 14.47c.04.5.1 1 .27 1.5.14.6.4 1.2.72 1.73 1.04 1.73 2.87 2.97 4.84 3.22 2.1.27 4.35-.12 5.96-1.6 1.8-1.66 2.45-4.32 1.5-6.6l-.13-.26c-.2-.46-.47-.87-.8-1.25l.05-.01m-3.1 6.3c-.28.24-.73.5-1.08.6-1.1.4-2.2-.16-2.87-.82 1.19-.28 1.89-1.16 2.09-2.05.17-.8-.14-1.46-.27-2.23-.12-.74-.1-1.37.18-2.06.17.38.37.76.6 1.06.76 1 1.95 1.44 2.2 2.8.04.14.06.28.06.43.03.82-.32 1.72-.92 2.27h.01z"/></svg>');
--md-admonition-icon--success: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10 10-4.5 10-10S17.5 2 12 2m-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>');
--md-admonition-icon--question: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M15.07 11.25l-.9.92C13.45 12.89 13 13.5 13 15h-2v-.5c0-1.11.45-2.11 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41a2 2 0 00-2-2 2 2 0 00-2 2H8a4 4 0 014-4 4 4 0 014 4 3.2 3.2 0 01-.93 2.25M13 19h-2v-2h2M12 2A10 10 0 002 12a10 10 0 0010 10 10 10 0 0010-10c0-5.53-4.5-10-10-10z"/></svg>');
--md-admonition-icon--warning: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M13 14h-2v-4h2m0 8h-2v-2h2M1 21h22L12 2 1 21z"/></svg>');
--md-admonition-icon--failure: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 2c5.53 0 10 4.47 10 10s-4.47 10-10 10S2 17.53 2 12 6.47 2 12 2m3.59 5L12 10.59 8.41 7 7 8.41 10.59 12 7 15.59 8.41 17 12 13.41 15.59 17 17 15.59 13.41 12 17 8.41 15.59 7z"/></svg>');
--md-admonition-icon--danger: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M11.5 20l4.86-9.73H13V4l-5 9.73h3.5V20M12 2c2.75 0 5.1 1 7.05 2.95C21 6.9 22 9.25 22 12s-1 5.1-2.95 7.05C17.1 21 14.75 22 12 22s-5.1-1-7.05-2.95C3 17.1 2 14.75 2 12s1-5.1 2.95-7.05C6.9 3 9.25 2 12 2z"/></svg>');
--md-admonition-icon--bug: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M14 12h-4v-2h4m0 6h-4v-2h4m6-6h-2.81a5.985 5.985 0 00-1.82-1.96L17 4.41 15.59 3l-2.17 2.17a6.002 6.002 0 00-2.83 0L8.41 3 7 4.41l1.62 1.63C7.88 6.55 7.26 7.22 6.81 8H4v2h2.09c-.05.33-.09.66-.09 1v1H4v2h2v1c0 .34.04.67.09 1H4v2h2.81c1.04 1.79 2.97 3 5.19 3s4.15-1.21 5.19-3H20v-2h-2.09c.05-.33.09-.66.09-1v-1h2v-2h-2v-1c0-.34-.04-.67-.09-1H20V8z"/></svg>');
--md-admonition-icon--example: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M7 13v-2h14v2H7m0 6v-2h14v2H7M7 7V5h14v2H7M3 8V5H2V4h2v4H3m-1 9v-1h3v4H2v-1h2v-.5H3v-1h1V17H2m2.25-7a.75.75 0 01.75.75c0 .2-.08.39-.21.52L3.12 13H5v1H2v-.92L4 11H2v-1h2.25z"/></svg>');
--md-admonition-icon--quote: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M14 17h3l2-4V7h-6v6h3M6 17h3l2-4V7H5v6h3l-2 4z"/></svg>');
--md-footnotes-icon: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 7v4H5.83l3.58-3.59L8 6l-6 6 6 6 1.41-1.42L5.83 13H21V7h-2z"/></svg>');
--md-details-icon: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M8.59 16.58 13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.42z"/></svg>');
--md-tasklist-icon: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M0 0h24v24H0z" fill="none"/><path d="M19 5v14H5V5h14m0-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z"/></svg>');
--md-tasklist-icon--checked: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M0 0h24v24H0z" fill="none"/><path d="M19 3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.11 0 2-.9 2-2V5c0-1.1-.89-2-2-2zm-9 14l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>');
--md-nav-icon--prev: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M20 11v2H8l5.5 5.5-1.42 1.42L4.16 12l7.92-7.92L13.5 5.5 8 11h12z"/></svg>');
--md-nav-icon--next: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M8.59 16.58 13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.42z"/></svg>');
--md-toc-icon: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M3 9h14V7H3v2m0 4h14v-2H3v2m0 4h14v-2H3v2m16 0h2v-2h-2v2m0-10v2h2V7h-2m0 6h2v-2h-2v2z"/></svg>');
--md-clipboard-icon: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 21H8V7h11m0-2H8a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h11a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2m-3-4H4a2 2 0 0 0-2 2v14h2V3h12V1z"/></svg>');
--md-search-result-icon: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h7c-.41-.25-.8-.56-1.14-.9-.33-.33-.61-.7-.86-1.1H6V4h7v5h5v1.18c.71.16 1.39.43 2 .82V8l-6-6m6.31 16.9c1.33-2.11.69-4.9-1.4-6.22-2.11-1.33-4.91-.68-6.22 1.4-1.34 2.11-.69 4.89 1.4 6.22 1.46.93 3.32.93 4.79.02L22 23.39 23.39 22l-3.08-3.1m-3.81.1a2.5 2.5 0 0 1-2.5-2.5 2.5 2.5 0 0 1 2.5-2.5 2.5 2.5 0 0 1 2.5 2.5 2.5 2.5 0 0 1-2.5 2.5z"/></svg>');
--md-source-forks-icon: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M5 3.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0zm0 2.122a2.25 2.25 0 1 0-1.5 0v.878A2.25 2.25 0 0 0 5.75 8.5h1.5v2.128a2.251 2.251 0 1 0 1.5 0V8.5h1.5a2.25 2.25 0 0 0 2.25-2.25v-.878a2.25 2.25 0 1 0-1.5 0v.878a.75.75 0 0 1-.75.75h-4.5A.75.75 0 0 1 5 6.25v-.878zm3.75 7.378a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0zm3-8.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5z"/></svg>');
--md-source-repositories-icon: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M2 2.5A2.5 2.5 0 0 1 4.5 0h8.75a.75.75 0 0 1 .75.75v12.5a.75.75 0 0 1-.75.75h-2.5a.75.75 0 1 1 0-1.5h1.75v-2h-8a1 1 0 0 0-.714 1.7.75.75 0 0 1-1.072 1.05A2.495 2.495 0 0 1 2 11.5v-9zm10.5-1V9h-8c-.356 0-.694.074-1 .208V2.5a1 1 0 0 1 1-1h8zM5 12.25v3.25a.25.25 0 0 0 .4.2l1.45-1.087a.25.25 0 0 1 .3 0L8.6 15.7a.25.25 0 0 0 .4-.2v-3.25a.25.25 0 0 0-.25-.25h-3.5a.25.25 0 0 0-.25.25z"/></svg>');
--md-source-stars-icon: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8 .25a.75.75 0 0 1 .673.418l1.882 3.815 4.21.612a.75.75 0 0 1 .416 1.279l-3.046 2.97.719 4.192a.75.75 0 0 1-1.088.791L8 12.347l-3.766 1.98a.75.75 0 0 1-1.088-.79l.72-4.194L.818 6.374a.75.75 0 0 1 .416-1.28l4.21-.611L7.327.668A.75.75 0 0 1 8 .25zm0 2.445L6.615 5.5a.75.75 0 0 1-.564.41l-3.097.45 2.24 2.184a.75.75 0 0 1 .216.664l-.528 3.084 2.769-1.456a.75.75 0 0 1 .698 0l2.77 1.456-.53-3.084a.75.75 0 0 1 .216-.664l2.24-2.183-3.096-.45a.75.75 0 0 1-.564-.41L8 2.694v.001z"/></svg>');
--md-source-version-icon: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M2.5 7.775V2.75a.25.25 0 0 1 .25-.25h5.025a.25.25 0 0 1 .177.073l6.25 6.25a.25.25 0 0 1 0 .354l-5.025 5.025a.25.25 0 0 1-.354 0l-6.25-6.25a.25.25 0 0 1-.073-.177zm-1.5 0V2.75C1 1.784 1.784 1 2.75 1h5.025c.464 0 .91.184 1.238.513l6.25 6.25a1.75 1.75 0 0 1 0 2.474l-5.026 5.026a1.75 1.75 0 0 1-2.474 0l-6.25-6.25A1.75 1.75 0 0 1 1 7.775zM6 5a1 1 0 1 0 0 2 1 1 0 0 0 0-2z"/></svg>');
--md-version-icon: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><!--! Font Awesome Free 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2022 Fonticons, Inc.--><path d="m310.6 246.6-127.1 128c-7.1 6.3-15.3 9.4-23.5 9.4s-16.38-3.125-22.63-9.375l-127.1-128C.224 237.5-2.516 223.7 2.438 211.8S19.07 192 32 192h255.1c12.94 0 24.62 7.781 29.58 19.75s3.12 25.75-6.08 34.85z"/></svg>');
}
: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<string>((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<HTMLDivElement>(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 (
<>
<TechDocsStateIndicator />
{withSearch && shadowDomRef?.current?.shadowRoot?.innerHTML && (
<Grid container className={classes.searchBar}>
<TechDocsSearch entityId={entityRef} />
</Grid>
)}
<div data-testid="techdocs-content-shadowroot" ref={shadowDomRef} />
</>
);
};
/**
* Component responsible for rendering TechDocs documentation
*
* @public
*/
export const Reader = (props: ReaderProps) => {
const { entityRef, onReady = () => {}, withSearch = true } = props;
return (
<TechDocsReaderProvider entityRef={entityRef}>
<TheReader
entityRef={entityRef}
onReady={onReady}
withSearch={withSearch}
/>
</TechDocsReaderProvider>
);
};
@@ -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: () => <div />,
};
});
const { useParams }: { useParams: jest.Mock } =
jest.requireMock('react-router-dom');
global.scroll = jest.fn();
describe('<TechDocsReaderPage />', () => {
it('should render techdocs page', async () => {
useParams.mockReturnValue({
entityRef: 'Component::backstage',
});
const scmIntegrationsApi: ScmIntegrationsApi =
ScmIntegrationsApi.fromConfig(
new ConfigReader({
integrations: {},
}),
);
const techdocsApi: Partial<TechDocsApi> = {
getEntityMetadata: () =>
Promise.resolve({
apiVersion: 'v1',
kind: 'Component',
metadata: {
name: 'backstage',
},
}),
getTechDocsMetadata: () =>
Promise.resolve({
site_name: 'string',
site_description: 'string',
}),
};
const techdocsStorageApi: Partial<TechDocsStorageApi> = {
getEntityDocs: (): Promise<string> => Promise.resolve('String'),
getBaseUrl: (): Promise<string> => Promise.resolve('String'),
getApiOrigin: (): Promise<string> => 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(
<ApiProvider apis={apiRegistry}>
<TechDocsReaderPage />
</ApiProvider>,
),
);
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<TechDocsApi> = {
getEntityMetadata: () =>
Promise.resolve({
apiVersion: 'v1',
kind: 'Component',
metadata: {
name: 'backstage',
},
}),
getTechDocsMetadata: () =>
Promise.resolve({
site_name: 'string',
site_description: 'string',
}),
};
const techdocsStorageApi: Partial<TechDocsStorageApi> = {
getEntityDocs: (): Promise<string> => Promise.resolve('String'),
getBaseUrl: (): Promise<string> => Promise.resolve('String'),
getApiOrigin: (): Promise<string> => 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(
<ApiProvider apis={apiRegistry}>
<TechDocsReaderPage>
{({ techdocsMetadataValue }) => (
<Header
type="documentation"
title="A custom header"
subtitle={techdocsMetadataValue?.site_name}
/>
)}
</TechDocsReaderPage>
</ApiProvider>,
),
);
expect(rendered.getByText('A custom header')).toBeInTheDocument();
});
});
});
@@ -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<boolean>(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 <NotFoundErrorPage />;
if (!children)
return (
outlet || (
<Page themeId="documentation">
<TechDocsReaderPageHeader
techDocsMetadata={techdocsMetadataValue}
entityMetadata={entityMetadataValue}
entityRef={{
kind,
namespace,
name,
}}
/>
<Content data-testid="techdocs-content">
<Reader
onReady={onReady}
entityRef={{
kind,
namespace,
name,
}}
/>
</Content>
</Page>
)
);
return (
<Page themeId="documentation">
{children instanceof Function
? children({
techdocsMetadataValue,
entityMetadataValue,
entityRef: { kind, namespace, name },
onReady,
})
: children}
</Page>
);
};
@@ -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<typeof techdocsStorageApiRef.T> = {
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 (
<ThemeProvider theme={lightTheme}>
<TestApiProvider
apis={[
[scmIntegrationsApiRef, {}],
[techdocsApiRef, techdocsApiMock],
[techdocsStorageApiRef, techdocsStorageApiMock],
]}
>
{children}
</TestApiProvider>
</ThemeProvider>
);
};
const mountedRoutes = {
'/catalog/:namespace/:kind/:name/*': entityRouteRef,
'/docs': rootRouteRef,
'/docs/:namespace/:kind/:name/*': rootDocsRouteRef,
};
describe('<TechDocsReaderPage />', () => {
beforeEach(() => {
getEntityMetadata.mockResolvedValue(mockEntityMetadata);
getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata);
});
afterEach(() => {
jest.resetAllMocks();
});
it('should render a techdocs reader page without children', async () => {
const rendered = await renderInTestApp(
<Wrapper>
<TechDocsReaderPage
entityRef={{
name: 'test-name',
namespace: 'test-namespace',
kind: 'test',
}}
/>
</Wrapper>,
{
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(
<Wrapper>
<TechDocsReaderPage
entityRef={{
name: 'test-name',
namespace: 'test-namespace',
kind: 'test',
}}
>
techdocs reader page
</TechDocsReaderPage>
</Wrapper>,
{
mountedRoutes,
},
);
expect(
rendered.container.querySelector('header'),
).not.toBeInTheDocument();
expect(
rendered.container.querySelector('article'),
).not.toBeInTheDocument();
expect(rendered.getByText('techdocs reader page')).toBeInTheDocument();
});
});
});
@@ -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<string, boolean>;
};
};
};
/**
* 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 (
<Page themeId="documentation">
{withHeader && <TechDocsReaderPageHeader />}
<TechDocsReaderPageSubheader />
<TechDocsReaderPageContent withSearch={withSearch} />
</Page>
);
};
/**
* @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) || (
<TechDocsReaderPageProvider entityRef={entityRef}>
<TechDocsReaderLayout />
</TechDocsReaderPageProvider>
)
);
}
return (
<TechDocsReaderPageProvider entityRef={entityRef}>
{({ metadata, entityMetadata, onReady }) => (
<Page themeId="documentation">
{children instanceof Function
? children({
entityRef,
techdocsMetadataValue: metadata.value,
entityMetadataValue: entityMetadata.value,
onReady,
})
: children}
</Page>
)}
</TechDocsReaderPageProvider>
);
};
@@ -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;
}) => (
<ThemeProvider theme={lightTheme}>
<TestApiProvider apis={[[techdocsApiRef, techdocsApiMock]]}>
<TechDocsReaderPageProvider entityRef={entityRef}>
{children}
</TechDocsReaderPageProvider>
</TestApiProvider>
</ThemeProvider>
);
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);
});
});
});
@@ -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;
};
@@ -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';
@@ -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 (
<Content>
<Progress />
</Content>
);
}
return (
<Content>
<Grid container>
<Grid xs={12} item>
<TechDocsStateIndicator />
</Grid>
{withSearch && (
<Grid className={classes.search} xs="auto" item>
<TechDocsSearch entityId={entityRef} />
</Grid>
)}
<Grid xs={12} item>
{/* sheetsManager={new Map()} is needed in order to deduplicate the injection of CSS in the page. */}
<StylesProvider jss={jss} sheetsManager={new Map()}>
<div ref={ref} data-testid="techdocs-native-shadowroot" />
<Portal container={primarySidebarAddonLocation}>
{addons.renderComponentsByLocation(locations.PrimarySidebar)}
</Portal>
<Portal container={contentElement}>
{addons.renderComponentsByLocation(locations.Content)}
</Portal>
<Portal container={secondarySidebarAddonLocation}>
{addons.renderComponentsByLocation(locations.SecondarySidebar)}
</Portal>
</StylesProvider>
</Grid>
</Grid>
</Content>
);
},
);
/**
* 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;
@@ -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<ReaderState>({} as ReaderState);
/**
* Note: this hook is currently being exported so that we can rapidly
* iterate on alternative <Reader /> 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 (
<TechDocsReaderContext.Provider value={value}>
{children instanceof Function ? children(value) : children}
</TechDocsReaderContext.Provider>
);
};
/**
* Note: this HOC is currently being exported so that we can rapidly
* iterate on alternative <Reader /> 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 =
<T extends {}>(Component: ComponentType<T>) =>
(props: T) =>
(
<TechDocsReaderProvider>
<Component {...props} />
</TechDocsReaderProvider>
);
@@ -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 <Reader /> 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<BackstageTheme>();
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<HTMLElement[]>();
const [dom, setDom] = useState<HTMLElement | null>(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,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M20.71 7.04c.39-.39.39-1.04 0-1.41l-2.34-2.34c-.37-.39-1.02-.39-1.41 0l-1.84 1.83 3.75 3.75M3 17.25V21h3.75L17.81 9.93l-3.75-3.75L3 17.25z"/></svg>');
--md-admonition-icon--abstract: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M4 5h16v2H4V5m0 4h16v2H4V9m0 4h16v2H4v-2m0 4h10v2H4v-2z"/></svg>');
--md-admonition-icon--info: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M13 9h-2V7h2m0 10h-2v-6h2m-1-9A10 10 0 002 12a10 10 0 0010 10 10 10 0 0010-10A10 10 0 0012 2z"/></svg>');
--md-admonition-icon--tip: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M17.55 11.2c-.23-.3-.5-.56-.76-.82-.65-.6-1.4-1.03-2.03-1.66C13.3 7.26 13 4.85 13.91 3c-.91.23-1.75.75-2.45 1.32-2.54 2.08-3.54 5.75-2.34 8.9.04.1.08.2.08.33 0 .22-.15.42-.35.5-.22.1-.46.04-.64-.12a.83.83 0 01-.15-.17c-1.1-1.43-1.28-3.48-.53-5.12C5.89 10 5 12.3 5.14 14.47c.04.5.1 1 .27 1.5.14.6.4 1.2.72 1.73 1.04 1.73 2.87 2.97 4.84 3.22 2.1.27 4.35-.12 5.96-1.6 1.8-1.66 2.45-4.32 1.5-6.6l-.13-.26c-.2-.46-.47-.87-.8-1.25l.05-.01m-3.1 6.3c-.28.24-.73.5-1.08.6-1.1.4-2.2-.16-2.87-.82 1.19-.28 1.89-1.16 2.09-2.05.17-.8-.14-1.46-.27-2.23-.12-.74-.1-1.37.18-2.06.17.38.37.76.6 1.06.76 1 1.95 1.44 2.2 2.8.04.14.06.28.06.43.03.82-.32 1.72-.92 2.27h.01z"/></svg>');
--md-admonition-icon--success: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10 10-4.5 10-10S17.5 2 12 2m-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>');
--md-admonition-icon--question: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M15.07 11.25l-.9.92C13.45 12.89 13 13.5 13 15h-2v-.5c0-1.11.45-2.11 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41a2 2 0 00-2-2 2 2 0 00-2 2H8a4 4 0 014-4 4 4 0 014 4 3.2 3.2 0 01-.93 2.25M13 19h-2v-2h2M12 2A10 10 0 002 12a10 10 0 0010 10 10 10 0 0010-10c0-5.53-4.5-10-10-10z"/></svg>');
--md-admonition-icon--warning: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M13 14h-2v-4h2m0 8h-2v-2h2M1 21h22L12 2 1 21z"/></svg>');
--md-admonition-icon--failure: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 2c5.53 0 10 4.47 10 10s-4.47 10-10 10S2 17.53 2 12 6.47 2 12 2m3.59 5L12 10.59 8.41 7 7 8.41 10.59 12 7 15.59 8.41 17 12 13.41 15.59 17 17 15.59 13.41 12 17 8.41 15.59 7z"/></svg>');
--md-admonition-icon--danger: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M11.5 20l4.86-9.73H13V4l-5 9.73h3.5V20M12 2c2.75 0 5.1 1 7.05 2.95C21 6.9 22 9.25 22 12s-1 5.1-2.95 7.05C17.1 21 14.75 22 12 22s-5.1-1-7.05-2.95C3 17.1 2 14.75 2 12s1-5.1 2.95-7.05C6.9 3 9.25 2 12 2z"/></svg>');
--md-admonition-icon--bug: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M14 12h-4v-2h4m0 6h-4v-2h4m6-6h-2.81a5.985 5.985 0 00-1.82-1.96L17 4.41 15.59 3l-2.17 2.17a6.002 6.002 0 00-2.83 0L8.41 3 7 4.41l1.62 1.63C7.88 6.55 7.26 7.22 6.81 8H4v2h2.09c-.05.33-.09.66-.09 1v1H4v2h2v1c0 .34.04.67.09 1H4v2h2.81c1.04 1.79 2.97 3 5.19 3s4.15-1.21 5.19-3H20v-2h-2.09c.05-.33.09-.66.09-1v-1h2v-2h-2v-1c0-.34-.04-.67-.09-1H20V8z"/></svg>');
--md-admonition-icon--example: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M7 13v-2h14v2H7m0 6v-2h14v2H7M7 7V5h14v2H7M3 8V5H2V4h2v4H3m-1 9v-1h3v4H2v-1h2v-.5H3v-1h1V17H2m2.25-7a.75.75 0 01.75.75c0 .2-.08.39-.21.52L3.12 13H5v1H2v-.92L4 11H2v-1h2.25z"/></svg>');
--md-admonition-icon--quote: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M14 17h3l2-4V7h-6v6h3M6 17h3l2-4V7H5v6h3l-2 4z"/></svg>');
--md-footnotes-icon: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 7v4H5.83l3.58-3.59L8 6l-6 6 6 6 1.41-1.42L5.83 13H21V7h-2z"/></svg>');
--md-details-icon: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M8.59 16.58 13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.42z"/></svg>');
--md-tasklist-icon: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M0 0h24v24H0z" fill="none"/><path d="M19 5v14H5V5h14m0-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z"/></svg>');
--md-tasklist-icon--checked: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M0 0h24v24H0z" fill="none"/><path d="M19 3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.11 0 2-.9 2-2V5c0-1.1-.89-2-2-2zm-9 14l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>');
--md-nav-icon--prev: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M20 11v2H8l5.5 5.5-1.42 1.42L4.16 12l7.92-7.92L13.5 5.5 8 11h12z"/></svg>');
--md-nav-icon--next: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M8.59 16.58 13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.42z"/></svg>');
--md-toc-icon: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M3 9h14V7H3v2m0 4h14v-2H3v2m0 4h14v-2H3v2m16 0h2v-2h-2v2m0-10v2h2V7h-2m0 6h2v-2h-2v2z"/></svg>');
--md-clipboard-icon: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 21H8V7h11m0-2H8a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h11a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2m-3-4H4a2 2 0 0 0-2 2v14h2V3h12V1z"/></svg>');
--md-search-result-icon: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h7c-.41-.25-.8-.56-1.14-.9-.33-.33-.61-.7-.86-1.1H6V4h7v5h5v1.18c.71.16 1.39.43 2 .82V8l-6-6m6.31 16.9c1.33-2.11.69-4.9-1.4-6.22-2.11-1.33-4.91-.68-6.22 1.4-1.34 2.11-.69 4.89 1.4 6.22 1.46.93 3.32.93 4.79.02L22 23.39 23.39 22l-3.08-3.1m-3.81.1a2.5 2.5 0 0 1-2.5-2.5 2.5 2.5 0 0 1 2.5-2.5 2.5 2.5 0 0 1 2.5 2.5 2.5 2.5 0 0 1-2.5 2.5z"/></svg>');
--md-source-forks-icon: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M5 3.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0zm0 2.122a2.25 2.25 0 1 0-1.5 0v.878A2.25 2.25 0 0 0 5.75 8.5h1.5v2.128a2.251 2.251 0 1 0 1.5 0V8.5h1.5a2.25 2.25 0 0 0 2.25-2.25v-.878a2.25 2.25 0 1 0-1.5 0v.878a.75.75 0 0 1-.75.75h-4.5A.75.75 0 0 1 5 6.25v-.878zm3.75 7.378a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0zm3-8.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5z"/></svg>');
--md-source-repositories-icon: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M2 2.5A2.5 2.5 0 0 1 4.5 0h8.75a.75.75 0 0 1 .75.75v12.5a.75.75 0 0 1-.75.75h-2.5a.75.75 0 1 1 0-1.5h1.75v-2h-8a1 1 0 0 0-.714 1.7.75.75 0 0 1-1.072 1.05A2.495 2.495 0 0 1 2 11.5v-9zm10.5-1V9h-8c-.356 0-.694.074-1 .208V2.5a1 1 0 0 1 1-1h8zM5 12.25v3.25a.25.25 0 0 0 .4.2l1.45-1.087a.25.25 0 0 1 .3 0L8.6 15.7a.25.25 0 0 0 .4-.2v-3.25a.25.25 0 0 0-.25-.25h-3.5a.25.25 0 0 0-.25.25z"/></svg>');
--md-source-stars-icon: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8 .25a.75.75 0 0 1 .673.418l1.882 3.815 4.21.612a.75.75 0 0 1 .416 1.279l-3.046 2.97.719 4.192a.75.75 0 0 1-1.088.791L8 12.347l-3.766 1.98a.75.75 0 0 1-1.088-.79l.72-4.194L.818 6.374a.75.75 0 0 1 .416-1.28l4.21-.611L7.327.668A.75.75 0 0 1 8 .25zm0 2.445L6.615 5.5a.75.75 0 0 1-.564.41l-3.097.45 2.24 2.184a.75.75 0 0 1 .216.664l-.528 3.084 2.769-1.456a.75.75 0 0 1 .698 0l2.77 1.456-.53-3.084a.75.75 0 0 1 .216-.664l2.24-2.183-3.096-.45a.75.75 0 0 1-.564-.41L8 2.694v.001z"/></svg>');
--md-source-version-icon: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M2.5 7.775V2.75a.25.25 0 0 1 .25-.25h5.025a.25.25 0 0 1 .177.073l6.25 6.25a.25.25 0 0 1 0 .354l-5.025 5.025a.25.25 0 0 1-.354 0l-6.25-6.25a.25.25 0 0 1-.073-.177zm-1.5 0V2.75C1 1.784 1.784 1 2.75 1h5.025c.464 0 .91.184 1.238.513l6.25 6.25a1.75 1.75 0 0 1 0 2.474l-5.026 5.026a1.75 1.75 0 0 1-2.474 0l-6.25-6.25A1.75 1.75 0 0 1 1 7.775zM6 5a1 1 0 1 0 0 2 1 1 0 0 0 0-2z"/></svg>');
--md-version-icon: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><!--! Font Awesome Free 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2022 Fonticons, Inc.--><path d="m310.6 246.6-127.1 128c-7.1 6.3-15.3 9.4-23.5 9.4s-16.38-3.125-22.63-9.375l-127.1-128C.224 237.5-2.516 223.7 2.438 211.8S19.07 192 32 192h255.1c12.94 0 24.62 7.781 29.58 19.75s3.12 25.75-6.08 34.85z"/></svg>');
}
: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<string>((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;
};
@@ -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';
@@ -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('<TechDocsReaderPageHeader />', () => {
it('should render a techdocs page header', async () => {
await act(async () => {
const rendered = await renderInTestApp(
<TechDocsReaderPageHeader
entityRef={{
kind: 'test',
name: 'test-name',
namespace: 'test-namespace',
}}
entityMetadata={{
locationMetadata: {
type: 'github',
target: 'https://example.com/',
},
apiVersion: 'v1',
kind: 'Component',
metadata: {
name: 'test',
},
spec: {
owner: 'test',
},
}}
techDocsMetadata={{
site_name: 'test-site-name',
site_description: 'test-site-desc',
}}
/>,
{
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(
<TechDocsReaderPageHeader
entityRef={{
kind: 'test',
name: 'test-name',
namespace: 'test-namespace',
}}
/>,
{
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(
<TechDocsReaderPageHeader
entityRef={{
kind: 'test',
name: 'test-name',
namespace: 'test-namespace',
}}
techDocsMetadata={{
site_name: 'test-site-name',
site_description: 'test-site-desc',
}}
/>,
{
mountedRoutes: {
'/catalog/:namespace/:kind/:name/*': entityRouteRef,
'/docs': rootRouteRef,
},
},
);
expect(rendered.container.innerHTML).toContain(
'/catalog/test-namespace/test/test-name',
);
});
});
});
@@ -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;
}) => (
<ThemeProvider theme={lightTheme}>
<TestApiProvider apis={[[techdocsApiRef, techdocsApiMock]]}>
<TechDocsReaderPageProvider entityRef={entityRef}>
{children}
</TechDocsReaderPageProvider>
</TestApiProvider>
</ThemeProvider>
);
describe('<TechDocsReaderPageHeader />', () => {
it('should render a techdocs page header', async () => {
getEntityMetadata.mockResolvedValue(mockEntityMetadata);
getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata);
await act(async () => {
const rendered = await renderInTestApp(
<Wrapper>
<TechDocsReaderPageHeader />
</Wrapper>,
{
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(
<Wrapper>
<TechDocsReaderPageHeader />
</Wrapper>,
{
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(
<Wrapper>
<TechDocsReaderPageHeader />
</Wrapper>,
{
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');
});
});
});
});
@@ -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 = <Skeleton animation="wave" variant="text" height={40} />;
/**
* 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 (
<Header
title={siteName ? siteName : '.'}
pageTitleOverride={siteName || name}
subtitle={
siteDescription && siteDescription !== 'None' ? siteDescription : ''
}
type="Docs"
type="Documentation"
typeLink={docsRootLink}
title={title || skeleton}
subtitle={subtitle || skeleton}
>
<Helmet titleTemplate="%s">
<title>{tabTitle}</title>
</Helmet>
{labels}
{children}
{addons.renderComponentsByLocation(locations.Header)}
</Header>
);
};
@@ -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';
@@ -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 (
<Toolbar {...toolbarProps}>
{subheaderAddons && (
<Box
display="flex"
justifyContent="flex-end"
width="100%"
flexWrap="wrap"
>
{subheaderAddons}
</Box>
)}
</Toolbar>
);
});
@@ -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';
@@ -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: {
@@ -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 <Reader /> 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';
@@ -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 <Reader />
*/
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,
+19 -15
View File
@@ -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;
+2 -1
View File
@@ -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',
];
+31 -2
View File
@@ -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"