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 * 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 DocumentationSidebar from '~/components/DocumentationSidebar';
13import DocumentationSidebarRight, {
14  SidebarRightComponentType,
15} from '~/components/DocumentationSidebarRight';
16import Head from '~/components/Head';
17import { H1 } from '~/components/base/headings';
18import navigation from '~/constants/navigation-deprecated';
19import * as Constants from '~/constants/theme';
20import { usePageApiVersion } from '~/providers/page-api-version';
21import { NavigationRoute } from '~/types/common';
22
23const STYLES_DOCUMENT = css`
24  background: ${theme.background.default};
25  margin: 0 auto;
26  padding: 40px 56px;
27
28  hr {
29    border: 0;
30    height: 0.01rem;
31    background-color: ${theme.border.default};
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 = React.PropsWithChildren<{
52  router: NextRouter;
53  title: string;
54  sourceCodeUrl?: string;
55  tocVisible: boolean;
56  /** If the page should not show up in the Algolia Docsearch results */
57  hideFromSearch?: boolean;
58  version: string;
59}>;
60
61type State = {
62  isMenuActive: boolean;
63  isMobileSearchActive: boolean;
64};
65
66class DocumentationPageWithApiVersion 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    });
81    window.addEventListener('resize', this.handleResize);
82  }
83
84  componentWillUnmount() {
85    window.removeEventListener('resize', this.handleResize);
86  }
87
88  private handleResize = () => {
89    if (WindowUtils.getViewportSize().width >= Constants.breakpoints.mobileValue) {
90      window.scrollTo(0, 0);
91    }
92  };
93
94  private handleShowMenu = () => {
95    this.setState({
96      isMenuActive: true,
97    });
98    this.handleHideSearch();
99  };
100
101  private handleHideMenu = () => {
102    this.setState({
103      isMenuActive: false,
104    });
105  };
106
107  private handleToggleSearch = () => {
108    this.setState(prevState => ({
109      isMobileSearchActive: !prevState.isMobileSearchActive,
110    }));
111  };
112
113  private handleHideSearch = () => {
114    this.setState({
115      isMobileSearchActive: false,
116    });
117  };
118
119  private isReferencePath = () => {
120    return this.props.router.pathname.startsWith('/versions');
121  };
122
123  private isGeneralPath = () => {
124    return some(navigation.generalDirectories, name =>
125      this.props.router.pathname.startsWith(`/${name}`)
126    );
127  };
128
129  private isGettingStartedPath = () => {
130    return (
131      this.props.router.pathname === '/' ||
132      some(navigation.startingDirectories, name =>
133        this.props.router.pathname.startsWith(`/${name}`)
134      )
135    );
136  };
137
138  private isFeaturePreviewPath = () => {
139    return some(navigation.featurePreviewDirectories, name =>
140      this.props.router.pathname.startsWith(`/${name}`)
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.isFeaturePreviewPath()) {
191      return 'featurePreview';
192    } else if (this.isPreviewPath()) {
193      return 'preview';
194    } else if (this.isEasPath()) {
195      return 'eas';
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
231    return (
232      <DocumentationNestedScrollLayout
233        ref={this.layoutRef}
234        header={headerElement}
235        sidebar={sidebarElement}
236        sidebarRight={sidebarRight}
237        tocVisible={this.props.tocVisible}
238        isMenuActive={this.state.isMenuActive}
239        isMobileSearchActive={this.state.isMobileSearchActive}
240        onContentScroll={handleContentScroll}
241        sidebarScrollPosition={sidebarScrollPosition}>
242        <Head title={`${this.props.title} - Expo Documentation`}>
243          {algoliaTag !== null && <meta name="docsearch:version" content={algoliaTag} />}
244          <meta property="og:title" content={`${this.props.title} - Expo Documentation`} />
245          <meta property="og:type" content="website" />
246          <meta property="og:image" content="https://docs.expo.dev/static/images/og.png" />
247          <meta property="og:image:url" content="https://docs.expo.dev/static/images/og.png" />
248          <meta
249            property="og:image:secure_url"
250            content="https://docs.expo.dev/static/images/og.png"
251          />
252          <meta property="og:locale" content="en_US" />
253          <meta property="og:site_name" content="Expo Documentation" />
254          <meta
255            property="og:description"
256            content="Expo is an open-source platform for making universal native apps for Android, iOS, and the web with JavaScript and React."
257          />
258
259          <meta name="twitter:site" content="@expo" />
260          <meta name="twitter:card" content="summary" />
261          <meta property="twitter:title" content={`${this.props.title} - Expo Documentation`} />
262          <meta
263            name="twitter:description"
264            content="Expo is an open-source platform for making universal native apps for Android, iOS, and the web with JavaScript and React."
265          />
266          <meta
267            property="twitter:image"
268            content="https://docs.expo.dev/static/images/twitter.png"
269          />
270
271          {(this.props.version === 'unversioned' || this.isPreviewPath()) && (
272            <meta name="robots" content="noindex" />
273          )}
274          {this.props.version !== 'unversioned' && (
275            <link rel="canonical" href={this.getCanonicalUrl()} />
276          )}
277        </Head>
278
279        {!this.state.isMenuActive ? (
280          <div css={STYLES_DOCUMENT}>
281            <H1>{this.props.title}</H1>
282            {this.props.children}
283            <DocumentationFooter
284              router={this.props.router}
285              title={this.props.title}
286              sourceCodeUrl={this.props.sourceCodeUrl}
287            />
288          </div>
289        ) : (
290          <div>
291            <div css={[STYLES_DOCUMENT, HIDDEN_ON_MOBILE]}>
292              <H1>{this.props.title}</H1>
293              {this.props.children}
294              <DocumentationFooter
295                router={this.props.router}
296                title={this.props.title}
297                sourceCodeUrl={this.props.sourceCodeUrl}
298              />
299            </div>
300            <div css={HIDDEN_ON_DESKTOP}>
301              <DocumentationSidebar router={this.props.router} routes={routes} />
302            </div>
303          </div>
304        )}
305      </DocumentationNestedScrollLayout>
306    );
307  }
308}
309
310export default function DocumentationPage(props: Omit<Props, 'version'>) {
311  const { version } = usePageApiVersion();
312  return <DocumentationPageWithApiVersion {...props} version={version} />;
313}
314