1import { css } from '@emotion/react'; 2import { theme } from '@expo/styleguide'; 3import some from 'lodash/some'; 4import Router from 'next/router'; 5import NProgress from 'nprogress'; 6import * as React from 'react'; 7 8import * as Utilities from '~/common/utilities'; 9import * as WindowUtils from '~/common/window'; 10import DocumentationFooter from '~/components/DocumentationFooter'; 11import DocumentationHeader from '~/components/DocumentationHeader'; 12import DocumentationNestedScrollLayout from '~/components/DocumentationNestedScrollLayout'; 13import DocumentationPageContext from '~/components/DocumentationPageContext'; 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'; 21import * as Constants from '~/constants/theme'; 22import { VERSIONS } from '~/constants/versions'; 23import { NavigationRoute, Url } 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-top: 1px solid ${theme.border.default}; 32 border-bottom: 0px; 33 } 34 35 @media screen and (max-width: ${Constants.breakpoints.mobile}) { 36 padding: 20px 16px 48px 16px; 37 } 38`; 39 40const HIDDEN_ON_MOBILE = css` 41 @media screen and (max-width: ${Constants.breakpoints.mobile}) { 42 display: none; 43 } 44`; 45 46const HIDDEN_ON_DESKTOP = css` 47 @media screen and (min-width: ${Constants.breakpoints.mobile}) { 48 display: none; 49 } 50`; 51 52type Props = { 53 url: Url; 54 title: string; 55 asPath: string; 56 sourceCodeUrl?: string; 57 tocVisible: boolean; 58 /* If the page should not show up in the Algolia Docsearch results */ 59 hideFromSearch?: boolean; 60}; 61 62type State = { 63 isMenuActive: boolean; 64 isMobileSearchActive: boolean; 65}; 66 67export default class DocumentationPage extends React.Component<Props, State> { 68 state = { 69 isMenuActive: false, 70 isMobileSearchActive: false, 71 }; 72 73 private layoutRef = React.createRef<DocumentationNestedScrollLayout>(); 74 private sidebarRightRef = React.createRef<SidebarRightComponentType>(); 75 76 componentDidMount() { 77 Router.events.on('routeChangeStart', () => { 78 if (this.layoutRef.current) { 79 window.__sidebarScroll = this.layoutRef.current.getSidebarScrollTop(); 80 } 81 NProgress.start(); 82 }); 83 84 Router.events.on('routeChangeComplete', () => { 85 NProgress.done(); 86 }); 87 88 Router.events.on('routeChangeError', () => { 89 NProgress.done(); 90 }); 91 92 window.addEventListener('resize', this.handleResize); 93 } 94 95 componentWillUnmount() { 96 window.removeEventListener('resize', this.handleResize); 97 } 98 99 private handleResize = () => { 100 if (WindowUtils.getViewportSize().width >= Constants.breakpoints.mobileValue) { 101 window.scrollTo(0, 0); 102 } 103 }; 104 105 private handleSetVersion = (version: string) => { 106 let newPath = Utilities.replaceVersionInUrl(this.props.url.pathname, version); 107 108 if (!newPath.endsWith('/')) { 109 newPath += '/'; 110 } 111 112 // note: we can do this without validating if the page exists or not. 113 // the error page will redirect users to the versioned-index page when a page doesn't exists. 114 Router.push(newPath); 115 }; 116 117 private handleShowMenu = () => { 118 this.setState({ 119 isMenuActive: true, 120 }); 121 this.handleHideSearch(); 122 }; 123 124 private handleHideMenu = () => { 125 this.setState({ 126 isMenuActive: false, 127 }); 128 }; 129 130 private handleToggleSearch = () => { 131 this.setState(prevState => ({ 132 isMobileSearchActive: !prevState.isMobileSearchActive, 133 })); 134 }; 135 136 private handleHideSearch = () => { 137 this.setState({ 138 isMobileSearchActive: false, 139 }); 140 }; 141 142 private isReferencePath = () => { 143 return this.props.url.pathname.startsWith('/versions'); 144 }; 145 146 private isGeneralPath = () => { 147 return some(navigation.generalDirectories, name => 148 this.props.url.pathname.startsWith(`/${name}`) 149 ); 150 }; 151 152 private isGettingStartedPath = () => { 153 return ( 154 this.props.url.pathname === '/' || 155 some(navigation.startingDirectories, name => this.props.url.pathname.startsWith(`/${name}`)) 156 ); 157 }; 158 159 private isEasPath = () => { 160 return some(navigation.easDirectories, name => this.props.url.pathname.startsWith(`/${name}`)); 161 }; 162 163 private isPreviewPath = () => { 164 return some(navigation.previewDirectories, name => 165 this.props.url.pathname.startsWith(`/${name}`) 166 ); 167 }; 168 169 private getCanonicalUrl = () => { 170 if (this.isReferencePath()) { 171 return `https://docs.expo.io${Utilities.replaceVersionInUrl( 172 this.props.url.pathname, 173 'latest' 174 )}`; 175 } else { 176 return `https://docs.expo.io/${this.props.url.pathname}`; 177 } 178 }; 179 180 private getAlgoliaTag = () => { 181 if (this.props.hideFromSearch === true) { 182 return null; 183 } 184 185 return this.isReferencePath() ? this.getVersion() : 'none'; 186 }; 187 188 private getVersion = () => { 189 let version = (this.props.asPath || this.props.url.pathname).split(`/`)[2]; 190 if (!version || !VERSIONS.includes(version)) { 191 version = 'latest'; 192 } 193 return version; 194 }; 195 196 private getRoutes = (): NavigationRoute[] => { 197 if (this.isReferencePath()) { 198 const version = this.getVersion(); 199 return navigation.reference[version]; 200 } else { 201 return navigation[this.getActiveTopLevelSection()]; 202 } 203 }; 204 205 private getActiveTopLevelSection = () => { 206 if (this.isReferencePath()) { 207 return 'reference'; 208 } else if (this.isGeneralPath()) { 209 return 'general'; 210 } else if (this.isGettingStartedPath()) { 211 return 'starting'; 212 } else if (this.isEasPath()) { 213 return 'eas'; 214 } else if (this.isPreviewPath()) { 215 return 'preview'; 216 } 217 218 return 'general'; 219 }; 220 221 render() { 222 const sidebarScrollPosition = process.browser ? window.__sidebarScroll : 0; 223 224 const version = this.getVersion(); 225 const routes = this.getRoutes(); 226 227 const isReferencePath = this.isReferencePath(); 228 229 const headerElement = ( 230 <DocumentationHeader 231 activeSection={this.getActiveTopLevelSection()} 232 version={version} 233 isMenuActive={this.state.isMenuActive} 234 isMobileSearchActive={this.state.isMobileSearchActive} 235 isAlgoliaSearchHidden={this.state.isMenuActive} 236 onShowMenu={this.handleShowMenu} 237 onHideMenu={this.handleHideMenu} 238 onToggleSearch={this.handleToggleSearch} 239 /> 240 ); 241 242 const sidebarElement = ( 243 <DocumentationSidebar 244 url={this.props.url} 245 asPath={this.props.asPath} 246 routes={routes} 247 version={version} 248 onSetVersion={this.handleSetVersion} 249 isVersionSelectorHidden={!isReferencePath} 250 /> 251 ); 252 253 const handleContentScroll = (contentScrollPosition: number) => { 254 window.requestAnimationFrame(() => { 255 if (this.sidebarRightRef && this.sidebarRightRef.current) { 256 this.sidebarRightRef.current.handleContentScroll(contentScrollPosition); 257 } 258 }); 259 }; 260 261 const sidebarRight = <DocumentationSidebarRight ref={this.sidebarRightRef} />; 262 263 const algoliaTag = this.getAlgoliaTag(); 264 265 return ( 266 <DocumentationNestedScrollLayout 267 ref={this.layoutRef} 268 header={headerElement} 269 sidebar={sidebarElement} 270 sidebarRight={sidebarRight} 271 tocVisible={this.props.tocVisible} 272 isMenuActive={this.state.isMenuActive} 273 isMobileSearchActive={this.state.isMobileSearchActive} 274 onContentScroll={handleContentScroll} 275 sidebarScrollPosition={sidebarScrollPosition}> 276 <Head title={`${this.props.title} - Expo Documentation`}> 277 {algoliaTag !== null && <meta name="docsearch:version" content={algoliaTag} />} 278 <meta property="og:title" content={`${this.props.title} - Expo Documentation`} /> 279 <meta property="og:type" content="website" /> 280 <meta property="og:image" content="https://docs.expo.io/static/images/og.png" /> 281 <meta property="og:image:url" content="https://docs.expo.io/static/images/og.png" /> 282 <meta 283 property="og:image:secure_url" 284 content="https://docs.expo.io/static/images/og.png" 285 /> 286 <meta property="og:locale" content="en_US" /> 287 <meta property="og:site_name" content="Expo Documentation" /> 288 <meta 289 property="og:description" 290 content="Expo is an open-source platform for making universal native apps for Android, iOS, and the web with JavaScript and React." 291 /> 292 293 <meta name="twitter:site" content="@expo" /> 294 <meta name="twitter:card" content="summary" /> 295 <meta property="twitter:title" content={`${this.props.title} - Expo Documentation`} /> 296 <meta 297 name="twitter:description" 298 content="Expo is an open-source platform for making universal native apps for Android, iOS, and the web with JavaScript and React." 299 /> 300 <meta property="twitter:image" content="https://docs.expo.io/static/images/twitter.png" /> 301 302 {(version === 'unversioned' || this.isPreviewPath()) && ( 303 <meta name="robots" content="noindex" /> 304 )} 305 {version !== 'unversioned' && <link rel="canonical" href={this.getCanonicalUrl()} />} 306 </Head> 307 308 {!this.state.isMenuActive ? ( 309 <div css={STYLES_DOCUMENT}> 310 <H1>{this.props.title}</H1> 311 <DocumentationPageContext.Provider value={{ version }}> 312 {this.props.children} 313 </DocumentationPageContext.Provider> 314 <DocumentationFooter 315 title={this.props.title} 316 url={this.props.url} 317 asPath={this.props.asPath} 318 sourceCodeUrl={this.props.sourceCodeUrl} 319 /> 320 </div> 321 ) : ( 322 <div> 323 <div css={[STYLES_DOCUMENT, HIDDEN_ON_MOBILE]}> 324 <H1>{this.props.title}</H1> 325 <DocumentationPageContext.Provider value={{ version }}> 326 {this.props.children} 327 </DocumentationPageContext.Provider> 328 <DocumentationFooter 329 title={this.props.title} 330 asPath={this.props.asPath} 331 sourceCodeUrl={this.props.sourceCodeUrl} 332 /> 333 </div> 334 <div css={HIDDEN_ON_DESKTOP}> 335 <DocumentationSidebar 336 url={this.props.url} 337 asPath={this.props.asPath} 338 routes={routes} 339 version={version} 340 onSetVersion={this.handleSetVersion} 341 isVersionSelectorHidden={!isReferencePath} 342 /> 343 </div> 344 </div> 345 )} 346 </DocumentationNestedScrollLayout> 347 ); 348 } 349} 350