diff --git a/.changeset/spotty-ducks-exercise.md b/.changeset/spotty-ducks-exercise.md new file mode 100644 index 0000000000..c04d312714 --- /dev/null +++ b/.changeset/spotty-ducks-exercise.md @@ -0,0 +1,5 @@ +--- +'@backstage/integration': patch +--- + +Exported `replaceGitLabUrlType` from package diff --git a/.changeset/techdocs-hold-me-closer.md b/.changeset/techdocs-hold-me-closer.md new file mode 100644 index 0000000000..544ba41cd8 --- /dev/null +++ b/.changeset/techdocs-hold-me-closer.md @@ -0,0 +1,9 @@ +--- +'@backstage/plugin-techdocs-react': minor +--- + +This package will house frontend utilities related to TechDocs to be shared across other frontend Backstage packages. + +In this release, it introduces a framework that can be used create TechDocs addons. + +Note: this package is not necessarily stable yet. After iteration on this package, its stability will be signaled by a major-version bump. diff --git a/.changeset/techdocs-until-you-puke.md b/.changeset/techdocs-until-you-puke.md new file mode 100644 index 0000000000..97d39a9095 --- /dev/null +++ b/.changeset/techdocs-until-you-puke.md @@ -0,0 +1,56 @@ +--- +'@backstage/plugin-techdocs': minor +--- + +TechDocs supports a new, experimental method of customization: addons! + +To customize the standalone TechDocs reader page experience, update your `/packages/app/src/App.tsx` in the following way: + +```diff +import { TechDocsIndexPage, TechDocsReaderPage } from '@backstage/plugin-techdocs'; ++ import { TechDocsAddons } from '@backstage/plugin-techdocs-react'; ++ import { SomeAddon } from '@backstage/plugin-some-plugin'; + +// ... + + } /> + } + > ++ ++ ++ + + +// ... +``` + +To customize the TechDocs reader experience on the Catalog entity page, update your `packages/app/src/components/catalog/EntityPage.tsx` in the following way: + +```diff +import { EntityTechdocsContent } from '@backstage/plugin-techdocs'; ++ import { TechDocsAddons } from '@backstage/plugin-techdocs-react'; ++ import { SomeAddon } from '@backstage/plugin-some-plugin'; + +// ... + + + + {overviewContent} + + + +- ++ ++ ++ ++ ++ + + + +// ... +``` + +If you do not wish to customize your TechDocs reader experience in this way at this time, no changes are necessary! diff --git a/cypress/fixtures/mkdocs.yml b/cypress/fixtures/mkdocs.yml index 2362fab898..071d147509 100644 --- a/cypress/fixtures/mkdocs.yml +++ b/cypress/fixtures/mkdocs.yml @@ -1,5 +1,8 @@ site_name: e2e Fixture Documentation site_description: Documentation used for end-to-end tests of TechDocs in Backstage. +repo_url: https://github.com/backstage/backstage +edit_uri: edit/master/cypress/fixtures/docs + nav: - Home: index.md - Sub-page 1: sub-page-one.md diff --git a/mkdocs.yml b/mkdocs.yml index 204a4768c3..5c73516ba6 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,5 +1,7 @@ site_name: 'Backstage' site_description: 'Main documentation for Backstage features and platform APIs' +repo_url: https://github.com/backstage/backstage +edit_uri: edit/master/docs plugins: - techdocs-core diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 9cbbcc86a9..ac0b9daa52 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -66,8 +66,8 @@ import { SearchPage } from '@backstage/plugin-search'; import { TechRadarPage } from '@backstage/plugin-tech-radar'; import { TechDocsIndexPage, - techdocsPlugin, TechDocsReaderPage, + techdocsPlugin, } from '@backstage/plugin-techdocs'; import { UserSettingsPage, @@ -87,6 +87,7 @@ import { defaultPreviewTemplate } from './components/scaffolder/defaultPreviewTe import { searchPage } from './components/search/SearchPage'; import { providers } from './identityProviders'; import * as plugins from './plugins'; + import { techDocsPage } from './components/techdocs/TechDocsPage'; import { ApacheAirflowPage } from '@backstage/plugin-apache-airflow'; import { PermissionedRoute } from '@backstage/plugin-permission-react'; diff --git a/packages/app/src/components/techdocs/TechDocsPage.tsx b/packages/app/src/components/techdocs/TechDocsPage.tsx index 4f49cf38e8..48a9812160 100644 --- a/packages/app/src/components/techdocs/TechDocsPage.tsx +++ b/packages/app/src/components/techdocs/TechDocsPage.tsx @@ -14,29 +14,20 @@ * limitations under the License. */ -import { Content } from '@backstage/core-components'; import { - TechDocsReaderPageHeader, TechDocsReaderPage, - Reader, + TechDocsReaderPageHeader, + TechDocsReaderPageSubheader, + TechDocsReaderPageContent, } from '@backstage/plugin-techdocs'; import React from 'react'; const DefaultTechDocsPage = () => { return ( - {({ techdocsMetadataValue, entityMetadataValue, entityRef, onReady }) => ( - <> - - - - - - )} + + + ); }; diff --git a/packages/integration/api-report.md b/packages/integration/api-report.md index cdb260f089..190278c99f 100644 --- a/packages/integration/api-report.md +++ b/packages/integration/api-report.md @@ -416,6 +416,12 @@ export function replaceGitHubUrlType( type: 'blob' | 'tree' | 'edit', ): string; +// @public +export function replaceGitLabUrlType( + url: string, + type: 'blob' | 'tree' | 'edit', +): string; + // @public export interface ScmIntegration { resolveEditUrl(url: string): string; diff --git a/packages/integration/src/gitlab/GitLabIntegration.test.ts b/packages/integration/src/gitlab/GitLabIntegration.test.ts index 01e42e0984..575ce75308 100644 --- a/packages/integration/src/gitlab/GitLabIntegration.test.ts +++ b/packages/integration/src/gitlab/GitLabIntegration.test.ts @@ -15,7 +15,7 @@ */ import { ConfigReader } from '@backstage/config'; -import { GitLabIntegration, replaceUrlType } from './GitLabIntegration'; +import { GitLabIntegration, replaceGitLabUrlType } from './GitLabIntegration'; describe('GitLabIntegration', () => { it('has a working factory', () => { @@ -55,28 +55,28 @@ describe('GitLabIntegration', () => { }); }); -describe('replaceUrlType', () => { +describe('replaceGitLabUrlType', () => { it('should replace with expected type', () => { expect( - replaceUrlType( + replaceGitLabUrlType( 'https://gitlab.com/my-org/my-project/-/blob/develop/README.md', 'edit', ), ).toBe('https://gitlab.com/my-org/my-project/-/edit/develop/README.md'); expect( - replaceUrlType( + replaceGitLabUrlType( 'https://gitlab.com/webmodules/blob/-/blob/develop/test', 'tree', ), ).toBe('https://gitlab.com/webmodules/blob/-/tree/develop/test'); expect( - replaceUrlType( + replaceGitLabUrlType( 'https://gitlab.com/blob/blob/-/blob/develop/test', 'tree', ), ).toBe('https://gitlab.com/blob/blob/-/tree/develop/test'); expect( - replaceUrlType( + replaceGitLabUrlType( 'https://gitlab.com/blob/blob/-/edit/develop/README.md', 'tree', ), diff --git a/packages/integration/src/gitlab/GitLabIntegration.ts b/packages/integration/src/gitlab/GitLabIntegration.ts index cb24829946..0c52799599 100644 --- a/packages/integration/src/gitlab/GitLabIntegration.ts +++ b/packages/integration/src/gitlab/GitLabIntegration.ts @@ -60,11 +60,18 @@ export class GitLabIntegration implements ScmIntegration { } resolveEditUrl(url: string): string { - return replaceUrlType(url, 'edit'); + return replaceGitLabUrlType(url, 'edit'); } } -export function replaceUrlType( +/** + * Takes a GitLab URL and replaces the type part (blob, tree etc). + * + * @param url - The original URL + * @param type - The desired type, e.g. 'blob', 'tree', 'edit' + * @public + */ +export function replaceGitLabUrlType( url: string, type: 'blob' | 'tree' | 'edit', ): string { diff --git a/packages/integration/src/gitlab/index.ts b/packages/integration/src/gitlab/index.ts index 950205d61c..e8d6665a6f 100644 --- a/packages/integration/src/gitlab/index.ts +++ b/packages/integration/src/gitlab/index.ts @@ -20,4 +20,4 @@ export { } from './config'; export type { GitLabIntegrationConfig } from './config'; export { getGitLabFileFetchUrl, getGitLabRequestOptions } from './core'; -export { GitLabIntegration } from './GitLabIntegration'; +export { GitLabIntegration, replaceGitLabUrlType } from './GitLabIntegration'; diff --git a/packages/techdocs-cli-embedded-app/package.json b/packages/techdocs-cli-embedded-app/package.json index 8771d5c14b..e4db841955 100644 --- a/packages/techdocs-cli-embedded-app/package.json +++ b/packages/techdocs-cli-embedded-app/package.json @@ -17,6 +17,7 @@ "@backstage/integration-react": "^1.0.1-next.1", "@backstage/plugin-catalog": "^1.1.0-next.1", "@backstage/plugin-techdocs": "^1.0.1-next.1", + "@backstage/plugin-techdocs-react": "^0.0.0", "@backstage/test-utils": "^1.0.1-next.1", "@backstage/theme": "^0.2.15", "@material-ui/core": "^4.11.0", diff --git a/packages/techdocs-cli-embedded-app/src/App.tsx b/packages/techdocs-cli-embedded-app/src/App.tsx index 51bdfbbb11..194d26943d 100644 --- a/packages/techdocs-cli-embedded-app/src/App.tsx +++ b/packages/techdocs-cli-embedded-app/src/App.tsx @@ -16,20 +16,27 @@ import React from 'react'; import { Navigate, Route } from 'react-router'; -import { createApp } from '@backstage/app-defaults'; -import { FlatRoutes } from '@backstage/core-app-api'; -import { CatalogEntityPage } from '@backstage/plugin-catalog'; import { DefaultTechDocsHome, TechDocsIndexPage, TechDocsReaderPage, + techdocsPlugin, } from '@backstage/plugin-techdocs'; +import { + createTechDocsAddonExtension, + TechDocsAddons, + TechDocsAddonLocations, +} from '@backstage/plugin-techdocs-react'; +import { createApp } from '@backstage/app-defaults'; +import { FlatRoutes } from '@backstage/core-app-api'; +import { CatalogEntityPage } from '@backstage/plugin-catalog'; + import { apis } from './apis'; -import { Root } from './components/Root'; -import { techDocsPage } from './components/TechDocsPage'; import * as plugins from './plugins'; import { configLoader } from './config'; +import { Root } from './components/Root'; +import { techDocsPage, TechDocsThemeToggle } from './components/TechDocsPage'; const app = createApp({ apis, @@ -40,6 +47,14 @@ const app = createApp({ const AppProvider = app.getProvider(); const AppRouter = app.getRouter(); +const ThemeToggleAddon = techdocsPlugin.provide( + createTechDocsAddonExtension({ + name: 'ThemeToggleAddon', + component: TechDocsThemeToggle, + location: TechDocsAddonLocations.Header, + }), +); + const routes = ( @@ -56,6 +71,9 @@ const routes = ( element={} > {techDocsPage} + + + ); diff --git a/packages/techdocs-cli-embedded-app/src/components/TechDocsPage/TechDocsPage.tsx b/packages/techdocs-cli-embedded-app/src/components/TechDocsPage/TechDocsPage.tsx index 56686dd081..c7328a0487 100644 --- a/packages/techdocs-cli-embedded-app/src/components/TechDocsPage/TechDocsPage.tsx +++ b/packages/techdocs-cli-embedded-app/src/components/TechDocsPage/TechDocsPage.tsx @@ -14,29 +14,20 @@ * limitations under the License. */ -import React, { - FC, - createContext, - useContext, - useState, - useCallback, -} from 'react'; +import React, { useState } from 'react'; import { Theme, makeStyles } from '@material-ui/core'; -import { ThemeProvider, Box, Tooltip, IconButton } from '@material-ui/core'; +import { Box, Tooltip, IconButton } from '@material-ui/core'; import LightIcon from '@material-ui/icons/Brightness7'; import DarkIcon from '@material-ui/icons/Brightness4'; -import { lightTheme, darkTheme } from '@backstage/theme'; -import { CompoundEntityRef } from '@backstage/catalog-model'; - -import { Content } from '@backstage/core-components'; +import { appThemeApiRef, useApi } from '@backstage/core-plugin-api'; import { - Reader, TechDocsReaderPage, TechDocsReaderPageHeader, + TechDocsReaderPageContent, } from '@backstage/plugin-techdocs'; const useStyles = makeStyles((theme: Theme) => ({ @@ -60,44 +51,12 @@ enum Themes { DARK = 'dark', } -type TechDocsThemeValue = { - theme: Themes; - toggleTheme: () => void; -}; - -const TechDocsThemeContext = createContext({ - theme: Themes.LIGHT, - toggleTheme: () => {}, -}); - -const TechdocsThemeProvider: FC = ({ children }) => { - const [theme, setTheme] = useState(Themes.LIGHT); - - const toggleTheme = useCallback(() => { - setTheme(prevTheme => - prevTheme === Themes.LIGHT ? Themes.DARK : Themes.LIGHT, - ); - }, [setTheme]); - - const value = { theme, toggleTheme }; - - const themes = { - [Themes.LIGHT]: lightTheme, - [Themes.DARK]: darkTheme, - }; - - return ( - - {children} - - ); -}; - -const useTechDocsTheme = () => useContext(TechDocsThemeContext); - -const TechDocsThemeToggle = () => { +export const TechDocsThemeToggle = () => { + const appThemeApi = useApi(appThemeApiRef); const classes = useStyles(); - const { theme, toggleTheme } = useTechDocsTheme(); + const [theme, setTheme] = useState( + (appThemeApi.getActiveThemeId() as Themes) || Themes.LIGHT, + ); const themes = { [Themes.LIGHT]: { @@ -112,10 +71,18 @@ const TechDocsThemeToggle = () => { const { title, icon: Icon } = themes[theme]; + const handleSetTheme = () => { + setTheme(prevTheme => { + const newTheme = prevTheme === Themes.LIGHT ? Themes.DARK : Themes.LIGHT; + appThemeApi.setActiveThemeId(newTheme); + return newTheme; + }); + }; + return ( - + @@ -123,47 +90,13 @@ const TechDocsThemeToggle = () => { ); }; -const TechDocsPageContent = ({ - onReady, - entityRef, -}: { - entityRef: CompoundEntityRef; - onReady: () => void; -}) => { - const classes = useStyles(); - - return ( - - - - ); -}; - const DefaultTechDocsPage = () => { - const techDocsMetadata = { - site_name: 'Live preview environment', - site_description: '', - }; - return ( - {({ entityRef, onReady }) => ( - <> - - - - - - )} + + ); }; -export const techDocsPage = ( - - - -); +export const techDocsPage = ; diff --git a/plugins/techdocs-backend/examples/documented-component/mkdocs.yml b/plugins/techdocs-backend/examples/documented-component/mkdocs.yml index 2087979d22..1a159a4d12 100644 --- a/plugins/techdocs-backend/examples/documented-component/mkdocs.yml +++ b/plugins/techdocs-backend/examples/documented-component/mkdocs.yml @@ -1,4 +1,6 @@ site_name: 'Example Documentation' +repo_url: https://github.com/backstage/backstage +edit_uri: edit/master/plugins/techdocs-backend/examples/documented-component/docs nav: - Home: index.md diff --git a/plugins/techdocs-react/.eslintrc.js b/plugins/techdocs-react/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/techdocs-react/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/techdocs-react/README.md b/plugins/techdocs-react/README.md new file mode 100644 index 0000000000..548ba8be5a --- /dev/null +++ b/plugins/techdocs-react/README.md @@ -0,0 +1,9 @@ +# @backstage/plugin-techdocs-react + +This package provides frontend utilities for TechDocs and Addons. + +## Installation + +```sh +yarn add --cwd packages/app @backstage/plugin-techdocs-react +``` diff --git a/plugins/techdocs-react/api-report.md b/plugins/techdocs-react/api-report.md new file mode 100644 index 0000000000..29bbf7234b --- /dev/null +++ b/plugins/techdocs-react/api-report.md @@ -0,0 +1,129 @@ +## API Report File for "@backstage/plugin-techdocs-react" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { ApiRef } from '@backstage/core-plugin-api'; +import { AsyncState } from 'react-use/lib/useAsync'; +import { ComponentType } from 'react'; +import { CompoundEntityRef } from '@backstage/catalog-model'; +import { Dispatch } from 'react'; +import { Entity } from '@backstage/catalog-model'; +import { Extension } from '@backstage/core-plugin-api'; +import { default as React_2 } from 'react'; +import { ReactNode } from 'react'; +import { SetStateAction } from 'react'; + +// @alpha +export function createTechDocsAddonExtension( + options: TechDocsAddonOptions, +): Extension>; + +// @alpha (undocumented) +export const defaultTechDocsReaderPageValue: TechDocsReaderPageValue; + +// @alpha +export const TECHDOCS_ADDONS_WRAPPER_KEY = 'techdocs.addons.wrapper.v1'; + +// @alpha +export const TechDocsAddonLocations: Readonly<{ + readonly Header: 'Header'; + readonly Subheader: 'Subheader'; + readonly PrimarySidebar: 'PrimarySidebar'; + readonly SecondarySidebar: 'SecondarySidebar'; + readonly Content: 'Content'; +}>; + +// @alpha +export type TechDocsAddonOptions = { + name: string; + location: keyof typeof TechDocsAddonLocations; + component: ComponentType; +}; + +// @alpha +export const TechDocsAddons: React_2.ComponentType; + +// @public +export interface TechDocsApi { + // (undocumented) + getApiOrigin(): Promise; + // (undocumented) + getEntityMetadata( + entityId: CompoundEntityRef, + ): Promise; + // (undocumented) + getTechDocsMetadata(entityId: CompoundEntityRef): Promise; +} + +// @public +export const techdocsApiRef: ApiRef; + +// @public +export type TechDocsEntityMetadata = Entity & { + locationMetadata?: { + type: string; + target: string; + }; +}; + +// @public +export type TechDocsMetadata = { + site_name: string; + site_description: string; +}; + +// @public +export const TechDocsReaderPageProvider: React_2.MemoExoticComponent< + ({ entityRef, children }: TechDocsReaderPageProviderProps) => JSX.Element +>; + +// @public +export type TechDocsReaderPageProviderProps = { + entityRef: CompoundEntityRef; + children: TechDocsReaderPageProviderRenderFunction | ReactNode; +}; + +// @public +export type TechDocsReaderPageProviderRenderFunction = ( + value: TechDocsReaderPageValue, +) => JSX.Element; + +// @public +export type TechDocsReaderPageValue = { + metadata: AsyncState; + entityRef: CompoundEntityRef; + entityMetadata: AsyncState; + shadowRoot?: ShadowRoot; + setShadowRoot: Dispatch>; + title: string; + setTitle: Dispatch>; + subtitle: string; + setSubtitle: Dispatch>; + onReady?: () => void; +}; + +// @alpha +export const useShadowRoot: () => ShadowRoot | undefined; + +// @alpha +export const useShadowRootElements: < + TReturnedElement extends HTMLElement = HTMLElement, +>( + selectors: string[], +) => TReturnedElement[]; + +// @alpha +export const useShadowRootSelection: (wait?: number) => Selection | null; + +// @alpha +export const useTechDocsAddons: () => { + renderComponentByName: (name: string) => JSX.Element | null; + renderComponentsByLocation: ( + location: keyof typeof TechDocsAddonLocations, + ) => (JSX.Element | null)[] | null; +}; + +// @alpha +export const useTechDocsReaderPage: () => TechDocsReaderPageValue; +``` diff --git a/plugins/techdocs-react/package.json b/plugins/techdocs-react/package.json new file mode 100644 index 0000000000..8ef881d406 --- /dev/null +++ b/plugins/techdocs-react/package.json @@ -0,0 +1,65 @@ +{ + "name": "@backstage/plugin-techdocs-react", + "description": "Shared frontend utilities for TechDocs and Addons", + "version": "0.0.0", + "private": false, + "publishConfig": { + "access": "public", + "alphaTypes": "dist/index.alpha.d.ts", + "main": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "backstage": { + "role": "web-library" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/techdocs-react" + }, + "keywords": [ + "backstage", + "techdocs" + ], + "license": "Apache-2.0", + "main": "src/index.ts", + "types": "src/index.ts", + "scripts": { + "build": "backstage-cli package build --experimental-type-build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack", + "clean": "backstage-cli package clean", + "start": "backstage-cli package start" + }, + "dependencies": { + "@backstage/catalog-model": "^1.0.1-next.1", + "@backstage/core-components": "^0.9.3-next.1", + "@backstage/core-plugin-api": "^1.0.0", + "@backstage/version-bridge": "^1.0.0", + "@material-ui/core": "^4.12.2", + "@material-ui/lab": "4.0.0-alpha.57", + "@material-ui/styles": "^4.11.0", + "jss": "~10.8.2", + "lodash": "^4.17.21", + "react-helmet": "6.1.0", + "react-router-dom": "6.0.0-beta.0", + "react-use": "^17.2.4" + }, + "peerDependencies": { + "@types/react": "^16.13.1 || ^17.0.0", + "react": "^16.13.1 || ^17.0.0" + }, + "devDependencies": { + "@testing-library/react": "^12.1.3", + "@testing-library/react-hooks": "^7.0.2", + "@backstage/test-utils": "^1.0.1-next.1", + "@backstage/theme": "^0.2.15" + }, + "files": [ + "alpha", + "dist" + ] +} diff --git a/plugins/techdocs-react/src/addons.tsx b/plugins/techdocs-react/src/addons.tsx new file mode 100644 index 0000000000..54d4f92e2f --- /dev/null +++ b/plugins/techdocs-react/src/addons.tsx @@ -0,0 +1,132 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { ComponentType, useCallback } from 'react'; +import { useOutlet } from 'react-router-dom'; + +import { + attachComponentData, + createReactExtension, + ElementCollection, + Extension, + useElementFilter, +} from '@backstage/core-plugin-api'; + +import { TechDocsAddonLocations, TechDocsAddonOptions } from './types'; + +export const TECHDOCS_ADDONS_KEY = 'techdocs.addons.addon.v1'; + +/** + * Marks the registry component. + * @alpha + */ +export const TECHDOCS_ADDONS_WRAPPER_KEY = 'techdocs.addons.wrapper.v1'; + +/** + * TechDocs Addon registry. + * @alpha + */ +export const TechDocsAddons: React.ComponentType = () => null; + +attachComponentData(TechDocsAddons, TECHDOCS_ADDONS_WRAPPER_KEY, true); + +const getDataKeyByName = (name: string) => { + return `${TECHDOCS_ADDONS_KEY}.${name.toLocaleLowerCase('en-US')}`; +}; + +/** + * Create a TechDocs addon. + * @alpha + */ +export function createTechDocsAddonExtension( + options: TechDocsAddonOptions, +): Extension> { + const { name, component: TechDocsAddon } = options; + return createReactExtension({ + name, + component: { + sync: (props: TComponentProps) => , + }, + data: { + [TECHDOCS_ADDONS_KEY]: options, + [getDataKeyByName(name)]: true, + }, + }); +} + +const getTechDocsAddonByName = ( + collection: ElementCollection, + key: string, +): JSX.Element | undefined => { + return collection.selectByComponentData({ key }).getElements()[0]; +}; + +const getAllTechDocsAddons = (collection: ElementCollection) => { + return collection + .selectByComponentData({ + key: TECHDOCS_ADDONS_WRAPPER_KEY, + }) + .selectByComponentData({ + key: TECHDOCS_ADDONS_KEY, + }); +}; + +const getAllTechDocsAddonsData = (collection: ElementCollection) => { + return collection + .selectByComponentData({ + key: TECHDOCS_ADDONS_WRAPPER_KEY, + }) + .findComponentData({ + key: TECHDOCS_ADDONS_KEY, + }); +}; + +/** + * hook to use addons in components + * @alpha + */ +export const useTechDocsAddons = () => { + const node = useOutlet(); + const collection = useElementFilter(node, getAllTechDocsAddons); + const options = useElementFilter(node, getAllTechDocsAddonsData); + + const findAddonByData = useCallback( + (data: TechDocsAddonOptions | undefined) => { + if (!collection || !data) return null; + const nameKey = getDataKeyByName(data.name); + return getTechDocsAddonByName(collection, nameKey) ?? null; + }, + [collection], + ); + + const renderComponentByName = useCallback( + (name: string) => { + const data = options.find(option => option.name === name); + return data ? findAddonByData(data) : null; + }, + [options, findAddonByData], + ); + + const renderComponentsByLocation = useCallback( + (location: keyof typeof TechDocsAddonLocations) => { + const data = options.filter(option => option.location === location); + return data.length ? data.map(findAddonByData) : null; + }, + [options, findAddonByData], + ); + + return { renderComponentByName, renderComponentsByLocation }; +}; diff --git a/plugins/techdocs-react/src/api.ts b/plugins/techdocs-react/src/api.ts new file mode 100644 index 0000000000..0d3c0b931a --- /dev/null +++ b/plugins/techdocs-react/src/api.ts @@ -0,0 +1,41 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { CompoundEntityRef } from '@backstage/catalog-model'; +import { createApiRef } from '@backstage/core-plugin-api'; +import { TechDocsEntityMetadata, TechDocsMetadata } from './types'; + +/** + * API to talk to techdocs-backend. + * + * @public + */ +export interface TechDocsApi { + getApiOrigin(): Promise; + getTechDocsMetadata(entityId: CompoundEntityRef): Promise; + getEntityMetadata( + entityId: CompoundEntityRef, + ): Promise; +} + +/** + * Utility API reference for the {@link TechDocsApi}. + * + * @public + */ +export const techdocsApiRef = createApiRef({ + id: 'plugin.techdocs.service', +}); diff --git a/plugins/techdocs-react/src/context.test.tsx b/plugins/techdocs-react/src/context.test.tsx new file mode 100644 index 0000000000..14669dc336 --- /dev/null +++ b/plugins/techdocs-react/src/context.test.tsx @@ -0,0 +1,126 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { renderHook, act } from '@testing-library/react-hooks'; + +import { ThemeProvider } from '@material-ui/core'; + +import { lightTheme } from '@backstage/theme'; +import { TestApiProvider } from '@backstage/test-utils'; +import { Entity, CompoundEntityRef } from '@backstage/catalog-model'; + +import { techdocsApiRef } from './api'; +import { useTechDocsReaderPage, TechDocsReaderPageProvider } from './context'; +import { TechDocsMetadata } from './types'; + +const mockShadowRoot = () => { + const div = document.createElement('div'); + const shadowRoot = div.attachShadow({ mode: 'open' }); + shadowRoot.innerHTML = '

Shadow DOM Mock

'; + return shadowRoot; +}; + +const mockEntityMetadata: Entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'test', + namespace: 'default', + }, + spec: { + owner: 'test', + }, +}; + +const mockTechDocsMetadata: TechDocsMetadata = { + site_name: 'test-componnet', + site_description: 'this is a test component', +}; + +const techdocsApiMock = { + getEntityMetadata: jest.fn().mockResolvedValue(mockEntityMetadata), + getTechDocsMetadata: jest.fn().mockResolvedValue(mockTechDocsMetadata), +}; + +const wrapper = ({ + entityRef = { + kind: mockEntityMetadata.kind, + name: mockEntityMetadata.metadata.name, + namespace: mockEntityMetadata.metadata.namespace!!, + }, + children, +}: { + entityRef?: CompoundEntityRef; + children: React.ReactNode; +}) => ( + + + + {children} + + + +); + +describe('useTechDocsReaderPage', () => { + it('should set title', async () => { + const { result, waitForNextUpdate } = renderHook( + () => useTechDocsReaderPage(), + { wrapper }, + ); + + expect(result.current.title).toBe(''); + + act(() => result.current.setTitle('test site title')); + + await waitForNextUpdate(); + + expect(result.current.title).toBe('test site title'); + }); + + it('should set subtitle', async () => { + const { result, waitForNextUpdate } = renderHook( + () => useTechDocsReaderPage(), + { wrapper }, + ); + + expect(result.current.subtitle).toBe(''); + + act(() => result.current.setSubtitle('test site subtitle')); + + await waitForNextUpdate(); + + expect(result.current.subtitle).toBe('test site subtitle'); + }); + + it('should set shadow root', async () => { + const { result, waitForNextUpdate } = renderHook( + () => useTechDocsReaderPage(), + { wrapper }, + ); + + // mock shadowroot + const shadowRoot = mockShadowRoot(); + + act(() => result.current.setShadowRoot(shadowRoot)); + + await waitForNextUpdate(); + + expect(result.current.shadowRoot?.innerHTML).toBe( + '

Shadow DOM Mock

', + ); + }); +}); diff --git a/plugins/techdocs-react/src/context.tsx b/plugins/techdocs-react/src/context.tsx new file mode 100644 index 0000000000..67bf416818 --- /dev/null +++ b/plugins/techdocs-react/src/context.tsx @@ -0,0 +1,171 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { + Dispatch, + SetStateAction, + useContext, + useState, + memo, + ReactNode, +} from 'react'; +import useAsync, { AsyncState } from 'react-use/lib/useAsync'; + +import { + CompoundEntityRef, + stringifyEntityRef, +} from '@backstage/catalog-model'; +import { + createVersionedContext, + createVersionedValueMap, +} from '@backstage/version-bridge'; + +import { useApi } from '@backstage/core-plugin-api'; + +import { techdocsApiRef } from './api'; +import { TechDocsEntityMetadata, TechDocsMetadata } from './types'; + +const areEntityRefsEqual = ( + prevEntityRef: CompoundEntityRef, + nextEntityRef: CompoundEntityRef, +) => { + return ( + stringifyEntityRef(prevEntityRef) === stringifyEntityRef(nextEntityRef) + ); +}; + +/** + * @public type for the value of the TechDocsReaderPageContext + */ +export type TechDocsReaderPageValue = { + metadata: AsyncState; + entityRef: CompoundEntityRef; + entityMetadata: AsyncState; + shadowRoot?: ShadowRoot; + setShadowRoot: Dispatch>; + title: string; + setTitle: Dispatch>; + subtitle: string; + setSubtitle: Dispatch>; + /** + * @deprecated property can be passed down directly to the `TechDocsReaderPageContent` instead. + */ + onReady?: () => void; +}; + +/** + * @alpha + */ +export const defaultTechDocsReaderPageValue: TechDocsReaderPageValue = { + title: '', + subtitle: '', + setTitle: () => {}, + setSubtitle: () => {}, + setShadowRoot: () => {}, + metadata: { loading: true }, + entityMetadata: { loading: true }, + entityRef: { kind: '', name: '', namespace: '' }, +}; + +const TechDocsReaderPageContext = createVersionedContext<{ + 1: TechDocsReaderPageValue; +}>('techdocs-reader-page-context'); + +/** + * render function for {@link TechDocsReaderPageProvider} + * + * @public + */ +export type TechDocsReaderPageProviderRenderFunction = ( + value: TechDocsReaderPageValue, +) => JSX.Element; + +/** + * Props for {@link TechDocsReaderPageProvider} + * + * @public + */ +export type TechDocsReaderPageProviderProps = { + entityRef: CompoundEntityRef; + children: TechDocsReaderPageProviderRenderFunction | ReactNode; +}; + +/** + * A context to store the reader page state + * @public + */ +export const TechDocsReaderPageProvider = memo( + ({ entityRef, children }: TechDocsReaderPageProviderProps) => { + const techdocsApi = useApi(techdocsApiRef); + + const metadata = useAsync(async () => { + return techdocsApi.getTechDocsMetadata(entityRef); + }, [entityRef]); + + const entityMetadata = useAsync(async () => { + return techdocsApi.getEntityMetadata(entityRef); + }, [entityRef]); + + const [title, setTitle] = useState(defaultTechDocsReaderPageValue.title); + const [subtitle, setSubtitle] = useState( + defaultTechDocsReaderPageValue.subtitle, + ); + const [shadowRoot, setShadowRoot] = useState( + defaultTechDocsReaderPageValue.shadowRoot, + ); + + const value = { + metadata, + entityRef, + entityMetadata, + shadowRoot, + setShadowRoot, + title, + setTitle, + subtitle, + setSubtitle, + }; + const versionedValue = createVersionedValueMap({ 1: value }); + + return ( + + {children instanceof Function ? children(value) : children} + + ); + }, + (prevProps, nextProps) => { + return areEntityRefsEqual(prevProps.entityRef, nextProps.entityRef); + }, +); + +/** + * Hook used to get access to shared state between reader page components. + * @alpha + */ +export const useTechDocsReaderPage = () => { + const versionedContext = useContext(TechDocsReaderPageContext); + + if (versionedContext === undefined) { + return defaultTechDocsReaderPageValue; + } + + const context = versionedContext.atVersion(1); + if (context === undefined) { + throw new Error('No context found for version 1.'); + } + + return context; +}; diff --git a/plugins/techdocs-react/src/hooks.test.ts b/plugins/techdocs-react/src/hooks.test.ts new file mode 100644 index 0000000000..2aa53b32d5 --- /dev/null +++ b/plugins/techdocs-react/src/hooks.test.ts @@ -0,0 +1,103 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + useShadowRoot, + useShadowRootElements, + useShadowRootSelection, +} from './hooks'; +import { renderHook } from '@testing-library/react-hooks'; +import { fireEvent, waitFor } from '@testing-library/react'; + +const fireSelectionChangeEvent = (window: Window) => { + const selectionChangeEvent = window.document.createEvent('Event'); + selectionChangeEvent.initEvent('selectionchange', true, true); + window.document.addEventListener('selectionchange', () => {}, false); + fireEvent(window.document, selectionChangeEvent); +}; + +const getSelection = jest.fn(); + +const mockShadowRoot = () => { + const div = document.createElement('div'); + const shadowRoot = div.attachShadow({ mode: 'open' }); + shadowRoot.innerHTML = '

Shadow DOM Mock

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