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