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