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 // TODO: Prevent starting the watcher. 33 const devServerManager = new DevServerManager(projectRoot, { 34 minify: options.minify, 35 mode: 'production', 36 location: {}, 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 [resources, { manifest, renderAsync }] = await Promise.all([ 98 devServer.getStaticResourcesAsync({ mode: 'production', minify }), 99 devServer.getStaticRenderFunctionAsync({ 100 mode: 'production', 101 minify, 102 }), 103 ]); 104 105 debug('Routes:\n', inspect(manifest, { colors: true, depth: null })); 106 107 const files = await getFilesToExportFromServerAsync(projectRoot, { 108 manifest, 109 async renderAsync(pathname: string) { 110 const template = await renderAsync(pathname); 111 let html = await devServer.composeResourcesWithHtml({ 112 mode: 'production', 113 resources, 114 template, 115 }); 116 117 if (injectFaviconTag) { 118 html = injectFaviconTag(html); 119 } 120 121 return html; 122 }, 123 }); 124 125 resources.forEach((resource) => { 126 files.set(resource.filename, resource.source); 127 }); 128 129 fs.mkdirSync(path.join(outputDir), { recursive: true }); 130 131 Log.log(''); 132 Log.log(chalk.bold`Exporting ${files.size} files:`); 133 await Promise.all( 134 [...files.entries()] 135 .sort(([a], [b]) => a.localeCompare(b)) 136 .map(async ([file, contents]) => { 137 const length = Buffer.byteLength(contents, 'utf8'); 138 Log.log(file, chalk.gray`(${prettyBytes(length)})`); 139 const outputPath = path.join(outputDir, file); 140 await fs.promises.mkdir(path.dirname(outputPath), { recursive: true }); 141 await fs.promises.writeFile(outputPath, contents); 142 }) 143 ); 144 Log.log(''); 145} 146 147export function getHtmlFiles({ manifest }: { manifest: any }): string[] { 148 const htmlFiles = new Set<string>(); 149 150 function traverseScreens(screens: string | { screens: any; path: string }, basePath = '') { 151 for (const value of Object.values(screens)) { 152 if (typeof value === 'string') { 153 let filePath = basePath + value; 154 if (value === '') { 155 filePath = 156 basePath === '' 157 ? 'index' 158 : basePath.endsWith('/') 159 ? basePath + 'index' 160 : basePath.slice(0, -1); 161 } 162 // TODO: Dedupe requests for alias routes. 163 addOptionalGroups(filePath); 164 } else if (typeof value === 'object' && value?.screens) { 165 const newPath = basePath + value.path + '/'; 166 traverseScreens(value.screens, newPath); 167 } 168 } 169 } 170 171 function addOptionalGroups(path: string) { 172 const variations = getPathVariations(path); 173 for (const variation of variations) { 174 htmlFiles.add(variation); 175 } 176 } 177 178 traverseScreens(manifest.screens); 179 180 return Array.from(htmlFiles).map((value) => { 181 const parts = value.split('/'); 182 // Replace `:foo` with `[foo]` and `*foo` with `[...foo]` 183 const partsWithGroups = parts.map((part) => { 184 if (part.startsWith(':')) { 185 return `[${part.slice(1)}]`; 186 } else if (part.startsWith('*')) { 187 return `[...${part.slice(1)}]`; 188 } 189 return part; 190 }); 191 return partsWithGroups.join('/') + '.html'; 192 }); 193} 194 195// Given a route like `(foo)/bar/(baz)`, return all possible variations of the route. 196// e.g. `(foo)/bar/(baz)`, `(foo)/bar/baz`, `foo/bar/(baz)`, `foo/bar/baz`, 197export function getPathVariations(routePath: string): string[] { 198 const variations = new Set<string>([routePath]); 199 const segments = routePath.split('/'); 200 201 function generateVariations(segments: string[], index: number): void { 202 if (index >= segments.length) { 203 return; 204 } 205 206 const newSegments = [...segments]; 207 while ( 208 index < newSegments.length && 209 matchGroupName(newSegments[index]) && 210 newSegments.length > 1 211 ) { 212 newSegments.splice(index, 1); 213 variations.add(newSegments.join('/')); 214 generateVariations(newSegments, index + 1); 215 } 216 217 generateVariations(segments, index + 1); 218 } 219 220 generateVariations(segments, 0); 221 222 return Array.from(variations); 223} 224