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