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