diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx index 0ba0b47458..b55f42e984 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx @@ -14,34 +14,33 @@ * limitations under the License. */ -import React, { ReactNode, useMemo } from 'react'; +import React, { ReactNode } from 'react'; import { useParams } from 'react-router-dom'; +import { Page } from '@backstage/core-components'; import { CompoundEntityRef } from '@backstage/catalog-model'; +import { TechDocsReaderPageRenderFunction } from '../../../types'; + import { TechDocsReaderPageContent } from '../TechDocsReaderPageContent'; import { TechDocsReaderPageHeader } from '../TechDocsReaderPageHeader'; import { TechDocsReaderPageSubheader } from '../TechDocsReaderPageSubheader'; -import { TechDocsReaderPageRenderFunction } from '../../../types'; - -import { - TechDocsEntityProvider, - TechDocsMetadataProvider, - TechDocsReaderPageProvider, -} from './context'; +import { TechDocsReaderPageProvider } from './context'; export type TechDocsReaderLayoutProps = { hideHeader?: boolean; + withSearch?: boolean; }; export const TechDocsReaderLayout = ({ hideHeader = false, + withSearch, }: TechDocsReaderLayoutProps) => ( <> {!hideHeader && } - + ); @@ -49,7 +48,6 @@ export const TechDocsReaderLayout = ({ * @public */ export type TechDocsReaderPageProps = { - path?: string; entityName?: CompoundEntityRef; children?: TechDocsReaderPageRenderFunction | ReactNode; }; @@ -59,37 +57,26 @@ export type TechDocsReaderPageProps = { * @public */ export const TechDocsReaderPage = ({ - path: defaultPath, entityName: defaultEntityName, children = , }: TechDocsReaderPageProps) => { - const params = useParams(); + const { kind, name, namespace } = 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]); + const entityName = defaultEntityName || { kind, name, namespace }; return ( - - - - {children} - - - + + {({ metadata, entityMetadata }) => ( + + {children instanceof Function + ? children({ + entityRef: entityName, + techdocsMetadataValue: metadata.value, + entityMetadataValue: entityMetadata.value, + }) + : children} + + )} + ); }; diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage/context.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPage/context.tsx index 56e0991df6..bd1c884ca0 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPage/context.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage/context.tsx @@ -15,92 +15,42 @@ */ import React, { - createContext, + ReactNode, + memo, Dispatch, - PropsWithChildren, SetStateAction, + createContext, 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 = T & - PropsWithChildren<{ 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); +const areEntityNamesEqual = ( + prevEntityName: CompoundEntityRef, + nextEntityName: CompoundEntityRef, +) => { + if (prevEntityName.kind !== nextEntityName.kind) { + return false; + } + if (prevEntityName.name !== nextEntityName.name) { + return false; + } + if (prevEntityName.namespace !== nextEntityName.namespace) { + return false; + } + return true; }; export type TechDocsReaderPageValue = { - path: string; + metadata: AsyncState; entityName: CompoundEntityRef; + entityMetadata: AsyncState; shadowRoot?: ShadowRoot; setShadowRoot: Dispatch>; title: string; @@ -110,12 +60,13 @@ export type TechDocsReaderPageValue = { }; export const defaultTechDocsReaderPageValue: TechDocsReaderPageValue = { - path: '', title: '', - setTitle: () => {}, subtitle: '', + setTitle: () => {}, setSubtitle: () => {}, setShadowRoot: () => {}, + metadata: { loading: true }, + entityMetadata: { loading: true }, entityName: { kind: '', name: '', namespace: '' }, }; @@ -127,48 +78,74 @@ export const useTechDocsReaderPage = () => { return useContext(TechDocsReaderPageContext); }; -type TechDocsReaderPageProviderProps = PropsWithEntityName<{ - path?: string; -}>; +type TechDocsReaderPageProviderRenderFunction = ( + value: TechDocsReaderPageValue, +) => JSX.Element; -export const TechDocsReaderPageProvider = ({ - path = '', - entityName, - children, -}: TechDocsReaderPageProviderProps) => { - const { value: entityMetadataValue } = useEntityMetadata(); - const { value: techdocsMetadataValue } = useTechDocsMetadata(); - - 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, - entityMetadataValue, - techdocsMetadataValue, - }) - : children} - - - ); +type TechDocsReaderPageProviderProps = { + entityName: CompoundEntityRef; + children: TechDocsReaderPageProviderRenderFunction | ReactNode; +}; + +export const TechDocsReaderPageProvider = memo( + ({ entityName, children }: TechDocsReaderPageProviderProps) => { + const techdocsApi = useApi(techdocsApiRef); + + const metadata = useAsync(async () => { + return techdocsApi.getTechDocsMetadata(entityName); + }, [entityName]); + + const entityMetadata = useAsync(async () => { + return techdocsApi.getEntityMetadata(entityName); + }, [entityName]); + + const [title, setTitle] = useState(defaultTechDocsReaderPageValue.title); + const [subtitle, setSubtitle] = useState( + defaultTechDocsReaderPageValue.subtitle, + ); + const [shadowRoot, setShadowRoot] = useState( + defaultTechDocsReaderPageValue.shadowRoot, + ); + + const value = { + metadata, + entityName, + entityMetadata, + shadowRoot, + setShadowRoot, + title, + setTitle, + subtitle, + setSubtitle, + }; + + return ( + + {children instanceof Function ? children(value) : children} + + ); + }, + (prevProps, nextProps) => { + return areEntityNamesEqual(prevProps.entityName, nextProps.entityName); + }, +); + +/** + * Hook for use within TechDocs addons to retrieve Entity Metadata for the + * current TechDocs site. + * @public + */ +export const useEntityMetadata = () => { + const { entityMetadata } = useTechDocsReaderPage(); + return entityMetadata; +}; + +/** + * Hook for use within TechDocs addons to retrieve TechDocs Metadata for the + * current TechDocs site. + * @public + */ +export const useTechDocsMetadata = () => { + const { metadata } = useTechDocsReaderPage(); + return metadata; }; diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.tsx index 0feeb65e12..4a823a9e7c 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useRef, useState, useEffect, useCallback } from 'react'; import { create } from 'jss'; import { makeStyles, Grid, Portal } from '@material-ui/core'; @@ -29,7 +29,9 @@ import { Content, Progress } from '@backstage/core-components'; import { TechDocsSearch } from '../../../search'; import { useTechDocsReaderPage } from '../TechDocsReaderPage'; import { TechDocsStateIndicator } from '../TechDocsStateIndicator'; -import { useTechDocsReaderDom, withTechDocsReaderProvider } from './context'; + +import { useTechDocsReaderDom } from './dom'; +import { withTechDocsReaderProvider } from './context'; const useStyles = makeStyles({ search: { @@ -49,10 +51,10 @@ export const TechDocsReaderPageContent = withTechDocsReaderProvider( ({ withSearch = true }: TechDocsReaderPageContentProps) => { const classes = useStyles(); const addons = useTechDocsAddons(); - const page = useTechDocsReaderPage(); - const dom = useTechDocsReaderDom(page.entityName); + const { entityName, setShadowRoot } = useTechDocsReaderPage(); + const dom = useTechDocsReaderDom(entityName); - const ref = useRef(null); + const ref = useRef(null); const [jss, setJss] = useState( create({ ...jssPreset(), @@ -75,16 +77,16 @@ export const TechDocsReaderPageContent = withTechDocsReaderProvider( shadowHost.shadowRoot ?? shadowHost.attachShadow({ mode: 'open' }); shadowRoot.innerHTML = ''; shadowRoot.appendChild(dom); - page.setShadowRoot(shadowRoot); - }, [dom, page]); + setShadowRoot(shadowRoot); + }, [dom, setShadowRoot]); - const contentElement = ref.current?.shadowRoot?.querySelector( + const contentElement = ref.current?.querySelector( '[data-md-component="container"]', ); - const primarySidebarElement = ref.current?.shadowRoot?.querySelector( + const primarySidebarElement = ref.current?.querySelector( 'div[data-md-component="sidebar"][data-md-type="navigation"], div[data-md-component="navigation"]', ); - const secondarySidebarElement = ref.current?.shadowRoot?.querySelector( + const secondarySidebarElement = ref.current?.querySelector( 'div[data-md-component="sidebar"][data-md-type="toc"], div[data-md-component="toc"]', ); @@ -111,7 +113,7 @@ export const TechDocsReaderPageContent = withTechDocsReaderProvider( {withSearch && ( - + )} diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/context.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/context.tsx index 7a4877d3cd..e1d6a0d55f 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/context.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/context.tsx @@ -15,42 +15,14 @@ */ import React, { - FC, ComponentType, createContext, useContext, - useCallback, - useEffect, - useState, + ReactNode, } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useParams } from 'react-router-dom'; -import { useTheme, Theme } from '@material-ui/core'; -import { lighten, alpha } from '@material-ui/core/styles'; - -import { BackstageTheme } from '@backstage/theme'; import { CompoundEntityRef } from '@backstage/catalog-model'; -import { useApi, configApiRef } from '@backstage/core-plugin-api'; -import { SidebarPinStateContext } from '@backstage/core-components'; -import { scmIntegrationsApiRef } from '@backstage/integration-react'; - -import { techdocsStorageApiRef } from '../../../api'; - -import { - 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'; @@ -72,13 +44,37 @@ const TechDocsReaderContext = createContext( {} as TechDocsReaderValue, ); -export const TechDocsReaderProvider: FC = ({ children }) => { - const { path, entityName } = useTechDocsReaderPage(); +/** + * 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 TechDocsReaderProviderRenderFunction = ( + value: TechDocsReaderValue, +) => JSX.Element; + +type TechDocsReaderProviderProps = { + children: TechDocsReaderProviderRenderFunction | ReactNode; +}; + +export const TechDocsReaderProvider = ({ + children, +}: TechDocsReaderProviderProps) => { + const { '*': path = '' } = useParams(); + const { entityName } = useTechDocsReaderPage(); const { kind, namespace, name } = entityName; const value = useReaderState(kind, namespace, name, path); + return ( - {children} + {children instanceof Function ? children(value) : children} ); }; @@ -100,758 +96,3 @@ export const withTechDocsReaderProvider = ); - -/** - * 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/dom.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/dom.tsx new file mode 100644 index 0000000000..a6cf79f1db --- /dev/null +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/dom.tsx @@ -0,0 +1,790 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useContext, useCallback, useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import { useTheme, Theme } from '@material-ui/core'; +import { lighten, alpha } from '@material-ui/core/styles'; + +import { BackstageTheme } from '@backstage/theme'; +import { CompoundEntityRef } from '@backstage/catalog-model'; +import { useApi, configApiRef } from '@backstage/core-plugin-api'; +import { SidebarPinStateContext } from '@backstage/core-components'; +import { scmIntegrationsApiRef } from '@backstage/integration-react'; + +import { techdocsStorageApiRef } from '../../../api'; + +import { useTechDocsReader } from './context'; + +import { + addBaseUrl, + addGitFeedbackLink, + addLinkClickListener, + addSidebarToggle, + injectCss, + onCssReady, + removeMkdocsHeader, + rewriteDocLinks, + sanitizeDOM, + simplifyMkdocsFooter, + scrollIntoAnchor, + transform as transformer, + copyToClipboard, +} from '../../transformers'; + +type TypographyHeadings = Pick< + Theme['typography'], + 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' +>; +type TypographyHeadingsKeys = keyof TypographyHeadings; + +const headings: TypographyHeadingsKeys[] = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']; + +/** + * Hook that encapsulates the behavior of getting raw HTML and applying + * transforms to it in order to make it function at a basic level in the + * Backstage UI. + * + * Note: this hook is currently being exported so that we can rapidly iterate + * on alternative implementations that extend core functionality. + * There is no guarantee that this hook will continue to be exported by the + * package in the future! + * + * todo: Make public or stop exporting (see others: "altReaderExperiments") + * @internal + */ +export const useTechDocsReaderDom = ( + entityRef: CompoundEntityRef, +): Element | null => { + const navigate = useNavigate(); + const theme = useTheme(); + const techdocsStorageApi = useApi(techdocsStorageApiRef); + const scmIntegrationsApi = useApi(scmIntegrationsApiRef); + const techdocsSanitizer = useApi(configApiRef); + const { namespace = '', kind = '', name = '' } = entityRef; + const { state, path, content: rawPage } = useTechDocsReader(); + const isDarkTheme = theme.palette.type === 'dark'; + + const [sidebars, setSidebars] = useState(); + const [dom, setDom] = useState(null); + + // sidebar pinned status to be used in computing CSS style injections + const { isPinned } = useContext(SidebarPinStateContext); + + const updateSidebarPosition = useCallback(() => { + if (!dom || !sidebars) return; + // set sidebar height so they don't initially render in wrong position + const mdTabs = dom.querySelector('.md-container > .md-tabs'); + const sidebarsCollapsed = window.matchMedia( + 'screen and (max-width: 76.1875em)', + ).matches; + const newTop = Math.max(dom.getBoundingClientRect().top, 0); + sidebars.forEach(sidebar => { + if (sidebarsCollapsed) { + sidebar.style.top = '0px'; + } else if (mdTabs) { + sidebar.style.top = `${ + newTop + mdTabs.getBoundingClientRect().height + }px`; + } else { + sidebar.style.top = `${newTop}px`; + } + }); + }, [dom, sidebars]); + + useEffect(() => { + updateSidebarPosition(); + window.addEventListener('scroll', updateSidebarPosition, true); + window.addEventListener('resize', updateSidebarPosition); + return () => { + window.removeEventListener('scroll', updateSidebarPosition, true); + window.removeEventListener('resize', updateSidebarPosition); + }; + // an update to "state" might lead to an updated UI so we include it as a trigger + }, [updateSidebarPosition, state]); + + // dynamically set width of footer to accommodate for pinning of the sidebar + const updateFooterWidth = useCallback(() => { + if (!dom) return; + const footer = dom.querySelector('.md-footer') as HTMLElement; + if (footer) { + footer.style.width = `${dom.getBoundingClientRect().width}px`; + } + }, [dom]); + + useEffect(() => { + updateFooterWidth(); + window.addEventListener('resize', updateFooterWidth); + return () => { + window.removeEventListener('resize', updateFooterWidth); + }; + }); + + // a function that performs transformations that are executed prior to adding it to the DOM + const preRender = useCallback( + (rawContent: string, contentPath: string) => + transformer(rawContent, [ + sanitizeDOM(techdocsSanitizer.getOptionalConfig('techdocs.sanitizer')), + addBaseUrl({ + techdocsStorageApi, + entityId: { + kind, + name, + namespace, + }, + path: contentPath, + }), + rewriteDocLinks(), + addSidebarToggle(), + removeMkdocsHeader(), + simplifyMkdocsFooter(), + addGitFeedbackLink(scmIntegrationsApi), + injectCss({ + // Variables + css: ` + /* + As the MkDocs output is rendered in shadow DOM, the CSS variable definitions on the root selector are not applied. Instead, they have to be applied on :host. + As there is no way to transform the served main*.css yet (for example in the backend), we have to copy from main*.css and modify them. + */ + :host { + /* FONT */ + --md-default-fg-color: ${theme.palette.text.primary}; + --md-default-fg-color--light: ${theme.palette.text.secondary}; + --md-default-fg-color--lighter: ${lighten( + theme.palette.text.secondary, + 0.7, + )}; + --md-default-fg-color--lightest: ${lighten( + theme.palette.text.secondary, + 0.3, + )}; + + /* BACKGROUND */ + --md-default-bg-color:${theme.palette.background.default}; + --md-default-bg-color--light: ${theme.palette.background.paper}; + --md-default-bg-color--lighter: ${lighten( + theme.palette.background.paper, + 0.7, + )}; + --md-default-bg-color--lightest: ${lighten( + theme.palette.background.paper, + 0.3, + )}; + + /* PRIMARY */ + --md-primary-fg-color: ${theme.palette.primary.main}; + --md-primary-fg-color--light: ${theme.palette.primary.light}; + --md-primary-fg-color--dark: ${theme.palette.primary.dark}; + --md-primary-bg-color: ${theme.palette.primary.contrastText}; + --md-primary-bg-color--light: ${lighten( + theme.palette.primary.contrastText, + 0.7, + )}; + + /* ACCENT */ + --md-accent-fg-color: var(--md-primary-fg-color); + + /* SHADOW */ + --md-shadow-z1: ${theme.shadows[1]}; + --md-shadow-z2: ${theme.shadows[2]}; + --md-shadow-z3: ${theme.shadows[3]}; + + /* EXTENSIONS */ + --md-admonition-fg-color: var(--md-default-fg-color); + --md-admonition-bg-color: var(--md-default-bg-color); + /* Admonitions and others are using SVG masks to define icons. These masks are defined as CSS variables. */ + --md-admonition-icon--note: url('data:image/svg+xml;charset=utf-8,'); + --md-admonition-icon--abstract: url('data:image/svg+xml;charset=utf-8,'); + --md-admonition-icon--info: url('data:image/svg+xml;charset=utf-8,'); + --md-admonition-icon--tip: url('data:image/svg+xml;charset=utf-8,'); + --md-admonition-icon--success: url('data:image/svg+xml;charset=utf-8,'); + --md-admonition-icon--question: url('data:image/svg+xml;charset=utf-8,'); + --md-admonition-icon--warning: url('data:image/svg+xml;charset=utf-8,'); + --md-admonition-icon--failure: url('data:image/svg+xml;charset=utf-8,'); + --md-admonition-icon--danger: url('data:image/svg+xml;charset=utf-8,'); + --md-admonition-icon--bug: url('data:image/svg+xml;charset=utf-8,'); + --md-admonition-icon--example: url('data:image/svg+xml;charset=utf-8,'); + --md-admonition-icon--quote: url('data:image/svg+xml;charset=utf-8,'); + --md-footnotes-icon: url('data:image/svg+xml;charset=utf-8,'); + --md-details-icon: url('data:image/svg+xml;charset=utf-8,'); + --md-tasklist-icon: url('data:image/svg+xml;charset=utf-8,'); + --md-tasklist-icon--checked: url('data:image/svg+xml;charset=utf-8,'); + --md-nav-icon--prev: url('data:image/svg+xml;charset=utf-8,'); + --md-nav-icon--next: url('data:image/svg+xml;charset=utf-8,'); + --md-toc-icon: url('data:image/svg+xml;charset=utf-8,'); + --md-clipboard-icon: url('data:image/svg+xml;charset=utf-8,'); + --md-search-result-icon: url('data:image/svg+xml;charset=utf-8,'); + --md-source-forks-icon: url('data:image/svg+xml;charset=utf-8,'); + --md-source-repositories-icon: url('data:image/svg+xml;charset=utf-8,'); + --md-source-stars-icon: url('data:image/svg+xml;charset=utf-8,'); + --md-source-version-icon: url('data:image/svg+xml;charset=utf-8,'); + --md-version-icon: url('data:image/svg+xml;charset=utf-8,'); + } + + :host > * { + /* CODE */ + --md-code-fg-color: ${theme.palette.text.primary}; + --md-code-bg-color: ${theme.palette.background.paper}; + --md-code-hl-color: ${alpha(theme.palette.warning.main, 0.5)}; + --md-code-hl-keyword-color: ${ + isDarkTheme + ? theme.palette.primary.light + : theme.palette.primary.dark + }; + --md-code-hl-function-color: ${ + isDarkTheme + ? theme.palette.secondary.light + : theme.palette.secondary.dark + }; + --md-code-hl-string-color: ${ + isDarkTheme + ? theme.palette.success.light + : theme.palette.success.dark + }; + --md-code-hl-number-color: ${ + isDarkTheme + ? theme.palette.error.light + : theme.palette.error.dark + }; + --md-code-hl-constant-color: var(--md-code-hl-function-color); + --md-code-hl-special-color: var(--md-code-hl-function-color); + --md-code-hl-name-color: var(--md-code-fg-color); + --md-code-hl-comment-color: var(--md-default-fg-color--light); + --md-code-hl-generic-color: var(--md-default-fg-color--light); + --md-code-hl-variable-color: var(--md-default-fg-color--light); + --md-code-hl-operator-color: var(--md-default-fg-color--light); + --md-code-hl-punctuation-color: var(--md-default-fg-color--light); + + /* TYPESET */ + --md-typeset-font-size: 1rem; + --md-typeset-color: var(--md-default-fg-color); + --md-typeset-a-color: var(--md-accent-fg-color); + --md-typeset-table-color: ${theme.palette.text.primary}; + --md-typeset-del-color: ${ + isDarkTheme + ? alpha(theme.palette.error.dark, 0.5) + : alpha(theme.palette.error.light, 0.5) + }; + --md-typeset-ins-color: ${ + isDarkTheme + ? alpha(theme.palette.success.dark, 0.5) + : alpha(theme.palette.success.light, 0.5) + }; + --md-typeset-mark-color: ${ + isDarkTheme + ? alpha(theme.palette.warning.dark, 0.5) + : alpha(theme.palette.warning.light, 0.5) + }; + } + + @media screen and (max-width: 76.1875em) { + :host > * { + /* TYPESET */ + --md-typeset-font-size: .9rem; + } + } + + @media screen and (max-width: 600px) { + :host > * { + /* TYPESET */ + --md-typeset-font-size: .7rem; + } + } + `, + }), + injectCss({ + // Reset + css: ` + body { + --md-text-color: var(--md-default-fg-color); + --md-text-link-color: var(--md-accent-fg-color); + --md-text-font-family: ${theme.typography.fontFamily}; + font-family: var(--md-text-font-family); + background-color: unset; + } + `, + }), + injectCss({ + // Layout + css: ` + .md-grid { + max-width: 100%; + margin: 0; + } + + .md-nav { + font-size: calc(var(--md-typeset-font-size) * 0.9); + } + .md-nav__link { + display: flex; + align-items: center; + justify-content: space-between; + } + .md-nav__icon { + height: 20px !important; + width: 20px !important; + margin-left:${theme.spacing(1)}px; + } + .md-nav__icon svg { + margin: 0; + width: 20px !important; + height: 20px !important; + } + .md-nav__icon:after { + width: 20px !important; + height: 20px !important; + } + + .md-main__inner { + margin-top: 0; + } + + .md-sidebar { + bottom: 75px; + position: fixed; + width: 16rem; + overflow-y: auto; + overflow-x: hidden; + scrollbar-color: rgb(193, 193, 193) #eee; + scrollbar-width: thin; + } + .md-sidebar .md-sidebar__scrollwrap { + width: calc(16rem - 10px); + } + .md-sidebar--secondary { + right: ${theme.spacing(3)}px; + } + .md-sidebar::-webkit-scrollbar { + width: 5px; + } + .md-sidebar::-webkit-scrollbar-button { + width: 5px; + height: 5px; + } + .md-sidebar::-webkit-scrollbar-track { + background: #eee; + border: 1 px solid rgb(250, 250, 250); + box-shadow: 0px 0px 3px #dfdfdf inset; + border-radius: 3px; + } + .md-sidebar::-webkit-scrollbar-thumb { + width: 5px; + background: rgb(193, 193, 193); + border: transparent; + border-radius: 3px; + } + .md-sidebar::-webkit-scrollbar-thumb:hover { + background: rgb(125, 125, 125); + } + + .md-content { + max-width: calc(100% - 16rem * 2); + margin-left: 16rem; + margin-bottom: 50px; + } + + .md-footer { + position: fixed; + bottom: 0px; + } + .md-footer__title { + background-color: unset; + } + .md-footer-nav__link { + width: 16rem; + } + + .md-dialog { + background-color: unset; + } + + @media screen and (min-width: 76.25em) { + .md-sidebar { + height: auto; + } + } + + @media screen and (max-width: 76.1875em) { + .md-nav { + transition: none !important; + background-color: var(--md-default-bg-color) + } + .md-nav--primary .md-nav__title { + cursor: auto; + color: var(--md-default-fg-color); + font-weight: 700; + white-space: normal; + line-height: 1rem; + height: auto; + display: flex; + flex-flow: column; + row-gap: 1.6rem; + padding: 1.2rem .8rem .8rem; + background-color: var(--md-default-bg-color); + } + .md-nav--primary .md-nav__title~.md-nav__list { + box-shadow: none; + } + .md-nav--primary .md-nav__title ~ .md-nav__list > :first-child { + border-top: none; + } + .md-nav--primary .md-nav__title .md-nav__button { + display: none; + } + .md-nav--primary .md-nav__title .md-nav__icon { + color: var(--md-default-fg-color); + position: static; + height: auto; + margin: 0 0 0 -0.2rem; + } + .md-nav--primary > .md-nav__title [for="none"] { + padding-top: 0; + } + .md-nav--primary .md-nav__item { + border-top: none; + } + .md-nav--primary :is(.md-nav__title,.md-nav__item) { + font-size : var(--md-typeset-font-size); + } + .md-nav .md-source { + display: none; + } + + .md-sidebar { + height: 100%; + } + .md-sidebar--primary { + width: 12.1rem !important; + z-index: 200; + left: ${ + isPinned + ? 'calc(-12.1rem + 242px)' + : 'calc(-12.1rem + 72px)' + } !important; + } + .md-sidebar--secondary:not([hidden]) { + display: none; + } + + .md-content { + max-width: 100%; + margin-left: 0; + } + + .md-header__button { + margin: 0.4rem 0; + margin-left: 0.4rem; + padding: 0; + } + + .md-overlay { + left: 0; + } + + .md-footer { + position: static; + padding-left: 0; + } + .md-footer-nav__link { + /* footer links begin to overlap at small sizes without setting width */ + width: 50%; + } + } + + @media screen and (max-width: 600px) { + .md-sidebar--primary { + left: -12.1rem !important; + width: 12.1rem; + } + } + `, + }), + injectCss({ + // Typeset + css: ` + .md-typeset { + font-size: var(--md-typeset-font-size); + } + + ${headings.reduce((style, heading) => { + const styles = theme.typography[heading]; + const { lineHeight, fontFamily, fontWeight, fontSize } = styles; + const calculate = (value: typeof fontSize) => { + let factor: number | string = 1; + if (typeof value === 'number') { + // 60% of the size defined because it is too big + factor = (value / 16) * 0.6; + } + if (typeof value === 'string') { + factor = value.replace('rem', ''); + } + return `calc(${factor} * var(--md-typeset-font-size))`; + }; + return style.concat(` + .md-typeset ${heading} { + color: var(--md-default-fg-color); + line-height: ${lineHeight}; + font-family: ${fontFamily}; + font-weight: ${fontWeight}; + font-size: ${calculate(fontSize)}; + } + `); + }, '')} + + .md-typeset .md-content__button { + color: var(--md-default-fg-color); + } + + .md-typeset hr { + border-bottom: 0.05rem dotted ${theme.palette.divider}; + } + + .md-typeset details { + font-size: var(--md-typeset-font-size) !important; + } + .md-typeset details summary { + padding-left: 2.5rem !important; + } + .md-typeset details summary:before, + .md-typeset details summary:after { + top: 50% !important; + width: 20px !important; + height: 20px !important; + transform: rotate(0deg) translateY(-50%) !important; + } + .md-typeset details[open] > summary:after { + transform: rotate(90deg) translateX(-50%) !important; + } + + .md-typeset blockquote { + color: var(--md-default-fg-color--light); + border-left: 0.2rem solid var(--md-default-fg-color--light); + } + + .md-typeset table:not([class]) { + font-size: var(--md-typeset-font-size); + border: 1px solid var(--md-default-fg-color); + border-bottom: none; + border-collapse: collapse; + } + .md-typeset table:not([class]) th { + font-weight: bold; + } + .md-typeset table:not([class]) td, .md-typeset table:not([class]) th { + border-bottom: 1px solid var(--md-default-fg-color); + } + + .md-typeset pre > code::-webkit-scrollbar-thumb { + background-color: hsla(0, 0%, 0%, 0.32); + } + .md-typeset pre > code::-webkit-scrollbar-thumb:hover { + background-color: hsla(0, 0%, 0%, 0.87); + } + `, + }), + injectCss({ + // Animations + css: ` + /* + Disable CSS animations on link colors as they lead to issues in dark mode. + The dark mode color theme is applied later and theirfore there is always an animation from light to dark mode when navigation between pages. + */ + .md-dialog, .md-nav__link, .md-footer__link, .md-typeset a, .md-typeset a::before, .md-typeset .headerlink { + transition: none; + } + `, + }), + injectCss({ + // Extensions + css: ` + /* HIGHLIGHT */ + .highlight .md-clipboard:after { + content: unset; + } + + .highlight .nx { + color: ${isDarkTheme ? '#ff53a3' : '#ec407a'}; + } + + /* CODE HILITE */ + .codehilite .gd { + background-color: ${ + isDarkTheme ? 'rgba(248,81,73,0.65)' : '#fdd' + }; + } + + .codehilite .gi { + background-color: ${ + isDarkTheme ? 'rgba(46,160,67,0.65)' : '#dfd' + }; + } + + /* TABBED */ + .tabbed-set>input:nth-child(1):checked~.tabbed-labels>:nth-child(1), + .tabbed-set>input:nth-child(2):checked~.tabbed-labels>:nth-child(2), + .tabbed-set>input:nth-child(3):checked~.tabbed-labels>:nth-child(3), + .tabbed-set>input:nth-child(4):checked~.tabbed-labels>:nth-child(4), + .tabbed-set>input:nth-child(5):checked~.tabbed-labels>:nth-child(5), + .tabbed-set>input:nth-child(6):checked~.tabbed-labels>:nth-child(6), + .tabbed-set>input:nth-child(7):checked~.tabbed-labels>:nth-child(7), + .tabbed-set>input:nth-child(8):checked~.tabbed-labels>:nth-child(8), + .tabbed-set>input:nth-child(9):checked~.tabbed-labels>:nth-child(9), + .tabbed-set>input:nth-child(10):checked~.tabbed-labels>:nth-child(10), + .tabbed-set>input:nth-child(11):checked~.tabbed-labels>:nth-child(11), + .tabbed-set>input:nth-child(12):checked~.tabbed-labels>:nth-child(12), + .tabbed-set>input:nth-child(13):checked~.tabbed-labels>:nth-child(13), + .tabbed-set>input:nth-child(14):checked~.tabbed-labels>:nth-child(14), + .tabbed-set>input:nth-child(15):checked~.tabbed-labels>:nth-child(15), + .tabbed-set>input:nth-child(16):checked~.tabbed-labels>:nth-child(16), + .tabbed-set>input:nth-child(17):checked~.tabbed-labels>:nth-child(17), + .tabbed-set>input:nth-child(18):checked~.tabbed-labels>:nth-child(18), + .tabbed-set>input:nth-child(19):checked~.tabbed-labels>:nth-child(19), + .tabbed-set>input:nth-child(20):checked~.tabbed-labels>:nth-child(20) { + color: var(--md-accent-fg-color); + border-color: var(--md-accent-fg-color); + } + + /* TASK-LIST */ + .task-list-control .task-list-indicator::before { + background-color: ${theme.palette.action.disabledBackground}; + } + .task-list-control [type="checkbox"]:checked + .task-list-indicator:before { + background-color: ${theme.palette.success.main}; + } + + /* ADMONITION */ + .admonition { + font-size: var(--md-typeset-font-size) !important; + } + .admonition .admonition-title { + padding-left: 2.5rem !important; + } + + .admonition .admonition-title:before { + top: 50% !important; + width: 20px !important; + height: 20px !important; + transform: translateY(-50%) !important; + } + `, + }), + ]), + [ + kind, + name, + namespace, + scmIntegrationsApi, + techdocsSanitizer, + techdocsStorageApi, + theme, + isDarkTheme, + isPinned, + ], + ); + + // a function that performs transformations that are executed after adding it to the DOM + const postRender = useCallback( + async (transformedElement: Element) => + transformer(transformedElement, [ + scrollIntoAnchor(), + copyToClipboard(theme), + addLinkClickListener({ + baseUrl: window.location.origin, + onClick: (event: MouseEvent, url: string) => { + // detect if CTRL or META keys are pressed so that links can be opened in a new tab with `window.open` + const modifierActive = event.ctrlKey || event.metaKey; + const parsedUrl = new URL(url); + + // hash exists when anchor is clicked on secondary sidebar + if (parsedUrl.hash) { + if (modifierActive) { + window.open(`${parsedUrl.pathname}${parsedUrl.hash}`, '_blank'); + } else { + navigate(`${parsedUrl.pathname}${parsedUrl.hash}`); + // Scroll to hash if it's on the current page + transformedElement + ?.querySelector(`#${parsedUrl.hash.slice(1)}`) + ?.scrollIntoView(); + } + } else { + if (modifierActive) { + window.open(parsedUrl.pathname, '_blank'); + } else { + navigate(parsedUrl.pathname); + // 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 index d3da3d6aa4..288969e73b 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/index.ts +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/index.ts @@ -16,3 +16,4 @@ export { TechDocsReaderPageContent } from './TechDocsReaderPageContent'; export * from './context'; +export * from './dom'; diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.tsx index c417274403..3f9a5b9e1f 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.tsx @@ -33,11 +33,7 @@ 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 { - useTechDocsReaderPage, - useTechDocsMetadata, - useEntityMetadata, -} from '../TechDocsReaderPage'; +import { useTechDocsReaderPage } from '../TechDocsReaderPage'; import { rootRouteRef } from '../../../routes'; @@ -47,31 +43,30 @@ export const TechDocsReaderPageHeader: FC = ({ children }) => { const addons = useTechDocsAddons(); const configApi = useApi(configApiRef); - const { value: entityMetadata } = useEntityMetadata(); - const { value: techDocsMetadata } = useTechDocsMetadata(); - const { title, setTitle, subtitle, setSubtitle, - entityName: entityRef, + entityName, + metadata: { value: metadata }, + entityMetadata: { value: entityMetadata }, } = useTechDocsReaderPage(); useEffect(() => { - if (!techDocsMetadata) return; + if (!metadata) return; setTitle(prevTitle => { - const { site_name } = techDocsMetadata; + const { site_name } = metadata; return prevTitle || site_name; }); setSubtitle(prevSubtitle => { - let { site_description } = techDocsMetadata; + let { site_description } = metadata; if (site_description === 'None') { site_description = 'Home'; } return prevSubtitle || site_description; }); - }, [techDocsMetadata, setTitle, setSubtitle]); + }, [metadata, setTitle, setSubtitle]); const appTitle = configApi.getOptional('app.title') || 'Backstage'; const tabTitle = [subtitle, title, appTitle].filter(Boolean).join(' | '); @@ -92,7 +87,7 @@ export const TechDocsReaderPageHeader: FC = ({ children }) => { value={ }