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