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