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 isArchivePath = () => {
126    return this.props.router.pathname.startsWith('/archive');
127  };
128
129  private isGeneralPath = () => {
130    return some(navigation.generalDirectories, name =>
131      this.props.router.pathname.startsWith(`/${name}`)
132    );
133  };
134
135  private isGettingStartedPath = () => {
136    return (
137      this.props.router.pathname === '/' ||
138      some(navigation.startingDirectories, name =>
139        this.props.router.pathname.startsWith(`/${name}`)
140      )
141    );
142  };
143
144  private isPreviewPath = () => {
145    return some(navigation.previewDirectories, name =>
146      this.props.router.pathname.startsWith(`/${name}`)
147    );
148  };
149
150  private isEasPath = () => {
151    return some(navigation.easDirectories, name =>
152      this.props.router.pathname.startsWith(`/${name}`)
153    );
154  };
155
156  private getCanonicalUrl = () => {
157    if (this.isReferencePath()) {
158      return `https://docs.expo.dev${Utilities.replaceVersionInUrl(
159        this.props.router.pathname,
160        'latest'
161      )}`;
162    } else {
163      return `https://docs.expo.dev${this.props.router.pathname}`;
164    }
165  };
166
167  private getAlgoliaTag = () => {
168    if (this.props.hideFromSearch === true) {
169      return null;
170    }
171
172    return this.isReferencePath() ? this.props.version : 'none';
173  };
174
175  private getRoutes = (): NavigationRoute[] => {
176    if (this.isReferencePath()) {
177      return navigation.reference[this.props.version];
178    } else {
179      return navigation[this.getActiveTopLevelSection()];
180    }
181  };
182
183  private getActiveTopLevelSection = () => {
184    if (this.isReferencePath()) {
185      return 'reference';
186    } else if (this.isGeneralPath()) {
187      return 'general';
188    } else if (this.isGettingStartedPath()) {
189      return 'starting';
190    } else if (this.isPreviewPath()) {
191      return 'preview';
192    } else if (this.isEasPath()) {
193      return 'eas';
194    } else if (this.isArchivePath()) {
195      return 'archive';
196    }
197
198    return 'general';
199  };
200
201  render() {
202    const sidebarScrollPosition = process.browser ? window.__sidebarScroll : 0;
203    const routes = this.getRoutes();
204
205    const headerElement = (
206      <DocumentationHeader
207        activeSection={this.getActiveTopLevelSection()}
208        isMenuActive={this.state.isMenuActive}
209        isMobileSearchActive={this.state.isMobileSearchActive}
210        isAlgoliaSearchHidden={this.state.isMenuActive}
211        onShowMenu={this.handleShowMenu}
212        onHideMenu={this.handleHideMenu}
213        onToggleSearch={this.handleToggleSearch}
214      />
215    );
216
217    const sidebarElement = <DocumentationSidebar router={this.props.router} routes={routes} />;
218
219    const handleContentScroll = (contentScrollPosition: number) => {
220      window.requestAnimationFrame(() => {
221        if (this.sidebarRightRef && this.sidebarRightRef.current) {
222          this.sidebarRightRef.current.handleContentScroll(contentScrollPosition);
223        }
224      });
225    };
226
227    const sidebarRight = <DocumentationSidebarRight ref={this.sidebarRightRef} />;
228
229    const algoliaTag = this.getAlgoliaTag();
230    const title = this.props.title
231      ? `${this.props.title} - Expo Documentation`
232      : `Expo Documentation`;
233
234    const pageContent = (
235      <>
236        {this.props.title && <H1>{this.props.title}</H1>}
237        <AppJSBanner />
238        {this.props.children}
239        {this.props.title && (
240          <DocumentationFooter
241            router={this.props.router}
242            title={this.props.title}
243            sourceCodeUrl={this.props.sourceCodeUrl}
244          />
245        )}
246      </>
247    );
248
249    return (
250      <DocumentationNestedScrollLayout
251        ref={this.layoutRef}
252        header={headerElement}
253        sidebar={sidebarElement}
254        sidebarRight={sidebarRight}
255        tocVisible={this.props.tocVisible}
256        isMenuActive={this.state.isMenuActive}
257        isMobileSearchActive={this.state.isMobileSearchActive}
258        onContentScroll={handleContentScroll}
259        sidebarScrollPosition={sidebarScrollPosition}>
260        <Head title={this.props.title}>
261          {algoliaTag !== null && <meta name="docsearch:version" content={algoliaTag} />}
262          <meta property="og:title" content={title} />
263          <meta property="og:type" content="website" />
264          <meta property="og:image" content="https://docs.expo.dev/static/images/og.png" />
265          <meta property="og:image:url" content="https://docs.expo.dev/static/images/og.png" />
266          <meta
267            property="og:image:secure_url"
268            content="https://docs.expo.dev/static/images/og.png"
269          />
270          <meta property="og:locale" content="en_US" />
271          <meta property="og:site_name" content="Expo Documentation" />
272          <meta
273            property="og:description"
274            content="Expo is an open-source platform for making universal native apps for Android, iOS, and the web with JavaScript and React."
275          />
276
277          <meta name="twitter:site" content="@expo" />
278          <meta name="twitter:card" content="summary" />
279          <meta property="twitter:title" content={title} />
280          <meta
281            name="twitter:description"
282            content="Expo is an open-source platform for making universal native apps for Android, iOS, and the web with JavaScript and React."
283          />
284          <meta
285            property="twitter:image"
286            content="https://docs.expo.dev/static/images/twitter.png"
287          />
288
289          {(this.props.version === 'unversioned' || this.isPreviewPath()) && (
290            <meta name="robots" content="noindex" />
291          )}
292          {this.props.version !== 'unversioned' && (
293            <link rel="canonical" href={this.getCanonicalUrl()} />
294          )}
295        </Head>
296
297        {!this.state.isMenuActive ? (
298          <div css={STYLES_DOCUMENT}>{pageContent}</div>
299        ) : (
300          <div>
301            <div css={[STYLES_DOCUMENT, HIDDEN_ON_MOBILE]}>{pageContent}</div>
302            <div css={HIDDEN_ON_DESKTOP}>
303              <DocumentationSidebar router={this.props.router} routes={routes} />
304            </div>
305          </div>
306        )}
307      </DocumentationNestedScrollLayout>
308    );
309  }
310}
311
312export default function DocumentationPage(props: Omit<Props, 'version'>) {
313  const { version } = usePageApiVersion();
314  return <DocumentationPageWithApiVersion {...props} version={version} />;
315}
316