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