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