1import { css } from '@emotion/react';
2import { theme } from '@expo/styleguide';
3import some from 'lodash/some';
4import Router, { NextRouter } from 'next/router';
5import * as React from 'react';
6
7import { AppJSBanner } from './AppJSBanner.tsx';
8
9import * as Utilities from '~/common/utilities';
10import * as WindowUtils from '~/common/window';
11import DocumentationFooter from '~/components/DocumentationFooter';
12import DocumentationHeader from '~/components/DocumentationHeader';
13import DocumentationNestedScrollLayout from '~/components/DocumentationNestedScrollLayout';
14import DocumentationSidebar from '~/components/DocumentationSidebar';
15import DocumentationSidebarRight, {
16  SidebarRightComponentType,
17} from '~/components/DocumentationSidebarRight';
18import Head from '~/components/Head';
19import { H1 } from '~/components/base/headings';
20import navigation from '~/constants/navigation-deprecated';
21import * as Constants from '~/constants/theme';
22import { usePageApiVersion } from '~/providers/page-api-version';
23import { NavigationRoute } from '~/types/common';
24
25const STYLES_DOCUMENT = css`
26  background: ${theme.background.default};
27  margin: 0 auto;
28  padding: 40px 56px;
29
30  hr {
31    border: 0;
32    height: 0.01rem;
33    background-color: ${theme.border.default};
34  }
35
36  @media screen and (max-width: ${Constants.breakpoints.mobile}) {
37    padding: 20px 16px 48px 16px;
38  }
39`;
40
41const HIDDEN_ON_MOBILE = css`
42  @media screen and (max-width: ${Constants.breakpoints.mobile}) {
43    display: none;
44  }
45`;
46
47const HIDDEN_ON_DESKTOP = css`
48  @media screen and (min-width: ${Constants.breakpoints.mobile}) {
49    display: none;
50  }
51`;
52
53type Props = React.PropsWithChildren<{
54  router: NextRouter;
55  title?: string;
56  sourceCodeUrl?: string;
57  tocVisible: boolean;
58  /** If the page should not show up in the Algolia Docsearch results */
59  hideFromSearch?: boolean;
60  version: string;
61}>;
62
63type State = {
64  isMenuActive: boolean;
65  isMobileSearchActive: boolean;
66};
67
68class DocumentationPageWithApiVersion extends React.Component<Props, State> {
69  state = {
70    isMenuActive: false,
71    isMobileSearchActive: false,
72  };
73
74  private layoutRef = React.createRef<DocumentationNestedScrollLayout>();
75  private sidebarRightRef = React.createRef<SidebarRightComponentType>();
76
77  componentDidMount() {
78    Router.events.on('routeChangeStart', () => {
79      if (this.layoutRef.current) {
80        window.__sidebarScroll = this.layoutRef.current.getSidebarScrollTop();
81      }
82    });
83    window.addEventListener('resize', this.handleResize);
84  }
85
86  componentWillUnmount() {
87    window.removeEventListener('resize', this.handleResize);
88  }
89
90  private handleResize = () => {
91    if (WindowUtils.getViewportSize().width >= Constants.breakpoints.mobileValue) {
92      window.scrollTo(0, 0);
93    }
94  };
95
96  private handleShowMenu = () => {
97    this.setState({
98      isMenuActive: true,
99    });
100    this.handleHideSearch();
101  };
102
103  private handleHideMenu = () => {
104    this.setState({
105      isMenuActive: false,
106    });
107  };
108
109  private handleToggleSearch = () => {
110    this.setState(prevState => ({
111      isMobileSearchActive: !prevState.isMobileSearchActive,
112    }));
113  };
114
115  private handleHideSearch = () => {
116    this.setState({
117      isMobileSearchActive: false,
118    });
119  };
120
121  private isReferencePath = () => {
122    return this.props.router.pathname.startsWith('/versions');
123  };
124
125  private isGeneralPath = () => {
126    return some(navigation.generalDirectories, name =>
127      this.props.router.pathname.startsWith(`/${name}`)
128    );
129  };
130
131  private isGettingStartedPath = () => {
132    return (
133      this.props.router.pathname === '/' ||
134      some(navigation.startingDirectories, name =>
135        this.props.router.pathname.startsWith(`/${name}`)
136      )
137    );
138  };
139
140  private isFeaturePreviewPath = () => {
141    return some(navigation.featurePreviewDirectories, name =>
142      this.props.router.pathname.startsWith(`/${name}`)
143    );
144  };
145
146  private isPreviewPath = () => {
147    return some(navigation.previewDirectories, name =>
148      this.props.router.pathname.startsWith(`/${name}`)
149    );
150  };
151
152  private isEasPath = () => {
153    return some(navigation.easDirectories, name =>
154      this.props.router.pathname.startsWith(`/${name}`)
155    );
156  };
157
158  private getCanonicalUrl = () => {
159    if (this.isReferencePath()) {
160      return `https://docs.expo.dev${Utilities.replaceVersionInUrl(
161        this.props.router.pathname,
162        'latest'
163      )}`;
164    } else {
165      return `https://docs.expo.dev${this.props.router.pathname}`;
166    }
167  };
168
169  private getAlgoliaTag = () => {
170    if (this.props.hideFromSearch === true) {
171      return null;
172    }
173
174    return this.isReferencePath() ? this.props.version : 'none';
175  };
176
177  private getRoutes = (): NavigationRoute[] => {
178    if (this.isReferencePath()) {
179      return navigation.reference[this.props.version];
180    } else {
181      return navigation[this.getActiveTopLevelSection()];
182    }
183  };
184
185  private getActiveTopLevelSection = () => {
186    if (this.isReferencePath()) {
187      return 'reference';
188    } else if (this.isGeneralPath()) {
189      return 'general';
190    } else if (this.isGettingStartedPath()) {
191      return 'starting';
192    } else if (this.isFeaturePreviewPath()) {
193      return 'featurePreview';
194    } else if (this.isPreviewPath()) {
195      return 'preview';
196    } else if (this.isEasPath()) {
197      return 'eas';
198    }
199
200    return 'general';
201  };
202
203  render() {
204    const sidebarScrollPosition = process.browser ? window.__sidebarScroll : 0;
205    const routes = this.getRoutes();
206
207    const headerElement = (
208      <DocumentationHeader
209        activeSection={this.getActiveTopLevelSection()}
210        isMenuActive={this.state.isMenuActive}
211        isMobileSearchActive={this.state.isMobileSearchActive}
212        isAlgoliaSearchHidden={this.state.isMenuActive}
213        onShowMenu={this.handleShowMenu}
214        onHideMenu={this.handleHideMenu}
215        onToggleSearch={this.handleToggleSearch}
216      />
217    );
218
219    const sidebarElement = <DocumentationSidebar router={this.props.router} routes={routes} />;
220
221    const handleContentScroll = (contentScrollPosition: number) => {
222      window.requestAnimationFrame(() => {
223        if (this.sidebarRightRef && this.sidebarRightRef.current) {
224          this.sidebarRightRef.current.handleContentScroll(contentScrollPosition);
225        }
226      });
227    };
228
229    const sidebarRight = <DocumentationSidebarRight ref={this.sidebarRightRef} />;
230
231    const algoliaTag = this.getAlgoliaTag();
232    const title = this.props.title
233      ? `${this.props.title} - Expo Documentation`
234      : `Expo Documentation`;
235
236    const pageContent = (
237      <>
238        {this.props.title && <H1>{this.props.title}</H1>}
239        <AppJSBanner />
240        {this.props.children}
241        {this.props.title && (
242          <DocumentationFooter
243            router={this.props.router}
244            title={this.props.title}
245            sourceCodeUrl={this.props.sourceCodeUrl}
246          />
247        )}
248      </>
249    );
250
251    return (
252      <DocumentationNestedScrollLayout
253        ref={this.layoutRef}
254        header={headerElement}
255        sidebar={sidebarElement}
256        sidebarRight={sidebarRight}
257        tocVisible={this.props.tocVisible}
258        isMenuActive={this.state.isMenuActive}
259        isMobileSearchActive={this.state.isMobileSearchActive}
260        onContentScroll={handleContentScroll}
261        sidebarScrollPosition={sidebarScrollPosition}>
262        <Head title={this.props.title}>
263          {algoliaTag !== null && <meta name="docsearch:version" content={algoliaTag} />}
264          <meta property="og:title" content={title} />
265          <meta property="og:type" content="website" />
266          <meta property="og:image" content="https://docs.expo.dev/static/images/og.png" />
267          <meta property="og:image:url" content="https://docs.expo.dev/static/images/og.png" />
268          <meta
269            property="og:image:secure_url"
270            content="https://docs.expo.dev/static/images/og.png"
271          />
272          <meta property="og:locale" content="en_US" />
273          <meta property="og:site_name" content="Expo Documentation" />
274          <meta
275            property="og:description"
276            content="Expo is an open-source platform for making universal native apps for Android, iOS, and the web with JavaScript and React."
277          />
278
279          <meta name="twitter:site" content="@expo" />
280          <meta name="twitter:card" content="summary" />
281          <meta property="twitter:title" content={title} />
282          <meta
283            name="twitter:description"
284            content="Expo is an open-source platform for making universal native apps for Android, iOS, and the web with JavaScript and React."
285          />
286          <meta
287            property="twitter:image"
288            content="https://docs.expo.dev/static/images/twitter.png"
289          />
290
291          {(this.props.version === 'unversioned' || this.isPreviewPath()) && (
292            <meta name="robots" content="noindex" />
293          )}
294          {this.props.version !== 'unversioned' && (
295            <link rel="canonical" href={this.getCanonicalUrl()} />
296          )}
297        </Head>
298
299        {!this.state.isMenuActive ? (
300          <div css={STYLES_DOCUMENT}>{pageContent}</div>
301        ) : (
302          <div>
303            <div css={[STYLES_DOCUMENT, HIDDEN_ON_MOBILE]}>{pageContent}</div>
304            <div css={HIDDEN_ON_DESKTOP}>
305              <DocumentationSidebar router={this.props.router} routes={routes} />
306            </div>
307          </div>
308        )}
309      </DocumentationNestedScrollLayout>
310    );
311  }
312}
313
314export default function DocumentationPage(props: Omit<Props, 'version'>) {
315  const { version } = usePageApiVersion();
316  return <DocumentationPageWithApiVersion {...props} version={version} />;
317}
318