diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json index b4868ab4e7..6716a1a2c5 100644 --- a/plugins/techdocs/package.json +++ b/plugins/techdocs/package.json @@ -45,7 +45,7 @@ "@backstage/plugin-catalog-react": "^1.0.1-next.2", "@backstage/plugin-catalog": "^0.10.0", "@backstage/plugin-search": "^0.7.5-next.0", - "@backstage/plugin-techdocs-addons": "^0.0.0", + "@backstage/techdocs-addons": "^0.0.0", "@backstage/theme": "^0.2.15", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", @@ -54,7 +54,9 @@ "dompurify": "^2.2.9", "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", diff --git a/plugins/techdocs/src/EntityPageDocs.tsx b/plugins/techdocs/src/EntityPageDocs.tsx index ba64ca2c78..50f44b3773 100644 --- a/plugins/techdocs/src/EntityPageDocs.tsx +++ b/plugins/techdocs/src/EntityPageDocs.tsx @@ -14,65 +14,20 @@ * limitations under the License. */ -import React, { PropsWithChildren } from 'react'; -import { - CompoundEntityRef, - DEFAULT_NAMESPACE, - Entity, -} from '@backstage/catalog-model'; -import { - Reader, - useTechDocsReaderDom, - withTechDocsReaderProvider, -} from './reader'; +import React from 'react'; + +import { configApiRef, useApi } from '@backstage/core-plugin-api'; +import { DEFAULT_NAMESPACE, Entity } from '@backstage/catalog-model'; + import { toLowerMaybe } from './helpers'; -import { - configApiRef, - getComponentData, - useApi, -} from '@backstage/core-plugin-api'; -import { - TechDocsReaderPage as AddonAwareReaderPage, - TECHDOCS_ADDONS_WRAPPER_KEY, -} from '@backstage/plugin-techdocs-addons'; -import { AsyncState } from 'react-use/lib/useAsyncFn'; -import { TechDocsEntityMetadata } from './types'; -import { techdocsApiRef } from '.'; -import useAsync from 'react-use/lib/useAsync'; +import { TechDocsReaderPage } from './plugin'; +import { TechDocsReaderLayout } from './reader'; -type SpecialReaderPageProps = { - entityName: CompoundEntityRef; - asyncEntityMetadata: AsyncState; - addonConfig?: React.ReactNode; -}; +type EntityPageDocsProps = { entity: Entity }; -// todo(backstage/techdocs-core): Combine with and simplify -// with the version in TechDocsReaderPage.tsx -const SpecialReaderPage = (props: SpecialReaderPageProps) => { - const techdocsApi = useApi(techdocsApiRef); - const dom = useTechDocsReaderDom(props.entityName); - const { kind, namespace, name } = props.entityName; - - const asyncTechDocsMetadata = useAsync(() => { - return techdocsApi.getTechDocsMetadata({ kind, namespace, name }); - }, [kind, namespace, name, techdocsApi]); - - return ( - - ); -}; - -export const EntityPageDocs = ({ - children, - entity, -}: PropsWithChildren<{ entity: Entity }>) => { +export const EntityPageDocs = ({ entity }: EntityPageDocsProps) => { const config = useApi(configApiRef); + const entityName = { namespace: toLowerMaybe( entity.metadata.namespace ?? DEFAULT_NAMESPACE, @@ -82,22 +37,9 @@ export const EntityPageDocs = ({ name: toLowerMaybe(entity.metadata.name, config), }; - // Check if we were given a set of TechDocs addons. - if (children && getComponentData(children, TECHDOCS_ADDONS_WRAPPER_KEY)) { - const Component = withTechDocsReaderProvider(SpecialReaderPage, entityName); - return ( - - ); - } - - // Otherwise, return a version of the reader that is not addon-aware. - return ; + return ( + + + + ); }; diff --git a/plugins/techdocs/src/Router.tsx b/plugins/techdocs/src/Router.tsx index 0a2c569097..40308e3379 100644 --- a/plugins/techdocs/src/Router.tsx +++ b/plugins/techdocs/src/Router.tsx @@ -67,10 +67,9 @@ export const EmbeddedDocsRouter = (props: PropsWithChildren<{}>) => { return ( - {children}} - /> + }> + {children} + ); }; 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 6e8609694d..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'; - -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 55637806a3..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'; - -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 e51f5272eb..0000000000 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPage.tsx +++ /dev/null @@ -1,140 +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, { AsyncState } from 'react-use/lib/useAsync'; -import { techdocsApiRef } from '../../api'; -import { TechDocsEntityMetadata, TechDocsMetadata } from '../../types'; -import { CompoundEntityRef } from '@backstage/catalog-model'; -import { getComponentData, useApi, useApp } from '@backstage/core-plugin-api'; -import { Page } from '@backstage/core-components'; -import { - TechDocsReaderPage as AddonAwareReaderPage, - TECHDOCS_ADDONS_WRAPPER_KEY, -} from '@backstage/plugin-techdocs-addons'; -import { useTechDocsReaderDom, withTechDocsReaderProvider } from './Reader'; - -/** - * 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; - -type SpecialReaderPageProps = { - entityName: CompoundEntityRef; - asyncEntityMetadata: AsyncState; - asyncTechDocsMetadata: AsyncState; -}; - -const SpecialReaderPage = (props: SpecialReaderPageProps) => { - const dom = useTechDocsReaderDom(props.entityName); - - return ( - - ); -}; - -/** - * 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 asyncTechDocsMetadata = useAsync(() => { - if (documentReady) { - return techdocsApi.getTechDocsMetadata({ kind, namespace, name }); - } - - return Promise.resolve(undefined); - }, [kind, namespace, name, techdocsApi, documentReady]); - - const asyncEntityMetadata = useAsync(() => { - return techdocsApi.getEntityMetadata({ kind, namespace, name }); - }, [kind, namespace, name, techdocsApi]); - - const onReady = useCallback(() => { - setDocumentReady(true); - }, [setDocumentReady]); - - if (asyncEntityMetadata.error) return ; - - if (!children) { - if (outlet) { - // If the outlet is a single child and that child is an instance of the - // TechDocsAddons registry, then render it a certain way. - if ( - getComponentData(outlet.props.children, TECHDOCS_ADDONS_WRAPPER_KEY) - ) { - const Component = withTechDocsReaderProvider(SpecialReaderPage, { - kind, - namespace, - name, - }); - return ( - - ); - } - // Otherwise, just return the outlet (legacy-style composability). - return outlet; - } - } - - return ( - - {children instanceof Function - ? children({ - techdocsMetadataValue: asyncTechDocsMetadata.value, - entityMetadataValue: asyncEntityMetadata.value, - entityRef: { kind, namespace, name }, - onReady, - }) - : children} - - ); -}; 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..f49f7b74c1 --- /dev/null +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx @@ -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 React, { ReactNode, useMemo } from 'react'; +import { useParams } from 'react-router-dom'; + +import { CompoundEntityRef } from '@backstage/catalog-model'; + +import { TechDocsReaderPageContent } from '../TechDocsReaderPageContent'; +import { TechDocsReaderPageHeader } from '../TechDocsReaderPageHeader'; +import { TechDocsReaderPageSubheader } from '../TechDocsReaderPageSubheader'; + +import { TechDocsReaderPageRenderFunction } from '../../../types'; + +import { + TechDocsEntityProvider, + TechDocsMetadataProvider, + TechDocsReaderPageProvider, +} from './context'; + +export type TechDocsReaderLayoutProps = { + hideHeader?: boolean; +}; + +export const TechDocsReaderLayout = ({ + hideHeader = false, +}: TechDocsReaderLayoutProps) => ( + <> + {!hideHeader && } + + + +); + +/** + * @public + */ +export type TechDocsReaderPageProps = { + path?: string; + entityName?: CompoundEntityRef; + children?: TechDocsReaderPageRenderFunction | ReactNode; +}; + +/** + * An addon-aware implementation of the TechDocsReaderPage. + * @public + */ +export const TechDocsReaderPage = ({ + path: defaultPath, + entityName: defaultEntityName, + children = , +}: TechDocsReaderPageProps) => { + const params = useParams(); + + const path = useMemo(() => { + if (defaultPath) { + return defaultPath; + } + return params['*'] ?? ''; + }, [params, defaultPath]); + + const entityName = useMemo(() => { + if (defaultEntityName) { + return defaultEntityName; + } + return { + kind: params.kind, + name: params.name, + namespace: params.namespace, + }; + }, [params, defaultEntityName]); + + return ( + + + + {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..921c2efd7d --- /dev/null +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage/context.test.tsx @@ -0,0 +1,146 @@ +/* + * 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 { TechDocsMetadata } from './types'; +import { + useEntityMetadata, + useTechDocsMetadata, + useTechDocsReaderPage, + TechDocsEntityProvider, + TechDocsMetadataProvider, + TechDocsReaderPageProvider, +} from './context'; +import { renderHook, act } from '@testing-library/react-hooks'; + +import { Entity, CompoundEntityRef } from '@backstage/catalog-model'; + +const mockEntity: Entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { name: 'test-component', namespace: 'default' }, +}; + +const mockTechDocsMetadata: TechDocsMetadata = { + site_name: 'test-componnet', + site_description: 'this is a test component', +}; + +const mockShadowRoot = () => { + const div = document.createElement('div'); + const shadowRoot = div.attachShadow({ mode: 'open' }); + shadowRoot.innerHTML = '

Shadow DOM Mock

'; + return shadowRoot; +}; + +const wrapper = ({ + entityName = { + namespace: mockEntity.metadata.namespace!!, + kind: mockEntity.kind, + name: mockEntity.metadata.name, + }, + children, +}: { + entityName: CompoundEntityRef; + children: React.ReactNode; +}) => ( + + + + {children} + + + +); + +describe('context', () => { + 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 } = renderHook(() => useEntityMetadata(), { wrapper }); + + expect(result.current.value).toBeDefined(); + expect(result.current.error).toBeUndefined(); + expect(result.current.value).toMatchObject(mockEntity); + }); + }); + + 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 } = renderHook(() => useTechDocsMetadata(), { wrapper }); + + expect(result.current.value).toBeDefined(); + expect(result.current.error).toBeUndefined(); + expect(result.current.value).toMatchObject(mockTechDocsMetadata); + }); + }); + + describe('useTechDocsReaderPage', () => { + it('should set title', () => { + const { result } = renderHook(() => useTechDocsReaderPage(), { wrapper }); + + expect(result.current.title).toBe(''); + + act(() => result.current.setTitle('test site title')); + expect(result.current.title).toBe('test site title'); + }); + + it('should set subtitle', () => { + const { result } = renderHook(() => useTechDocsReaderPage(), { wrapper }); + + expect(result.current.subtitle).toBe(''); + + act(() => result.current.setSubtitle('test site subtitle')); + expect(result.current.subtitle).toBe('test site subtitle'); + }); + + it('should set shadow root', async () => { + const { result } = renderHook(() => useTechDocsReaderPage(), { wrapper }); + + // mock shadowroot + const shadowRoot = mockShadowRoot(); + + act(() => result.current.setShadowRoot(shadowRoot)); + + expect(result.current.shadowRoot?.innerHTML).toBe( + '

Shadow DOM Mock

', + ); + }); + }); +}); 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..0c580d17cd --- /dev/null +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage/context.tsx @@ -0,0 +1,175 @@ +/* + * 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, { + createContext, + Dispatch, + PropsWithChildren, + SetStateAction, + useContext, + useState, +} from 'react'; +import useAsync, { AsyncState } from 'react-use/lib/useAsync'; + +import { Page } from '@backstage/core-components'; +import { useApi } from '@backstage/core-plugin-api'; +import { CompoundEntityRef } from '@backstage/catalog-model'; + +import { techdocsApiRef } from '../../../api'; +import { TechDocsEntityMetadata, TechDocsMetadata } from '../../../types'; + +type PropsWithEntityName = PropsWithChildren< + T & { entityName: CompoundEntityRef } +>; + +const initialContextValue = { + loading: true, + error: undefined, + value: undefined, +}; + +const TechDocsMetadataContext = + createContext>(initialContextValue); + +export const TechDocsMetadataProvider = ({ + entityName, + children, +}: PropsWithEntityName) => { + const techdocsApi = useApi(techdocsApiRef); + + const value = useAsync(async () => { + return techdocsApi.getTechDocsMetadata(entityName); + }, [entityName]); + + return ( + + {children} + + ); +}; + +/** + * Hook for use within TechDocs addons to retrieve TechDocs Metadata for the + * current TechDocs site. + * @public + */ +export const useTechDocsMetadata = () => { + return useContext(TechDocsMetadataContext); +}; + +const TechDocsEntityContext = + createContext>(initialContextValue); + +export const TechDocsEntityProvider = ({ + entityName, + children, +}: PropsWithEntityName) => { + const techdocsApi = useApi(techdocsApiRef); + + const value = useAsync(async () => { + return techdocsApi.getEntityMetadata(entityName); + }, [entityName]); + + return ( + + {children} + + ); +}; + +/** + * Hook for use within TechDocs addons to retrieve Entity Metadata for the + * current TechDocs site. + * @public + */ +export const useEntityMetadata = () => { + return useContext(TechDocsEntityContext); +}; + +export type TechDocsReaderPageValue = { + path: string; + entityName: CompoundEntityRef; + shadowRoot?: ShadowRoot; + setShadowRoot: Dispatch>; + title: string; + setTitle: Dispatch>; + subtitle: string; + setSubtitle: Dispatch>; +}; + +export const defaultTechDocsReaderPageValue: TechDocsReaderPageValue = { + path: '', + title: '', + setTitle: () => {}, + subtitle: '', + setSubtitle: () => {}, + setShadowRoot: () => {}, + entityName: { kind: '', name: '', namespace: '' }, +}; + +export const TechDocsReaderPageContext = createContext( + defaultTechDocsReaderPageValue, +); + +export const useTechDocsReaderPage = () => { + return useContext(TechDocsReaderPageContext); +}; + +type TechDocsReaderPageProviderProps = PropsWithEntityName<{ + path: string; +}>; + +export const TechDocsReaderPageProvider = ({ + path, + entityName, + children, +}: TechDocsReaderPageProviderProps) => { + const metadata = useTechDocsMetadata(); + const entityMetadata = useEntityMetadata(); + + const [title, setTitle] = useState(defaultTechDocsReaderPageValue.title); + const [subtitle, setSubtitle] = useState( + defaultTechDocsReaderPageValue.subtitle, + ); + const [shadowRoot, setShadowRoot] = useState( + defaultTechDocsReaderPageValue.shadowRoot, + ); + + const value = { + path, + entityName, + shadowRoot, + setShadowRoot, + title, + setTitle, + subtitle, + setSubtitle, + }; + + return ( + + + {children instanceof Function + ? children({ + entityRef: entityName, + techdocsMetadataValue: metadata.value, + entityMetadataValue: entityMetadata.value, + }) + : children} + + + ); +}; diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage/hooks.test.ts b/plugins/techdocs/src/reader/components/TechDocsReaderPage/hooks.test.ts new file mode 100644 index 0000000000..c85d3a3d11 --- /dev/null +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage/hooks.test.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. + */ + +import { useShadowRoot, useShadowRootElements } from './hooks'; +import { renderHook } from '@testing-library/react-hooks'; + +const mockShadowRoot = () => { + const div = document.createElement('div'); + const shadowRoot = div.attachShadow({ mode: 'open' }); + shadowRoot.innerHTML = '

Shadow DOM Mock

'; + return shadowRoot; +}; + +const shadowRoot = mockShadowRoot(); + +jest.mock('./context', () => { + return { + useTechDocsReaderPage: () => ({ shadowRoot }), + }; +}); + +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); + }); + }); +}); diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage/hooks.ts b/plugins/techdocs/src/reader/components/TechDocsReaderPage/hooks.ts new file mode 100644 index 0000000000..7bc6152006 --- /dev/null +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage/hooks.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. + */ +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. + * @public + */ +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. + * + * todo(backstage/techdocs-core): Consider extending `selectors` from string[] + * to some kind of typed object array, so users have more control over the + * shape of the result. e.g. a flag to indicate querySelector vs. + * querySelectorAll. + * + * @public + */ +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(); +}; 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..10749dedeb --- /dev/null +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage/index.ts @@ -0,0 +1,23 @@ +/* + * 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'; +export * from './context'; +export * from './hooks'; 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..0feeb65e12 --- /dev/null +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.tsx @@ -0,0 +1,136 @@ +/* + * 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, { useEffect, useRef, useState } 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, +} from '@backstage/techdocs-addons'; +import { Content, Progress } from '@backstage/core-components'; + +import { TechDocsSearch } from '../../../search'; +import { useTechDocsReaderPage } from '../TechDocsReaderPage'; +import { TechDocsStateIndicator } from '../TechDocsStateIndicator'; +import { useTechDocsReaderDom, withTechDocsReaderProvider } from './context'; + +const useStyles = makeStyles({ + search: { + width: '100%', + '@media (min-width: 76.1875em)': { + width: 'calc(100% - 34.4rem)', + margin: '0 auto', + }, + }, +}); + +export type TechDocsReaderPageContentProps = { + withSearch?: boolean; +}; + +export const TechDocsReaderPageContent = withTechDocsReaderProvider( + ({ withSearch = true }: TechDocsReaderPageContentProps) => { + const classes = useStyles(); + const addons = useTechDocsAddons(); + const page = useTechDocsReaderPage(); + const dom = useTechDocsReaderDom(page.entityName); + + const ref = useRef(null); + const [jss, setJss] = useState( + create({ + ...jssPreset(), + insertionPoint: undefined, + }), + ); + + useEffect(() => { + const shadowHost = ref.current; + if (!dom || !shadowHost) return; + + setJss( + create({ + ...jssPreset(), + insertionPoint: dom.querySelector('head') || undefined, + }), + ); + + const shadowRoot = + shadowHost.shadowRoot ?? shadowHost.attachShadow({ mode: 'open' }); + shadowRoot.innerHTML = ''; + shadowRoot.appendChild(dom); + page.setShadowRoot(shadowRoot); + }, [dom, page]); + + const contentElement = ref.current?.shadowRoot?.querySelector( + '[data-md-component="container"]', + ); + const primarySidebarElement = ref.current?.shadowRoot?.querySelector( + 'div[data-md-component="sidebar"][data-md-type="navigation"], div[data-md-component="navigation"]', + ); + const secondarySidebarElement = ref.current?.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.PRIMARY_SIDEBAR)} + + + {addons.renderComponentsByLocation(locations.CONTENT)} + + + {addons.renderComponentsByLocation(locations.SECONDARY_SIDEBAR)} + + + + + + ); + }, +); 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..007b1fcc65 --- /dev/null +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/context.tsx @@ -0,0 +1,856 @@ +/* + * 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, { + FC, + ComponentType, + createContext, + useContext, + useCallback, + useEffect, + useState, +} from 'react'; +import { useNavigate } from 'react-router-dom'; + +import { useTheme, Theme, lighten, alpha } from '@material-ui/core'; + +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 { + addBaseUrl, + addGitFeedbackLink, + addLinkClickListener, + addSidebarToggle, + injectCss, + onCssReady, + removeMkdocsHeader, + rewriteDocLinks, + sanitizeDOM, + simplifyMkdocsFooter, + scrollIntoAnchor, + transform as transformer, + copyToClipboard, +} from '../../transformers'; + +import { useReaderState } from '../useReaderState'; +import { useTechDocsReaderPage } from '../TechDocsReaderPage'; + +/** + * Props for {@link Reader} + * + * @public + */ +export type ReaderProps = { + entityRef: CompoundEntityRef; + withSearch?: boolean; + onReady?: () => void; +}; + +type TechDocsReaderValue = ReturnType; + +const TechDocsReaderContext = createContext( + {} as TechDocsReaderValue, +); + +export const TechDocsReaderProvider: FC = ({ children }) => { + const { path, entityName } = useTechDocsReaderPage(); + const { kind, namespace, name } = entityName; + 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) => + (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 .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); + // 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; +}; 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..d3da3d6aa4 --- /dev/null +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/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 { TechDocsReaderPageContent } from './TechDocsReaderPageContent'; +export * from './context'; diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader.test.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.test.tsx similarity index 100% rename from plugins/techdocs/src/reader/components/TechDocsReaderPageHeader.test.tsx rename to plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.test.tsx diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.tsx similarity index 56% rename from plugins/techdocs/src/reader/components/TechDocsReaderPageHeader.tsx rename to plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.tsx index f6a3352e7d..b1ca6ce50a 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,62 @@ * limitations under the License. */ -import React, { PropsWithChildren } from 'react'; +import React, { FC, 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, +} from '@backstage/techdocs-addons'; import { EntityRefLink, EntityRefLinks, getEntityRelations, } from '@backstage/plugin-catalog-react'; +import { RELATION_OWNED_BY } 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 { + useTechDocsReaderPage, + useTechDocsMetadata, + useEntityMetadata, +} from '../TechDocsReaderPage'; -/** - * Props for {@link TechDocsReaderPageHeader} - * - * @public - */ -export type TechDocsReaderPageHeaderProps = PropsWithChildren<{ - entityRef: CompoundEntityRef; - entityMetadata?: TechDocsEntityMetadata; - techDocsMetadata?: TechDocsMetadata; -}>; +import { rootRouteRef } from '../../../routes'; -/** - * Component responsible for rendering a Header with metadata on TechDocs reader page. - * - * @public - */ -export const TechDocsReaderPageHeader = ( - props: TechDocsReaderPageHeaderProps, -) => { - const { entityRef, entityMetadata, techDocsMetadata, children } = props; - const { name } = entityRef; +const skeleton = ; - const { site_name: siteName, site_description: siteDescription } = - techDocsMetadata || {}; +export const TechDocsReaderPageHeader: FC = props => { + const { children } = props; + const addons = useTechDocsAddons(); + const configApi = useApi(configApiRef); + + const { value: techDocsMetadata } = useTechDocsMetadata(); + const { value: entityMetadata } = useEntityMetadata(); + + const { + title, + setTitle, + subtitle, + setSubtitle, + entityName: entityRef, + } = useTechDocsReaderPage(); + + useEffect(() => { + if (!techDocsMetadata) return; + setTitle(prevTitle => prevTitle || techDocsMetadata.site_name); + setSubtitle( + prevSubtitle => + prevSubtitle || techDocsMetadata.site_description || 'Home', + ); + }, [techDocsMetadata, 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 +126,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..741a8e9af1 --- /dev/null +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/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 { TechDocsReaderPageHeader } 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..45a89d0c84 --- /dev/null +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageSubheader/TechDocsReaderPageSubheader.tsx @@ -0,0 +1,52 @@ +/* + * 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/techdocs-addons'; + +export const TechDocsReaderPageSubheader = withStyles(theme => ({ + root: { + gridArea: 'pageSubheader', + flexDirection: 'column', + minHeight: 'auto', + padding: theme.spacing(3, 3, 0), + }, +}))(({ ...rest }: ToolbarProps) => { + const addons = useTechDocsAddons(); + + if (!addons.renderComponentsByLocation(locations.SUBHEADER)) return null; + + return ( + + {addons.renderComponentsByLocation(locations.SUBHEADER) && ( + + {addons.renderComponentsByLocation(locations.SUBHEADER)} + + )} + + ); +}); 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..f6294200a3 100644 --- a/plugins/techdocs/src/reader/components/index.ts +++ b/plugins/techdocs/src/reader/components/index.ts @@ -14,23 +14,18 @@ * limitations under the License. */ -export * from './Reader'; export type { TechDocsReaderPageProps, - TechDocsReaderPageRenderFunction, + TechDocsReaderLayoutProps, +} from './TechDocsReaderPage'; +export { + TechDocsReaderLayout, + useTechDocsMetadata, + useEntityMetadata, + useTechDocsReaderPage, + useShadowRoot, + useShadowRootElements, } from './TechDocsReaderPage'; export * from './TechDocsReaderPageHeader'; +export * from './TechDocsReaderPageContent'; 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 - */ diff --git a/plugins/techdocs/src/types.ts b/plugins/techdocs/src/types.ts index ee6b88756c..20f78c01f3 100644 --- a/plugins/techdocs/src/types.ts +++ b/plugins/techdocs/src/types.ts @@ -14,7 +14,22 @@ * limitations under the License. */ -import { Entity } from '@backstage/catalog-model'; +import { Entity, CompoundEntityRef } from '@backstage/catalog-model'; + +/** + * 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; +}) => JSX.Element; /** * Metadata for TechDocs page