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 isFeaturePreviewPath = () => {
160    return some(navigation.featurePreviewDirectories, name =>
161      this.props.url.pathname.startsWith(`/${name}`)
162    );
163  };
164
165  private isPreviewPath = () => {
166    return some(navigation.previewDirectories, name =>
167      this.props.url.pathname.startsWith(`/${name}`)
168    );
169  };
170
171  private getCanonicalUrl = () => {
172    if (this.isReferencePath()) {
173      return `https://docs.expo.dev${Utilities.replaceVersionInUrl(
174        this.props.url.pathname,
175        'latest'
176      )}`;
177    } else {
178      return `https://docs.expo.dev${this.props.url.pathname}`;
179    }
180  };
181
182  private getAlgoliaTag = () => {
183    if (this.props.hideFromSearch === true) {
184      return null;
185    }
186
187    return this.isReferencePath() ? this.getVersion() : 'none';
188  };
189
190  private getVersion = () => {
191    let version = (this.props.asPath || this.props.url.pathname).split(`/`)[2];
192    if (!version || !VERSIONS.includes(version)) {
193      version = 'latest';
194    }
195    return version;
196  };
197
198  private getRoutes = (): NavigationRoute[] => {
199    if (this.isReferencePath()) {
200      const version = this.getVersion();
201      return navigation.reference[version];
202    } else {
203      return navigation[this.getActiveTopLevelSection()];
204    }
205  };
206
207  private getActiveTopLevelSection = () => {
208    if (this.isReferencePath()) {
209      return 'reference';
210    } else if (this.isGeneralPath()) {
211      return 'general';
212    } else if (this.isGettingStartedPath()) {
213      return 'starting';
214    } else if (this.isFeaturePreviewPath()) {
215      return 'featurePreview';
216    } else if (this.isPreviewPath()) {
217      return 'preview';
218    }
219
220    return 'general';
221  };
222
223  render() {
224    const sidebarScrollPosition = process.browser ? window.__sidebarScroll : 0;
225
226    const version = this.getVersion();
227    const routes = this.getRoutes();
228
229    const isReferencePath = this.isReferencePath();
230
231    const headerElement = (
232      <DocumentationHeader
233        activeSection={this.getActiveTopLevelSection()}
234        version={version}
235        isMenuActive={this.state.isMenuActive}
236        isMobileSearchActive={this.state.isMobileSearchActive}
237        isAlgoliaSearchHidden={this.state.isMenuActive}
238        onShowMenu={this.handleShowMenu}
239        onHideMenu={this.handleHideMenu}
240        onToggleSearch={this.handleToggleSearch}
241      />
242    );
243
244    const sidebarElement = (
245      <DocumentationSidebar
246        url={this.props.url}
247        asPath={this.props.asPath}
248        routes={routes}
249        version={version}
250        onSetVersion={this.handleSetVersion}
251        isVersionSelectorHidden={!isReferencePath}
252      />
253    );
254
255    const handleContentScroll = (contentScrollPosition: number) => {
256      window.requestAnimationFrame(() => {
257        if (this.sidebarRightRef && this.sidebarRightRef.current) {
258          this.sidebarRightRef.current.handleContentScroll(contentScrollPosition);
259        }
260      });
261    };
262
263    const sidebarRight = <DocumentationSidebarRight ref={this.sidebarRightRef} />;
264
265    const algoliaTag = this.getAlgoliaTag();
266
267    return (
268      <DocumentationNestedScrollLayout
269        ref={this.layoutRef}
270        header={headerElement}
271        sidebar={sidebarElement}
272        sidebarRight={sidebarRight}
273        tocVisible={this.props.tocVisible}
274        isMenuActive={this.state.isMenuActive}
275        isMobileSearchActive={this.state.isMobileSearchActive}
276        onContentScroll={handleContentScroll}
277        sidebarScrollPosition={sidebarScrollPosition}>
278        <Head title={`${this.props.title} - Expo Documentation`}>
279          {algoliaTag !== null && <meta name="docsearch:version" content={algoliaTag} />}
280          <meta property="og:title" content={`${this.props.title} - Expo Documentation`} />
281          <meta property="og:type" content="website" />
282          <meta property="og:image" content="https://docs.expo.dev/static/images/og.png" />
283          <meta property="og:image:url" content="https://docs.expo.dev/static/images/og.png" />
284          <meta
285            property="og:image:secure_url"
286            content="https://docs.expo.dev/static/images/og.png"
287          />
288          <meta property="og:locale" content="en_US" />
289          <meta property="og:site_name" content="Expo Documentation" />
290          <meta
291            property="og:description"
292            content="Expo is an open-source platform for making universal native apps for Android, iOS, and the web with JavaScript and React."
293          />
294
295          <meta name="twitter:site" content="@expo" />
296          <meta name="twitter:card" content="summary" />
297          <meta property="twitter:title" content={`${this.props.title} - Expo Documentation`} />
298          <meta
299            name="twitter: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          <meta
303            property="twitter:image"
304            content="https://docs.expo.dev/static/images/twitter.png"
305          />
306
307          {(version === 'unversioned' || this.isPreviewPath()) && (
308            <meta name="robots" content="noindex" />
309          )}
310          {version !== 'unversioned' && <link rel="canonical" href={this.getCanonicalUrl()} />}
311        </Head>
312
313        {!this.state.isMenuActive ? (
314          <div css={STYLES_DOCUMENT}>
315            <H1>{this.props.title}</H1>
316            <DocumentationPageContext.Provider value={{ version }}>
317              {this.props.children}
318            </DocumentationPageContext.Provider>
319            <DocumentationFooter
320              title={this.props.title}
321              url={this.props.url}
322              asPath={this.props.asPath}
323              sourceCodeUrl={this.props.sourceCodeUrl}
324            />
325          </div>
326        ) : (
327          <div>
328            <div css={[STYLES_DOCUMENT, HIDDEN_ON_MOBILE]}>
329              <H1>{this.props.title}</H1>
330              <DocumentationPageContext.Provider value={{ version }}>
331                {this.props.children}
332              </DocumentationPageContext.Provider>
333              <DocumentationFooter
334                title={this.props.title}
335                asPath={this.props.asPath}
336                sourceCodeUrl={this.props.sourceCodeUrl}
337              />
338            </div>
339            <div css={HIDDEN_ON_DESKTOP}>
340              <DocumentationSidebar
341                url={this.props.url}
342                asPath={this.props.asPath}
343                routes={routes}
344                version={version}
345                onSetVersion={this.handleSetVersion}
346                isVersionSelectorHidden={!isReferencePath}
347              />
348            </div>
349          </div>
350        )}
351      </DocumentationNestedScrollLayout>
352    );
353  }
354}
355