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