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