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