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;
+ };
+};