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  /** 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          <Footer title={this.props.title} sourceCodeUrl={this.props.sourceCodeUrl} />
197        )}
198      </>
199    );
200
201    return (
202      <DocumentationNestedScrollLayout
203        ref={this.layoutRef}
204        header={headerElement}
205        sidebar={sidebarElement}
206        sidebarActiveGroup={sidebarActiveGroup}
207        sidebarRight={sidebarRight}
208        tocVisible={this.props.tocVisible}
209        isMobileMenuVisible={this.state.isMobileMenuVisible}
210        onContentScroll={handleContentScroll}
211        sidebarScrollPosition={sidebarScrollPosition}>
212        <Head title={this.props.title}>
213          {algoliaTag !== null && <meta name="docsearch:version" content={algoliaTag} />}
214          <meta property="og:title" content={title} />
215          <meta property="og:type" content="website" />
216          <meta property="og:image" content="https://docs.expo.dev/static/images/og.png" />
217          <meta property="og:image:url" content="https://docs.expo.dev/static/images/og.png" />
218          <meta
219            property="og:image:secure_url"
220            content="https://docs.expo.dev/static/images/og.png"
221          />
222          <meta property="og:locale" content="en_US" />
223          <meta property="og:site_name" content="Expo Documentation" />
224          <meta
225            property="og:description"
226            content="Expo is an open-source platform for making universal native apps for Android, iOS, and the web with JavaScript and React."
227          />
228
229          <meta name="twitter:site" content="@expo" />
230          <meta name="twitter:card" content="summary" />
231          <meta property="twitter:title" content={title} />
232          <meta
233            name="twitter:description"
234            content="Expo is an open-source platform for making universal native apps for Android, iOS, and the web with JavaScript and React."
235          />
236          <meta
237            property="twitter:image"
238            content="https://docs.expo.dev/static/images/twitter.png"
239          />
240
241          {(this.props.version === 'unversioned' ||
242            this.isPreviewPath() ||
243            this.isArchivePath()) && <meta name="robots" content="noindex" />}
244
245          {this.props.version !== 'unversioned' && (
246            <link rel="canonical" href={this.getCanonicalUrl()} />
247          )}
248        </Head>
249        <div css={STYLES_DOCUMENT}>{pageContent}</div>
250      </DocumentationNestedScrollLayout>
251    );
252  }
253}
254
255export default function DocumentationPage(props: Omit<Props, 'version'>) {
256  const { version } = usePageApiVersion();
257  return <DocumentationPageWithApiVersion {...props} version={version} />;
258}
259