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 isGeneralPath = () => { 126 return some(navigation.generalDirectories, name => 127 this.props.router.pathname.startsWith(`/${name}`) 128 ); 129 }; 130 131 private isGettingStartedPath = () => { 132 return ( 133 this.props.router.pathname === '/' || 134 some(navigation.startingDirectories, name => 135 this.props.router.pathname.startsWith(`/${name}`) 136 ) 137 ); 138 }; 139 140 private isFeaturePreviewPath = () => { 141 return some(navigation.featurePreviewDirectories, name => 142 this.props.router.pathname.startsWith(`/${name}`) 143 ); 144 }; 145 146 private isPreviewPath = () => { 147 return some(navigation.previewDirectories, name => 148 this.props.router.pathname.startsWith(`/${name}`) 149 ); 150 }; 151 152 private isEasPath = () => { 153 return some(navigation.easDirectories, name => 154 this.props.router.pathname.startsWith(`/${name}`) 155 ); 156 }; 157 158 private getCanonicalUrl = () => { 159 if (this.isReferencePath()) { 160 return `https://docs.expo.dev${Utilities.replaceVersionInUrl( 161 this.props.router.pathname, 162 'latest' 163 )}`; 164 } else { 165 return `https://docs.expo.dev${this.props.router.pathname}`; 166 } 167 }; 168 169 private getAlgoliaTag = () => { 170 if (this.props.hideFromSearch === true) { 171 return null; 172 } 173 174 return this.isReferencePath() ? this.props.version : 'none'; 175 }; 176 177 private getRoutes = (): NavigationRoute[] => { 178 if (this.isReferencePath()) { 179 return navigation.reference[this.props.version]; 180 } else { 181 return navigation[this.getActiveTopLevelSection()]; 182 } 183 }; 184 185 private getActiveTopLevelSection = () => { 186 if (this.isReferencePath()) { 187 return 'reference'; 188 } else if (this.isGeneralPath()) { 189 return 'general'; 190 } else if (this.isGettingStartedPath()) { 191 return 'starting'; 192 } else if (this.isFeaturePreviewPath()) { 193 return 'featurePreview'; 194 } else if (this.isPreviewPath()) { 195 return 'preview'; 196 } else if (this.isEasPath()) { 197 return 'eas'; 198 } 199 200 return 'general'; 201 }; 202 203 render() { 204 const sidebarScrollPosition = process.browser ? window.__sidebarScroll : 0; 205 const routes = this.getRoutes(); 206 207 const headerElement = ( 208 <DocumentationHeader 209 activeSection={this.getActiveTopLevelSection()} 210 isMenuActive={this.state.isMenuActive} 211 isMobileSearchActive={this.state.isMobileSearchActive} 212 isAlgoliaSearchHidden={this.state.isMenuActive} 213 onShowMenu={this.handleShowMenu} 214 onHideMenu={this.handleHideMenu} 215 onToggleSearch={this.handleToggleSearch} 216 /> 217 ); 218 219 const sidebarElement = <DocumentationSidebar router={this.props.router} routes={routes} />; 220 221 const handleContentScroll = (contentScrollPosition: number) => { 222 window.requestAnimationFrame(() => { 223 if (this.sidebarRightRef && this.sidebarRightRef.current) { 224 this.sidebarRightRef.current.handleContentScroll(contentScrollPosition); 225 } 226 }); 227 }; 228 229 const sidebarRight = <DocumentationSidebarRight ref={this.sidebarRightRef} />; 230 231 const algoliaTag = this.getAlgoliaTag(); 232 const title = this.props.title 233 ? `${this.props.title} - Expo Documentation` 234 : `Expo Documentation`; 235 236 const pageContent = ( 237 <> 238 {this.props.title && <H1>{this.props.title}</H1>} 239 <AppJSBanner /> 240 {this.props.children} 241 {this.props.title && ( 242 <DocumentationFooter 243 router={this.props.router} 244 title={this.props.title} 245 sourceCodeUrl={this.props.sourceCodeUrl} 246 /> 247 )} 248 </> 249 ); 250 251 return ( 252 <DocumentationNestedScrollLayout 253 ref={this.layoutRef} 254 header={headerElement} 255 sidebar={sidebarElement} 256 sidebarRight={sidebarRight} 257 tocVisible={this.props.tocVisible} 258 isMenuActive={this.state.isMenuActive} 259 isMobileSearchActive={this.state.isMobileSearchActive} 260 onContentScroll={handleContentScroll} 261 sidebarScrollPosition={sidebarScrollPosition}> 262 <Head title={this.props.title}> 263 {algoliaTag !== null && <meta name="docsearch:version" content={algoliaTag} />} 264 <meta property="og:title" content={title} /> 265 <meta property="og:type" content="website" /> 266 <meta property="og:image" content="https://docs.expo.dev/static/images/og.png" /> 267 <meta property="og:image:url" content="https://docs.expo.dev/static/images/og.png" /> 268 <meta 269 property="og:image:secure_url" 270 content="https://docs.expo.dev/static/images/og.png" 271 /> 272 <meta property="og:locale" content="en_US" /> 273 <meta property="og:site_name" content="Expo Documentation" /> 274 <meta 275 property="og:description" 276 content="Expo is an open-source platform for making universal native apps for Android, iOS, and the web with JavaScript and React." 277 /> 278 279 <meta name="twitter:site" content="@expo" /> 280 <meta name="twitter:card" content="summary" /> 281 <meta property="twitter:title" content={title} /> 282 <meta 283 name="twitter:description" 284 content="Expo is an open-source platform for making universal native apps for Android, iOS, and the web with JavaScript and React." 285 /> 286 <meta 287 property="twitter:image" 288 content="https://docs.expo.dev/static/images/twitter.png" 289 /> 290 291 {(this.props.version === 'unversioned' || this.isPreviewPath()) && ( 292 <meta name="robots" content="noindex" /> 293 )} 294 {this.props.version !== 'unversioned' && ( 295 <link rel="canonical" href={this.getCanonicalUrl()} /> 296 )} 297 </Head> 298 299 {!this.state.isMenuActive ? ( 300 <div css={STYLES_DOCUMENT}>{pageContent}</div> 301 ) : ( 302 <div> 303 <div css={[STYLES_DOCUMENT, HIDDEN_ON_MOBILE]}>{pageContent}</div> 304 <div css={HIDDEN_ON_DESKTOP}> 305 <DocumentationSidebar router={this.props.router} routes={routes} /> 306 </div> 307 </div> 308 )} 309 </DocumentationNestedScrollLayout> 310 ); 311 } 312} 313 314export default function DocumentationPage(props: Omit<Props, 'version'>) { 315 const { version } = usePageApiVersion(); 316 return <DocumentationPageWithApiVersion {...props} version={version} />; 317} 318