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