1import { css } from '@emotion/react';
2import { breakpoints, 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 DocumentationNestedScrollLayout from '~/components/DocumentationNestedScrollLayout';
10import DocumentationSidebarRight, {
11  SidebarRightComponentType,
12} from '~/components/DocumentationSidebarRight';
13import Head from '~/components/Head';
14import { H1 } from '~/components/base/headings';
15import { PageApiVersionContextType, usePageApiVersion } from '~/providers/page-api-version';
16import navigation from '~/public/static/constants/navigation.json';
17import { NavigationRoute } from '~/types/common';
18import { Footer } from '~/ui/components/Footer';
19import { Header } from '~/ui/components/Header';
20import { Sidebar } from '~/ui/components/Sidebar';
21
22const STYLES_DOCUMENT = css`
23  background: ${theme.background.default};
24  margin: 0 auto;
25  padding: 40px 56px;
26
27  hr {
28    border: 0;
29    height: 0.01rem;
30    background-color: ${theme.border.default};
31  }
32
33  @media screen and (max-width: ${breakpoints.medium + 124}px) {
34    padding: 20px 16px 48px 16px;
35  }
36`;
37
38type Props = React.PropsWithChildren<{
39  router: NextRouter;
40  title?: string;
41  sourceCodeUrl?: string;
42  tocVisible: boolean;
43  packageName?: string;
44  /** If the page should not show up in the Algolia Docsearch results */
45  hideFromSearch?: boolean;
46  version: PageApiVersionContextType['version'];
47}>;
48
49type State = {
50  isMobileMenuVisible: boolean;
51};
52
53class DocumentationPageWithApiVersion extends React.Component<Props, State> {
54  state = {
55    isMobileMenuVisible: false,
56  };
57
58  private layoutRef = React.createRef<DocumentationNestedScrollLayout>();
59  private sidebarRightRef = React.createRef<SidebarRightComponentType>();
60
61  componentDidMount() {
62    Router.events.on('routeChangeStart', url => {
63      if (this.layoutRef.current) {
64        if (this.getActiveTopLevelSection() !== this.getActiveTopLevelSection(url)) {
65          window.__sidebarScroll = 0;
66        } else {
67          window.__sidebarScroll = this.layoutRef.current.getSidebarScrollTop();
68        }
69      }
70    });
71    window.addEventListener('resize', this.handleResize);
72  }
73
74  componentWillUnmount() {
75    window.removeEventListener('resize', this.handleResize);
76  }
77
78  private handleResize = () => {
79    if (WindowUtils.getViewportSize().width >= breakpoints.medium + 124) {
80      this.setState({ isMobileMenuVisible: false });
81      window.scrollTo(0, 0);
82    }
83  };
84
85  private pathStartsWith = (name: string, path: string = this.props.router.pathname) => {
86    return path.startsWith(`/${name}`);
87  };
88
89  private isArchivePath = () => {
90    return this.props.router.pathname.startsWith('/archive');
91  };
92
93  private isReferencePath = (path?: string) => {
94    return this.pathStartsWith('versions', path);
95  };
96
97  private isGeneralPath = () => {
98    return some(navigation.generalDirectories, name =>
99      this.props.router.pathname.startsWith(`/${name}`)
100    );
101  };
102
103  private isFeaturePreviewPath = (path?: string) => {
104    return navigation.featurePreview.some((name: string) => this.pathStartsWith(name, path));
105  };
106
107  private isPreviewPath = () => {
108    return some(navigation.previewDirectories, name =>
109      this.props.router.pathname.startsWith(`/${name}`)
110    );
111  };
112
113  private isEasPath = (path?: string) => {
114    return navigation.easDirectories.some(name => this.pathStartsWith(name, path));
115  };
116
117  private getCanonicalUrl = () => {
118    if (this.isReferencePath()) {
119      return `https://docs.expo.dev${Utilities.replaceVersionInUrl(
120        this.props.router.pathname,
121        'latest'
122      )}`;
123    } else {
124      return `https://docs.expo.dev${this.props.router.pathname}`;
125    }
126  };
127
128  private getAlgoliaTag = () => {
129    if (this.props.hideFromSearch === true) {
130      return null;
131    }
132
133    return this.isReferencePath() ? this.props.version : 'none';
134  };
135
136  private getRoutes = (): NavigationRoute[] => {
137    if (this.isReferencePath()) {
138      return navigation.reference[this.props.version] as NavigationRoute[];
139    } else {
140      return navigation[this.getActiveTopLevelSection()] as NavigationRoute[];
141    }
142  };
143
144  private getActiveTopLevelSection = (path?: string) => {
145    if (this.isReferencePath(path)) {
146      return 'reference';
147    } else if (this.isEasPath(path)) {
148      return 'eas';
149    } else if (this.isGeneralPath()) {
150      return 'general';
151    } else if (this.isFeaturePreviewPath(path)) {
152      return 'featurePreview';
153    } else if (this.isPreviewPath()) {
154      return 'preview';
155    } else if (this.isArchivePath()) {
156      return 'archive';
157    }
158
159    return 'general';
160  };
161
162  render() {
163    const routes = this.getRoutes();
164    const sidebarActiveGroup = this.getActiveTopLevelSection();
165    const sidebarScrollPosition = process.browser ? window.__sidebarScroll : 0;
166
167    const sidebarElement = <Sidebar routes={routes} />;
168    const headerElement = (
169      <Header
170        sidebar={sidebarElement}
171        sidebarActiveGroup={sidebarActiveGroup}
172        isMobileMenuVisible={this.state.isMobileMenuVisible}
173        setMobileMenuVisible={isMobileMenuVisible => this.setState({ isMobileMenuVisible })}
174      />
175    );
176
177    const handleContentScroll = (contentScrollPosition: number) => {
178      window.requestAnimationFrame(() => {
179        if (this.sidebarRightRef && this.sidebarRightRef.current) {
180          this.sidebarRightRef.current.handleContentScroll(contentScrollPosition);
181        }
182      });
183    };
184
185    const sidebarRight = <DocumentationSidebarRight ref={this.sidebarRightRef} />;
186
187    const algoliaTag = this.getAlgoliaTag();
188    const title = this.props.title
189      ? `${this.props.title} - Expo Documentation`
190      : `Expo Documentation`;
191
192    const pageContent = (
193      <>
194        {this.props.title && <H1>{this.props.title}</H1>}
195        {this.props.children}
196        {this.props.title && (
197          <Footer
198            title={this.props.title}
199            sourceCodeUrl={this.props.sourceCodeUrl}
200            packageName={this.props.packageName}
201          />
202        )}
203      </>
204    );
205
206    return (
207      <DocumentationNestedScrollLayout
208        ref={this.layoutRef}
209        header={headerElement}
210        sidebar={sidebarElement}
211        sidebarActiveGroup={sidebarActiveGroup}
212        sidebarRight={sidebarRight}
213        tocVisible={this.props.tocVisible}
214        isMobileMenuVisible={this.state.isMobileMenuVisible}
215        onContentScroll={handleContentScroll}
216        sidebarScrollPosition={sidebarScrollPosition}>
217        <Head title={this.props.title}>
218          {algoliaTag !== null && <meta name="docsearch:version" content={algoliaTag} />}
219          <meta property="og:title" content={title} />
220          <meta property="og:type" content="website" />
221          <meta property="og:image" content="https://docs.expo.dev/static/images/og.png" />
222          <meta property="og:image:url" content="https://docs.expo.dev/static/images/og.png" />
223          <meta
224            property="og:image:secure_url"
225            content="https://docs.expo.dev/static/images/og.png"
226          />
227          <meta property="og:locale" content="en_US" />
228          <meta property="og:site_name" content="Expo Documentation" />
229          <meta
230            property="og:description"
231            content="Expo is an open-source platform for making universal native apps for Android, iOS, and the web with JavaScript and React."
232          />
233
234          <meta name="twitter:site" content="@expo" />
235          <meta name="twitter:card" content="summary" />
236          <meta property="twitter:title" content={title} />
237          <meta
238            name="twitter:description"
239            content="Expo is an open-source platform for making universal native apps for Android, iOS, and the web with JavaScript and React."
240          />
241          <meta
242            property="twitter:image"
243            content="https://docs.expo.dev/static/images/twitter.png"
244          />
245
246          {(this.props.version === 'unversioned' ||
247            this.isPreviewPath() ||
248            this.isArchivePath()) && <meta name="robots" content="noindex" />}
249
250          {this.props.version !== 'unversioned' && (
251            <link rel="canonical" href={this.getCanonicalUrl()} />
252          )}
253        </Head>
254        <div css={STYLES_DOCUMENT}>{pageContent}</div>
255      </DocumentationNestedScrollLayout>
256    );
257  }
258}
259
260export default function DocumentationPage(props: Omit<Props, 'version'>) {
261  const { version } = usePageApiVersion();
262  return <DocumentationPageWithApiVersion {...props} version={version} />;
263}
264