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 isFeaturePreviewPath = () => { 160 return some(navigation.featurePreviewDirectories, name => 161 this.props.url.pathname.startsWith(`/${name}`) 162 ); 163 }; 164 165 private isPreviewPath = () => { 166 return some(navigation.previewDirectories, name => 167 this.props.url.pathname.startsWith(`/${name}`) 168 ); 169 }; 170 171 private getCanonicalUrl = () => { 172 if (this.isReferencePath()) { 173 return `https://docs.expo.dev${Utilities.replaceVersionInUrl( 174 this.props.url.pathname, 175 'latest' 176 )}`; 177 } else { 178 return `https://docs.expo.dev${this.props.url.pathname}`; 179 } 180 }; 181 182 private getAlgoliaTag = () => { 183 if (this.props.hideFromSearch === true) { 184 return null; 185 } 186 187 return this.isReferencePath() ? this.getVersion() : 'none'; 188 }; 189 190 private getVersion = () => { 191 let version = (this.props.asPath || this.props.url.pathname).split(`/`)[2]; 192 if (!version || !VERSIONS.includes(version)) { 193 version = 'latest'; 194 } 195 return version; 196 }; 197 198 private getRoutes = (): NavigationRoute[] => { 199 if (this.isReferencePath()) { 200 const version = this.getVersion(); 201 return navigation.reference[version]; 202 } else { 203 return navigation[this.getActiveTopLevelSection()]; 204 } 205 }; 206 207 private getActiveTopLevelSection = () => { 208 if (this.isReferencePath()) { 209 return 'reference'; 210 } else if (this.isGeneralPath()) { 211 return 'general'; 212 } else if (this.isGettingStartedPath()) { 213 return 'starting'; 214 } else if (this.isFeaturePreviewPath()) { 215 return 'featurePreview'; 216 } else if (this.isPreviewPath()) { 217 return 'preview'; 218 } 219 220 return 'general'; 221 }; 222 223 render() { 224 const sidebarScrollPosition = process.browser ? window.__sidebarScroll : 0; 225 226 const version = this.getVersion(); 227 const routes = this.getRoutes(); 228 229 const isReferencePath = this.isReferencePath(); 230 231 const headerElement = ( 232 <DocumentationHeader 233 activeSection={this.getActiveTopLevelSection()} 234 version={version} 235 isMenuActive={this.state.isMenuActive} 236 isMobileSearchActive={this.state.isMobileSearchActive} 237 isAlgoliaSearchHidden={this.state.isMenuActive} 238 onShowMenu={this.handleShowMenu} 239 onHideMenu={this.handleHideMenu} 240 onToggleSearch={this.handleToggleSearch} 241 /> 242 ); 243 244 const sidebarElement = ( 245 <DocumentationSidebar 246 url={this.props.url} 247 asPath={this.props.asPath} 248 routes={routes} 249 version={version} 250 onSetVersion={this.handleSetVersion} 251 isVersionSelectorHidden={!isReferencePath} 252 /> 253 ); 254 255 const handleContentScroll = (contentScrollPosition: number) => { 256 window.requestAnimationFrame(() => { 257 if (this.sidebarRightRef && this.sidebarRightRef.current) { 258 this.sidebarRightRef.current.handleContentScroll(contentScrollPosition); 259 } 260 }); 261 }; 262 263 const sidebarRight = <DocumentationSidebarRight ref={this.sidebarRightRef} />; 264 265 const algoliaTag = this.getAlgoliaTag(); 266 267 return ( 268 <DocumentationNestedScrollLayout 269 ref={this.layoutRef} 270 header={headerElement} 271 sidebar={sidebarElement} 272 sidebarRight={sidebarRight} 273 tocVisible={this.props.tocVisible} 274 isMenuActive={this.state.isMenuActive} 275 isMobileSearchActive={this.state.isMobileSearchActive} 276 onContentScroll={handleContentScroll} 277 sidebarScrollPosition={sidebarScrollPosition}> 278 <Head title={`${this.props.title} - Expo Documentation`}> 279 {algoliaTag !== null && <meta name="docsearch:version" content={algoliaTag} />} 280 <meta property="og:title" content={`${this.props.title} - Expo Documentation`} /> 281 <meta property="og:type" content="website" /> 282 <meta property="og:image" content="https://docs.expo.dev/static/images/og.png" /> 283 <meta property="og:image:url" content="https://docs.expo.dev/static/images/og.png" /> 284 <meta 285 property="og:image:secure_url" 286 content="https://docs.expo.dev/static/images/og.png" 287 /> 288 <meta property="og:locale" content="en_US" /> 289 <meta property="og:site_name" content="Expo Documentation" /> 290 <meta 291 property="og:description" 292 content="Expo is an open-source platform for making universal native apps for Android, iOS, and the web with JavaScript and React." 293 /> 294 295 <meta name="twitter:site" content="@expo" /> 296 <meta name="twitter:card" content="summary" /> 297 <meta property="twitter:title" content={`${this.props.title} - Expo Documentation`} /> 298 <meta 299 name="twitter: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 <meta 303 property="twitter:image" 304 content="https://docs.expo.dev/static/images/twitter.png" 305 /> 306 307 {(version === 'unversioned' || this.isPreviewPath()) && ( 308 <meta name="robots" content="noindex" /> 309 )} 310 {version !== 'unversioned' && <link rel="canonical" href={this.getCanonicalUrl()} />} 311 </Head> 312 313 {!this.state.isMenuActive ? ( 314 <div css={STYLES_DOCUMENT}> 315 <H1>{this.props.title}</H1> 316 <DocumentationPageContext.Provider value={{ version }}> 317 {this.props.children} 318 </DocumentationPageContext.Provider> 319 <DocumentationFooter 320 title={this.props.title} 321 url={this.props.url} 322 asPath={this.props.asPath} 323 sourceCodeUrl={this.props.sourceCodeUrl} 324 /> 325 </div> 326 ) : ( 327 <div> 328 <div css={[STYLES_DOCUMENT, HIDDEN_ON_MOBILE]}> 329 <H1>{this.props.title}</H1> 330 <DocumentationPageContext.Provider value={{ version }}> 331 {this.props.children} 332 </DocumentationPageContext.Provider> 333 <DocumentationFooter 334 title={this.props.title} 335 asPath={this.props.asPath} 336 sourceCodeUrl={this.props.sourceCodeUrl} 337 /> 338 </div> 339 <div css={HIDDEN_ON_DESKTOP}> 340 <DocumentationSidebar 341 url={this.props.url} 342 asPath={this.props.asPath} 343 routes={routes} 344 version={version} 345 onSetVersion={this.handleSetVersion} 346 isVersionSelectorHidden={!isReferencePath} 347 /> 348 </div> 349 </div> 350 )} 351 </DocumentationNestedScrollLayout> 352 ); 353 } 354} 355