diff --git a/.changeset/techdocs-dom-toretto.md b/.changeset/techdocs-dom-toretto.md new file mode 100644 index 0000000000..3efde880f7 --- /dev/null +++ b/.changeset/techdocs-dom-toretto.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs': patch +--- + +Refactored `` component internals to support future extensibility. diff --git a/plugins/techdocs/src/reader/components/Reader.tsx b/plugins/techdocs/src/reader/components/Reader.tsx index 826271a7c1..0cb6c039d0 100644 --- a/plugins/techdocs/src/reader/components/Reader.tsx +++ b/plugins/techdocs/src/reader/components/Reader.tsx @@ -14,22 +14,26 @@ * 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 } from '@material-ui/core'; + import { EntityName } from '@backstage/catalog-model'; -import { Progress } from '@backstage/core-components'; import { useApi } from '@backstage/core-plugin-api'; import { scmIntegrationsApiRef } from '@backstage/integration-react'; import { BackstageTheme } from '@backstage/theme'; -import { - Button, - CircularProgress, - Grid, - makeStyles, - useTheme, -} from '@material-ui/core'; -import { Alert } from '@material-ui/lab'; -import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { useNavigate, useParams } from 'react-router-dom'; + import { techdocsStorageApiRef } from '../../api'; + import { addBaseUrl, addGitFeedbackLink, @@ -40,26 +44,21 @@ import { rewriteDocLinks, sanitizeDOM, simplifyMkdocsFooter, + scrollIntoAnchor, transform as transformer, } from '../transformers'; -import { TechDocsBuildLogs } from './TechDocsBuildLogs'; -import { TechDocsNotFound } from './TechDocsNotFound'; + import { TechDocsSearch } from './TechDocsSearch'; +import { TechDocsStateIndicator } from './TechDocsStateIndicator'; import { useReaderState } from './useReaderState'; type Props = { entityRef: EntityName; - onReady?: () => void; withSearch?: boolean; + onReady?: () => void; }; const useStyles = makeStyles(theme => ({ - message: { - // `word-break: break-word` is deprecated, but gives legacy support to browsers not supporting `overflow-wrap` yet - // https://developer.mozilla.org/en-US/docs/Web/CSS/word-break - wordBreak: 'break-word', - overflowWrap: 'anywhere', - }, searchBar: { marginLeft: '20rem', maxWidth: 'calc(100% - 20rem * 2 - 3rem)', @@ -71,44 +70,86 @@ const useStyles = makeStyles(theme => ({ }, })); -export const Reader = ({ entityRef, onReady, withSearch = true }: Props) => { - const { kind, namespace, name } = entityRef; - const theme = useTheme(); - const classes = useStyles(); +type TechDocsReaderValue = ReturnType; - const { - state, - path, - contentReload, - content: rawPage, - contentErrorMessage, - syncErrorMessage, - buildLog, - } = useReaderState(kind, namespace, name, useParams()['*']); +const TechDocsReaderContext = createContext( + {} as TechDocsReaderValue, +); - const techdocsStorageApi = useApi(techdocsStorageApiRef); - const [sidebars, setSidebars] = useState(); +const TechDocsReaderProvider = ({ children }: PropsWithChildren<{}>) => { + const { namespace = '', kind = '', name = '', '*': path } = useParams(); + 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); + +/** + * 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 = (): Element | null => { const navigate = useNavigate(); - const shadowDomRef = useRef(null); + const theme = useTheme(); + const techdocsStorageApi = useApi(techdocsStorageApiRef); const scmIntegrationsApi = useApi(scmIntegrationsApiRef); + const { namespace = '', kind = '', name = '' } = useParams(); + const { state, path, content: rawPage } = useTechDocsReader(); + + const [sidebars, setSidebars] = useState(); + const [dom, setDom] = useState(null); const updateSidebarPosition = useCallback(() => { - if (!!shadowDomRef.current && !!sidebars) { - const shadowDiv: HTMLElement = shadowDomRef.current!; - const shadowRoot = - shadowDiv.shadowRoot || shadowDiv.attachShadow({ mode: 'open' }); - const mdTabs = shadowRoot.querySelector('.md-container > .md-tabs'); - sidebars!.forEach(sidebar => { - const newTop = Math.max( - shadowDomRef.current!.getBoundingClientRect().top, - 0, - ); - sidebar.style.top = mdTabs - ? `${newTop + mdTabs.getBoundingClientRect().height}px` - : `${newTop}px`; - }); - } - }, [shadowDomRef, sidebars]); + if (!dom || !sidebars) return; + // set sidebar height so they don't initially render in wrong position + const mdTabs = dom.querySelector('.md-container > .md-tabs'); + sidebars.forEach(sidebar => { + const newTop = Math.max(dom.getBoundingClientRect().top, 0); + sidebar.style.top = mdTabs + ? `${newTop + mdTabs.getBoundingClientRect().height}px` + : `${newTop}px`; + }); + }, [dom, sidebars]); useEffect(() => { updateSidebarPosition(); @@ -141,62 +182,62 @@ export const Reader = ({ entityRef, onReady, withSearch = true }: Props) => { addGitFeedbackLink(scmIntegrationsApi), injectCss({ css: ` - body { - font-family: ${theme.typography.fontFamily}; - --md-text-color: ${theme.palette.text.primary}; - --md-text-link-color: ${theme.palette.primary.main}; + body { + font-family: ${theme.typography.fontFamily}; + --md-text-color: ${theme.palette.text.primary}; + --md-text-link-color: ${theme.palette.primary.main}; - --md-code-fg-color: ${theme.palette.text.primary}; - --md-code-bg-color: ${theme.palette.background.paper}; - } - .md-main__inner { margin-top: 0; } - .md-sidebar { position: fixed; bottom: 100px; width: 20rem; } - .md-sidebar--secondary { right: 2rem; } - .md-content { margin-bottom: 50px } - .md-footer { position: fixed; bottom: 0px; width: 100vw; } - .md-footer-nav__link { width: 20rem;} - .md-content { margin-left: 20rem; max-width: calc(100% - 20rem * 2 - 3rem); } - .md-typeset { font-size: 1rem; } - .md-nav { font-size: 1rem; } - .md-grid { max-width: 90vw; margin: 0 } - .md-typeset table:not([class]) { - font-size: 1rem; - border: 1px solid ${theme.palette.text.primary}; - border-bottom: none; - border-collapse: collapse; - } - .md-typeset table:not([class]) td, .md-typeset table:not([class]) th { - border-bottom: 1px solid ${theme.palette.text.primary}; - } - .md-typeset table:not([class]) th { font-weight: bold; } - .md-typeset .admonition, .md-typeset details { - font-size: 1rem; - } - @media screen and (max-width: 76.1875em) { - .md-nav { - background-color: ${theme.palette.background.default}; - transition: none !important + --md-code-fg-color: ${theme.palette.text.primary}; + --md-code-bg-color: ${theme.palette.background.paper}; } - .md-sidebar--secondary { display: none; } - .md-sidebar--primary { left: 72px; width: 10rem } - .md-content { margin-left: 10rem; max-width: calc(100% - 10rem); } - .md-content__inner { font-size: 0.9rem } - .md-footer { - position: static; - margin-left: 10rem; - width: calc(100% - 10rem); + .md-main__inner { margin-top: 0; } + .md-sidebar { position: fixed; bottom: 100px; width: 20rem; } + .md-sidebar--secondary { right: 2rem; } + .md-content { margin-bottom: 50px } + .md-footer { position: fixed; bottom: 0px; width: 100vw; } + .md-footer-nav__link { width: 20rem;} + .md-content { margin-left: 20rem; max-width: calc(100% - 20rem * 2 - 3rem); } + .md-typeset { font-size: 1rem; } + .md-nav { font-size: 1rem; } + .md-grid { max-width: 90vw; margin: 0 } + .md-typeset table:not([class]) { + font-size: 1rem; + border: 1px solid ${theme.palette.text.primary}; + border-bottom: none; + border-collapse: collapse; } - .md-nav--primary .md-nav__title { - white-space: normal; - height: auto; - line-height: 1rem; - cursor: auto; + .md-typeset table:not([class]) td, .md-typeset table:not([class]) th { + border-bottom: 1px solid ${theme.palette.text.primary}; } - .md-nav--primary > .md-nav__title [for="none"] { - padding-top: 0; + .md-typeset table:not([class]) th { font-weight: bold; } + .md-typeset .admonition, .md-typeset details { + font-size: 1rem; } - } - `, + @media screen and (max-width: 76.1875em) { + .md-nav { + background-color: ${theme.palette.background.default}; + transition: none !important + } + .md-sidebar--secondary { display: none; } + .md-sidebar--primary { left: 72px; width: 10rem } + .md-content { margin-left: 10rem; max-width: calc(100% - 10rem); } + .md-content__inner { font-size: 0.9rem } + .md-footer { + position: static; + margin-left: 10rem; + width: calc(100% - 10rem); + } + .md-nav--primary .md-nav__title { + white-space: normal; + height: auto; + line-height: 1rem; + cursor: auto; + } + .md-nav--primary > .md-nav__title [for="none"] { + padding-top: 0; + } + } + `, }), injectCss({ // Disable CSS animations on link colors as they lead to issues in dark @@ -204,20 +245,20 @@ export const Reader = ({ entityRef, onReady, withSearch = true }: Props) => { // is always an animation from light to dark mode when navigation // between pages. css: ` - .md-nav__link, .md-typeset a, .md-typeset a::before, .md-typeset .headerlink { - transition: none; - } - `, + .md-nav__link, .md-typeset a, .md-typeset a::before, .md-typeset .headerlink { + transition: none; + } + `, }), injectCss({ // Properly style code blocks. css: ` - .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); - } + .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({ @@ -230,30 +271,30 @@ export const Reader = ({ entityRef, onReady, withSearch = true }: Props) => { // example in the backend), we have to copy from main*.css and modify // them. css: ` - :host { - --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,'); - } - :host { - --md-footnotes-icon: url('data:image/svg+xml;charset=utf-8,'); - } - :host { - --md-details-icon: url('data:image/svg+xml;charset=utf-8,'); - } - :host { - --md-tasklist-icon: url('data:image/svg+xml;charset=utf-8,'); - --md-tasklist-icon--checked: url('data:image/svg+xml;charset=utf-8,'); - } + :host { + --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,'); + } + :host { + --md-footnotes-icon: url('data:image/svg+xml;charset=utf-8,'); + } + :host { + --md-details-icon: url('data:image/svg+xml;charset=utf-8,'); + } + :host { + --md-tasklist-icon: url('data:image/svg+xml;charset=utf-8,'); + --md-tasklist-icon--checked: url('data:image/svg+xml;charset=utf-8,'); + } `, }), ]), @@ -273,29 +314,18 @@ export const Reader = ({ entityRef, onReady, withSearch = true }: Props) => { // a function that performs transformations that are executed after adding it to the DOM const postRender = useCallback( - async (shadowRoot: ShadowRoot) => - transformer(shadowRoot.children[0], [ - dom => { - setTimeout(() => { - // Scoll to the desired anchor on initial navigation - if (window.location.hash) { - const hash = window.location.hash.slice(1); - shadowRoot?.getElementById(hash)?.scrollIntoView(); - } - }, 200); - return dom; - }, + async (transformedElement: Element) => + transformer(transformedElement, [ + scrollIntoAnchor(), addLinkClickListener({ baseUrl: window.location.origin, onClick: (_: MouseEvent, url: string) => { const parsedUrl = new URL(url); - if (parsedUrl.hash) { navigate(`${parsedUrl.pathname}${parsedUrl.hash}`); - // Scroll to hash if it's on the current page - shadowRoot - ?.getElementById(parsedUrl.hash.slice(1)) + transformedElement + ?.querySelector(`#${parsedUrl.hash.slice(1)}`) ?.scrollIntoView(); } else { navigate(parsedUrl.pathname); @@ -304,28 +334,18 @@ export const Reader = ({ entityRef, onReady, withSearch = true }: Props) => { }), onCssReady({ docStorageUrl: await techdocsStorageApi.getApiOrigin(), - onLoading: (dom: Element) => { - (dom as HTMLElement).style.setProperty('opacity', '0'); + onLoading: (renderedElement: Element) => { + (renderedElement as HTMLElement).style.setProperty('opacity', '0'); }, - onLoaded: (dom: Element) => { - (dom as HTMLElement).style.removeProperty('opacity'); + onLoaded: (renderedElement: Element) => { + (renderedElement as HTMLElement).style.removeProperty('opacity'); // disable MkDocs drawer toggling ('for' attribute => checkbox mechanism) - (dom as HTMLElement) + renderedElement .querySelector('.md-nav__title') ?.removeAttribute('for'); - const sideDivs: HTMLElement[] = Array.from( - shadowRoot!.querySelectorAll('.md-sidebar'), + setSidebars( + Array.from(renderedElement.querySelectorAll('.md-sidebar')), ); - setSidebars(sideDivs); - // set sidebar height so they don't initially render in wrong position - const docTopPosition = (dom as HTMLElement).getBoundingClientRect() - .top; - const mdTabs = dom.querySelector('.md-container > .md-tabs'); - sideDivs!.forEach(sidebar => { - sidebar.style.top = mdTabs - ? `${docTopPosition + mdTabs.getBoundingClientRect().height}px` - : `${docTopPosition}px`; - }); }, }), ]), @@ -333,23 +353,14 @@ export const Reader = ({ entityRef, onReady, withSearch = true }: Props) => { ); useEffect(() => { - if (!rawPage || !shadowDomRef.current) { - // clear the shadow dom if no content is available - if (shadowDomRef.current?.shadowRoot) { - shadowDomRef.current.shadowRoot.innerHTML = ''; - } - return () => {}; - } - if (onReady) { - onReady(); - } + if (!rawPage) return () => {}; // if false, there is already a newer execution of this effect let shouldReplaceContent = true; // Pre-render - preRender(rawPage, path).then(async transformedElement => { - if (!transformedElement?.innerHTML) { + preRender(rawPage, path).then(async preTransformedDomElement => { + if (!preTransformedDomElement?.innerHTML) { return; // An unexpected error occurred } @@ -358,94 +369,49 @@ export const Reader = ({ entityRef, onReady, withSearch = true }: Props) => { return; } - const shadowDiv: HTMLElement = shadowDomRef.current!; - const shadowRoot = - shadowDiv.shadowRoot || shadowDiv.attachShadow({ mode: 'open' }); - Array.from(shadowRoot.children).forEach(child => - shadowRoot.removeChild(child), - ); - shadowRoot.appendChild(transformedElement); - // Scroll to top after render window.scroll({ top: 0 }); // Post-render - await postRender(shadowRoot); + const postTransformedDomElement = await postRender( + preTransformedDomElement, + ); + setDom(postTransformedDomElement as HTMLElement); }); // cancel this execution return () => { shouldReplaceContent = false; }; - }, [onReady, path, postRender, preRender, rawPage]); + }, [rawPage, path, preRender, postRender]); + + return dom; +}; + +const TheReader = ({ + entityRef, + onReady = () => {}, + withSearch = true, +}: Props) => { + const classes = useStyles(); + const dom = useTechDocsReaderDom(); + const shadowDomRef = useRef(null); + + 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); + onReady(); + }, [dom, onReady]); return ( <> - {state === 'CHECKING' && } - {state === 'INITIAL_BUILD' && ( - } - action={} - > - Documentation is accessed for the first time and is being prepared. - The subsequent loads are much faster. - - )} - {state === 'CONTENT_STALE_REFRESHING' && ( - } - action={} - > - A newer version of this documentation is being prepared and will be - available shortly. - - )} - {state === 'CONTENT_STALE_READY' && ( - contentReload()}> - Refresh - - } - > - A newer version of this documentation is now available, please refresh - to view. - - )} - {state === 'CONTENT_STALE_ERROR' && ( - } - classes={{ message: classes.message }} - > - Building a newer version of this documentation failed.{' '} - {syncErrorMessage} - - )} - {state === 'CONTENT_NOT_FOUND' && ( - <> - {syncErrorMessage && ( - } - classes={{ message: classes.message }} - > - Building a newer version of this documentation failed.{' '} - {syncErrorMessage} - - )} - - - )} - + {withSearch && shadowDomRef?.current?.shadowRoot?.innerHTML && ( @@ -455,3 +421,17 @@ export const Reader = ({ entityRef, onReady, withSearch = true }: Props) => { ); }; + +export const Reader = ({ + entityRef, + onReady = () => {}, + withSearch = true, +}: Props) => ( + + + +); diff --git a/plugins/techdocs/src/reader/components/TechDocsSearch.tsx b/plugins/techdocs/src/reader/components/TechDocsSearch.tsx index 5d330100ce..64229e6620 100644 --- a/plugins/techdocs/src/reader/components/TechDocsSearch.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsSearch.tsx @@ -61,7 +61,7 @@ export const buildInitialFilters = ( return legacyPaths ? entityId : Object.entries(entityId).reduce((acc, [key, value]) => { - return { ...acc, [key]: value.toLocaleLowerCase('en-US') }; + return { ...acc, [key]: value?.toLocaleLowerCase('en-US') }; }, {}); }; @@ -175,7 +175,7 @@ const TechDocsSearchBar = ({ ); }; -const TechDocsSearch = (props: TechDocsSearchProps) => { +export const TechDocsSearch = (props: TechDocsSearchProps) => { const configApi = useApi(configApiRef); const legacyPaths = configApi.getOptionalBoolean( 'techdocs.legacyUseCaseSensitiveTripletPaths', @@ -192,4 +192,3 @@ const TechDocsSearch = (props: TechDocsSearchProps) => { ); }; -export { TechDocsSearch }; diff --git a/plugins/techdocs/src/reader/components/TechDocsStateIndicator.tsx b/plugins/techdocs/src/reader/components/TechDocsStateIndicator.tsx new file mode 100644 index 0000000000..e70c17d14a --- /dev/null +++ b/plugins/techdocs/src/reader/components/TechDocsStateIndicator.tsx @@ -0,0 +1,142 @@ +/* + * Copyright 2021 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 { Progress } from '@backstage/core-components'; +import { CircularProgress, Button, makeStyles } from '@material-ui/core'; +import { Alert } from '@material-ui/lab'; + +import { TechDocsBuildLogs } from './TechDocsBuildLogs'; +import { TechDocsNotFound } from './TechDocsNotFound'; +import { useTechDocsReader } from './Reader'; + +const useStyles = makeStyles(() => ({ + message: { + // `word-break: break-word` is deprecated, but gives legacy support to browsers not supporting `overflow-wrap` yet + // https://developer.mozilla.org/en-US/docs/Web/CSS/word-break + wordBreak: 'break-word', + overflowWrap: 'anywhere', + }, +})); + +/** + * 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! + * + * todo: Make public or stop exporting (ctrl+f "altReaderExperiments") + * @internal + */ +export const TechDocsStateIndicator = () => { + let StateAlert: JSX.Element | null = null; + const classes = useStyles(); + + const { + state, + contentReload, + contentErrorMessage, + syncErrorMessage, + buildLog, + } = useTechDocsReader(); + + const ReaderProgress = state === 'CHECKING' ? : null; + + if (state === 'INITIAL_BUILD') { + StateAlert = ( + } + action={} + > + Documentation is accessed for the first time and is being prepared. The + subsequent loads are much faster. + + ); + } + + if (state === 'CONTENT_STALE_REFRESHING') { + StateAlert = ( + } + action={} + > + A newer version of this documentation is being prepared and will be + available shortly. + + ); + } + + if (state === 'CONTENT_STALE_READY') { + StateAlert = ( + contentReload()}> + Refresh + + } + > + A newer version of this documentation is now available, please refresh + to view. + + ); + } + + if (state === 'CONTENT_STALE_ERROR') { + StateAlert = ( + } + classes={{ message: classes.message }} + > + Building a newer version of this documentation failed.{' '} + {syncErrorMessage} + + ); + } + + if (state === 'CONTENT_NOT_FOUND') { + StateAlert = ( + <> + {syncErrorMessage && ( + } + classes={{ message: classes.message }} + > + Building a newer version of this documentation failed.{' '} + {syncErrorMessage} + + )} + + + ); + } + + return ( + <> + {ReaderProgress} + {StateAlert} + + ); +}; diff --git a/plugins/techdocs/src/reader/components/index.ts b/plugins/techdocs/src/reader/components/index.ts index 4f6d151e5d..99a0a7a3bc 100644 --- a/plugins/techdocs/src/reader/components/index.ts +++ b/plugins/techdocs/src/reader/components/index.ts @@ -17,3 +17,18 @@ export * from './Reader'; export * from './TechDocsPage'; export * from './TechDocsPageHeader'; +export * from './TechDocsStateIndicator'; + +/** + * Note: this component is currently being exported so that we can rapidly + * iterate on alternative implementations that extend core + * functionality. There is no guarantee that this component will continue to be + * exported by the package in the future! + * + * Why is this comment here instead of above the component itself? It's a + * workaround for some kind of bug in @microsoft/api-extractor. + * + * todo: Make public or stop exporting (ctrl+f "altReaderExperiments") + * @internal + */ +export { TechDocsSearch } from './TechDocsSearch'; diff --git a/plugins/techdocs/src/reader/transformers/index.ts b/plugins/techdocs/src/reader/transformers/index.ts index cd1ad6511c..530bea2092 100644 --- a/plugins/techdocs/src/reader/transformers/index.ts +++ b/plugins/techdocs/src/reader/transformers/index.ts @@ -23,4 +23,5 @@ export * from './simplifyMkdocsFooter'; export * from './onCssReady'; export * from './sanitizeDOM'; export * from './injectCss'; +export * from './scrollIntoAnchor'; export * from './transformer'; diff --git a/plugins/techdocs/src/reader/transformers/scrollIntoAnchor.test.ts b/plugins/techdocs/src/reader/transformers/scrollIntoAnchor.test.ts new file mode 100644 index 0000000000..b06762399b --- /dev/null +++ b/plugins/techdocs/src/reader/transformers/scrollIntoAnchor.test.ts @@ -0,0 +1,46 @@ +/* + * Copyright 2021 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 { scrollIntoAnchor } from '../transformers'; + +jest.useFakeTimers(); + +describe('scrollIntoAnchor', () => { + const transformer = scrollIntoAnchor(); + const dom = { querySelector: jest.fn() }; + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('does nothing if there is no anchor element', async () => { + transformer(dom as unknown as Element); + jest.advanceTimersByTime(200); + expect(dom.querySelector).not.toHaveBeenCalled(); + }); + + it('scroll to the hash anchor element', async () => { + const scrollIntoView = jest.fn(); + dom.querySelector.mockReturnValue({ scrollIntoView }); + const hash = '#hash'; + window.location.hash = hash; + transformer(dom as unknown as Element); + jest.advanceTimersByTime(200); + expect(dom.querySelector).toHaveBeenCalledWith(`#${hash.slice(1)}`); + expect(scrollIntoView).toHaveBeenCalledWith(); + window.location.hash = ''; + }); +}); diff --git a/plugins/techdocs/src/reader/transformers/scrollIntoAnchor.ts b/plugins/techdocs/src/reader/transformers/scrollIntoAnchor.ts new file mode 100644 index 0000000000..0fa977865e --- /dev/null +++ b/plugins/techdocs/src/reader/transformers/scrollIntoAnchor.ts @@ -0,0 +1,30 @@ +/* + * Copyright 2021 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 type { Transformer } from './transformer'; + +export const scrollIntoAnchor = (): Transformer => { + return dom => { + setTimeout(() => { + // Scroll to the desired anchor on initial navigation + if (window.location.hash) { + const hash = window.location.hash.slice(1); + dom?.querySelector(`#${hash}`)?.scrollIntoView(); + } + }, 200); + return dom; + }; +};