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