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