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