1/** 2 * Copyright © 2022 650 Industries. 3 * 4 * This source code is licensed under the MIT license found in the 5 * LICENSE file in the root directory of this source tree. 6 */ 7import assert from 'assert'; 8import chalk from 'chalk'; 9import fs from 'fs'; 10import path from 'path'; 11import prettyBytes from 'pretty-bytes'; 12import { inspect } from 'util'; 13 14import { getVirtualFaviconAssetsAsync } from './favicon'; 15import { Log } from '../log'; 16import { DevServerManager } from '../start/server/DevServerManager'; 17import { MetroBundlerDevServer } from '../start/server/metro/MetroBundlerDevServer'; 18import { logMetroErrorAsync } from '../start/server/metro/metroErrorInterface'; 19import { learnMore } from '../utils/link'; 20 21const debug = require('debug')('expo:export:generateStaticRoutes') as typeof console.log; 22 23type Options = { outputDir: string; minify: boolean }; 24 25/** @private */ 26export async function unstable_exportStaticAsync(projectRoot: string, options: Options) { 27 Log.warn( 28 `Experimental static rendering is enabled. ` + 29 learnMore('https://docs.expo.dev/router/reference/static-rendering/') 30 ); 31 32 const devServerManager = new DevServerManager(projectRoot, { 33 minify: options.minify, 34 mode: 'production', 35 location: {}, 36 }); 37 38 await devServerManager.startAsync([ 39 { 40 type: 'metro', 41 }, 42 ]); 43 44 await exportFromServerAsync(projectRoot, devServerManager, options); 45 46 await devServerManager.stopAsync(); 47} 48 49/** Match `(page)` -> `page` */ 50function matchGroupName(name: string): string | undefined { 51 return name.match(/^\(([^/]+?)\)$/)?.[1]; 52} 53 54export async function getFilesToExportFromServerAsync( 55 projectRoot: string, 56 { 57 manifest, 58 renderAsync, 59 }: { 60 manifest: any; 61 renderAsync: (pathname: string) => Promise<string>; 62 } 63): Promise<Map<string, string>> { 64 // name : contents 65 const files = new Map<string, string>(); 66 67 await Promise.all( 68 getHtmlFiles({ manifest }).map(async (outputPath) => { 69 const pathname = outputPath.replace(/(index)?\.html$/, ''); 70 try { 71 files.set(outputPath, ''); 72 const data = await renderAsync(pathname); 73 files.set(outputPath, data); 74 } catch (e: any) { 75 await logMetroErrorAsync({ error: e, projectRoot }); 76 throw new Error('Failed to statically export route: ' + pathname); 77 } 78 }) 79 ); 80 81 return files; 82} 83 84/** Perform all fs commits */ 85export async function exportFromServerAsync( 86 projectRoot: string, 87 devServerManager: DevServerManager, 88 { outputDir, minify }: Options 89): Promise<void> { 90 const injectFaviconTag = await getVirtualFaviconAssetsAsync(projectRoot, outputDir); 91 92 const devServer = devServerManager.getDefaultDevServer(); 93 assert(devServer instanceof MetroBundlerDevServer); 94 95 const [manifest, resources, renderAsync] = await Promise.all([ 96 devServer.getRoutesAsync(), 97 devServer.getStaticResourcesAsync({ mode: 'production', minify }), 98 devServer.getStaticRenderFunctionAsync({ 99 mode: 'production', 100 minify, 101 }), 102 ]); 103 104 debug('Routes:\n', inspect(manifest, { colors: true, depth: null })); 105 106 const files = await getFilesToExportFromServerAsync(projectRoot, { 107 manifest, 108 async renderAsync(pathname: string) { 109 const template = await renderAsync(pathname); 110 let html = await devServer.composeResourcesWithHtml({ 111 mode: 'production', 112 resources, 113 template, 114 }); 115 116 if (injectFaviconTag) { 117 html = injectFaviconTag(html); 118 } 119 120 return html; 121 }, 122 }); 123 124 resources.forEach((resource) => { 125 files.set(resource.filename, resource.source); 126 }); 127 128 fs.mkdirSync(path.join(outputDir), { recursive: true }); 129 130 Log.log(''); 131 Log.log(chalk.bold`Exporting ${files.size} files:`); 132 await Promise.all( 133 [...files.entries()] 134 .sort(([a], [b]) => a.localeCompare(b)) 135 .map(async ([file, contents]) => { 136 const length = Buffer.byteLength(contents, 'utf8'); 137 Log.log(file, chalk.gray`(${prettyBytes(length)})`); 138 const outputPath = path.join(outputDir, file); 139 await fs.promises.mkdir(path.dirname(outputPath), { recursive: true }); 140 await fs.promises.writeFile(outputPath, contents); 141 }) 142 ); 143 Log.log(''); 144} 145 146export function getHtmlFiles({ manifest }: { manifest: any }): string[] { 147 const htmlFiles = new Set<string>(); 148 149 function traverseScreens(screens: string | { screens: any; path: string }, basePath = '') { 150 for (const value of Object.values(screens)) { 151 if (typeof value === 'string') { 152 let filePath = basePath + value; 153 if (value === '') { 154 filePath = 155 basePath === '' 156 ? 'index' 157 : basePath.endsWith('/') 158 ? basePath + 'index' 159 : basePath.slice(0, -1); 160 } 161 // TODO: Dedupe requests for alias routes. 162 addOptionalGroups(filePath); 163 } else if (typeof value === 'object' && value?.screens) { 164 const newPath = basePath + value.path + '/'; 165 traverseScreens(value.screens, newPath); 166 } 167 } 168 } 169 170 function addOptionalGroups(path: string) { 171 const variations = getPathVariations(path); 172 for (const variation of variations) { 173 htmlFiles.add(variation); 174 } 175 } 176 177 traverseScreens(manifest.screens); 178 179 return Array.from(htmlFiles).map((value) => { 180 const parts = value.split('/'); 181 // Replace `:foo` with `[foo]` and `*foo` with `[...foo]` 182 const partsWithGroups = parts.map((part) => { 183 if (part.startsWith(':')) { 184 return `[${part.slice(1)}]`; 185 } else if (part.startsWith('*')) { 186 return `[...${part.slice(1)}]`; 187 } 188 return part; 189 }); 190 return partsWithGroups.join('/') + '.html'; 191 }); 192} 193 194// Given a route like `(foo)/bar/(baz)`, return all possible variations of the route. 195// e.g. `(foo)/bar/(baz)`, `(foo)/bar/baz`, `foo/bar/(baz)`, `foo/bar/baz`, 196export function getPathVariations(routePath: string): string[] { 197 const variations = new Set<string>([routePath]); 198 const segments = routePath.split('/'); 199 200 function generateVariations(segments: string[], index: number): void { 201 if (index >= segments.length) { 202 return; 203 } 204 205 const newSegments = [...segments]; 206 while ( 207 index < newSegments.length && 208 matchGroupName(newSegments[index]) && 209 newSegments.length > 1 210 ) { 211 newSegments.splice(index, 1); 212 variations.add(newSegments.join('/')); 213 generateVariations(newSegments, index + 1); 214 } 215 216 generateVariations(segments, index + 1); 217 } 218 219 generateVariations(segments, 0); 220 221 return Array.from(variations); 222} 223