1import chalk from 'chalk';
2import fs from 'fs-extra';
3import inquirer, { QuestionCollection } from 'inquirer';
4import path from 'path';
5
6import { GitDirectory } from '../Git';
7import logger from '../Logger';
8import { Directories } from '../expotools';
9
10type Options = {
11  sdk: string;
12  from: string;
13  to: string;
14};
15
16type DocsSummary = {
17  removed: string[];
18  added: string[];
19  changed: string[];
20};
21
22const EXPO_DIR = Directories.getExpoRepositoryRootDir();
23const DOCS_DIR = path.join(EXPO_DIR, 'docs');
24const SDK_DOCS_DIR = path.join(DOCS_DIR, 'pages', 'versions');
25
26const RN_REPO_DIR = path.join(DOCS_DIR, 'react-native-website');
27const RN_WEBSITE_DIR = path.join(RN_REPO_DIR, 'website');
28const RN_DOCS_DIR = path.join(RN_REPO_DIR, 'docs');
29
30const PREFIX_ADDED = 'ADDED_';
31const PREFIX_REMOVED = 'REMOVED_';
32const SUFFIX_CHANGED = '.diff';
33
34const DOCS_IGNORED = [
35  'appregistry',
36  'components-and-apis',
37  'drawerlayoutandroid',
38  'linking',
39  'settings',
40  'systrace',
41];
42
43const rootRepo = new GitDirectory(path.resolve('.'));
44const rnRepo = new GitDirectory(RN_REPO_DIR);
45const rnDocsRepo = new GitDirectory(RN_DOCS_DIR);
46
47async function action(input: Options) {
48  const options = await getOptions(input);
49
50  if (!(await validateGitStatusAsync())) {
51    return;
52  }
53
54  await updateDocsAsync(options);
55
56  const summary = getDocsSummary(
57    await getLocalFilesAsync(options),
58    await getUpstreamFilesAsync(options)
59  );
60
61  logger.log();
62
63  await applyAddedFilesAsync(options, summary);
64  await applyChangedFilesAsync(options, summary);
65  await applyRemovedFilesAsync(options, summary);
66
67  logCompleted(options);
68}
69
70async function getOptions(input: Options): Promise<Options> {
71  const questions: QuestionCollection[] = [];
72  const existingSdks = (await fs.promises.readdir(SDK_DOCS_DIR, { withFileTypes: true }))
73    .filter((entry) => entry.isDirectory() && entry.name !== 'latest')
74    .map((entry) => entry.name.replace(/v([0-9]+)/, '$1'));
75
76  if (input.sdk && !existingSdks.includes(input.sdk)) {
77    throw new Error(
78      `SDK docs ${input.sdk} does not exist, please create it with "et generate-sdk-docs"`
79    );
80  }
81
82  if (!input.sdk) {
83    questions.push({
84      type: 'list',
85      name: 'sdk',
86      message: 'What Expo SDK version do you want to update?',
87      choices: existingSdks,
88    });
89  }
90
91  if (!input.from) {
92    questions.push({
93      type: 'input',
94      name: 'from',
95      message:
96        'From which commit of the React Native Website do you want to update? (e.g. 9806ddd)',
97      filter: (value: string) => value.trim(),
98      validate: (value: string) => value.length !== 0,
99    });
100  }
101
102  const answers = questions.length > 0 ? await inquirer.prompt(questions) : {};
103
104  return {
105    sdk: input.sdk === 'unversioned' ? 'unversioned' : `v${answers.sdk || input.sdk}`,
106    from: answers.from || input.from,
107    to: input.to || 'main',
108  };
109}
110
111async function validateGitStatusAsync() {
112  logger.info('\n�� Checking local repository status...');
113
114  const result = await rootRepo.runAsync(['status', '--porcelain']);
115  const status = result.stdout === '' ? 'clean' : 'dirty';
116
117  if (status === 'clean') {
118    return true;
119  }
120
121  logger.warn(`⚠️  Your git working tree is`, chalk.underline('dirty'));
122  logger.info(
123    `It's recommended to ${chalk.bold(
124      'commit all your changes before proceeding'
125    )}, so you can revert the changes made by this command if necessary.`
126  );
127
128  const { useDirtyGit } = await inquirer.prompt({
129    type: 'confirm',
130    name: 'useDirtyGit',
131    message: `Would you like to proceed?`,
132    default: false,
133  });
134
135  logger.log();
136
137  return useDirtyGit;
138}
139
140async function updateDocsAsync(options: Options) {
141  logger.info(`�� Updating ${chalk.cyan('react-native-website')} submodule...`);
142
143  await rnRepo.runAsync(['checkout', 'main']);
144  await rnRepo.pullAsync({});
145
146  if (!(await rnRepo.tryAsync(['checkout', options.from]))) {
147    throw new Error(`The --from commit "${options.from}" doesn't exists in the submodule.`);
148  }
149
150  if (!(await rnRepo.tryAsync(['checkout', options.to]))) {
151    throw new Error(`The --to commit "${options.to}" doesn't exists in the submodule.`);
152  }
153}
154
155async function getLocalFilesAsync(options: Options) {
156  logger.info('�� Resolving local docs from', chalk.underline(options.sdk), 'folder...');
157
158  const versionedDocsPath = path.join(SDK_DOCS_DIR, options.sdk, 'react-native');
159  const files = await fs.promises.readdir(versionedDocsPath);
160
161  return files
162    .filter(
163      (entry) =>
164        !entry.endsWith(SUFFIX_CHANGED) &&
165        !entry.startsWith(PREFIX_ADDED) &&
166        !entry.startsWith(PREFIX_REMOVED)
167    )
168    .map((entry) => entry.replace('.md', ''));
169}
170
171async function getUpstreamFilesAsync(options: Options) {
172  logger.info(
173    '�� Resolving upstream docs from',
174    chalk.underline('react-native-website'),
175    'submodule...'
176  );
177
178  const sidebarPath = path.join(RN_WEBSITE_DIR, 'sidebars.json');
179  const sidebarData = await fs.readJson(sidebarPath);
180
181  let relevantNestedDocs: any[] = [];
182  try {
183    relevantNestedDocs = [
184      ...sidebarData.api.APIs,
185      ...sidebarData.components['Core Components'],
186      ...sidebarData.components.Props,
187    ];
188  } catch (error) {
189    logger.error('\n�� There was an error extracting the sidebar information.');
190    logger.info(
191      'Please double-check the sidebar and update the "relevantNestedDocs" in this script.'
192    );
193    logger.info(chalk.dim(`./${path.relative(process.cwd(), sidebarPath)}\n`));
194    throw error;
195  }
196
197  const upstreamDocs: any[] = [];
198  const relevantDocs: any = relevantNestedDocs.map((entry) => {
199    if (typeof entry === 'object' && Array.isArray(entry.ids)) {
200      return entry.ids;
201    }
202
203    if (typeof entry === 'string') {
204      return entry;
205    }
206  });
207
208  for (const entry of relevantDocs.flat()) {
209    const docExists = await fs.pathExists(path.join(RN_DOCS_DIR, `${entry}.md`));
210    const docIsIgnored = DOCS_IGNORED.includes(entry);
211
212    if (docExists && !docIsIgnored) {
213      upstreamDocs.push(entry);
214    }
215  }
216
217  return upstreamDocs;
218}
219
220function getDocsSummary(localFiles: string[], upstreamFiles: string[]): DocsSummary {
221  const removed = localFiles.filter((entry) => !upstreamFiles.includes(entry));
222  const added = upstreamFiles.filter((entry) => !localFiles.includes(entry));
223
224  const changed = upstreamFiles.filter(
225    (entry) => !(removed.includes(entry) || added.includes(entry))
226  );
227
228  return { removed, added, changed };
229}
230
231async function applyRemovedFilesAsync(options: Options, summary: DocsSummary) {
232  if (!summary.removed.length) {
233    return logger.info('��‍ Upstream did not', chalk.red('remove'), 'any files');
234  }
235
236  for (const entry of summary.removed) {
237    if (entry.startsWith(PREFIX_REMOVED)) {
238      continue;
239    }
240
241    const sdkDocsDir = path.join(SDK_DOCS_DIR, options.sdk, 'react-native');
242
243    await fs.move(
244      path.join(sdkDocsDir, `${entry}.md`),
245      path.join(sdkDocsDir, `${PREFIX_REMOVED}${entry}.md`)
246    );
247  }
248
249  logger.info(
250    '➖ Upstream',
251    chalk.underline(`removed ${summary.removed.length} files`),
252    `see "${PREFIX_REMOVED}*.md" files.`
253  );
254}
255
256async function applyAddedFilesAsync(options: Options, summary: DocsSummary) {
257  if (!summary.added.length) {
258    return logger.info('��‍ Upstream did not', chalk.green('add'), 'any files');
259  }
260
261  for (const entry of summary.added) {
262    if (entry.startsWith(PREFIX_ADDED)) {
263      continue;
264    }
265
266    await fs.copyFile(
267      path.join(RN_DOCS_DIR, `${entry}.md`),
268      path.join(SDK_DOCS_DIR, options.sdk, 'react-native', `${PREFIX_ADDED}${entry}.md`)
269    );
270  }
271
272  logger.info(
273    `➕ Upstream ${chalk.underline(
274      `added ${summary.added.length} files`
275    )}, see "${PREFIX_ADDED}*.md" files.`
276  );
277}
278
279async function applyChangedFilesAsync(options: Options, summary: DocsSummary) {
280  if (!summary.changed.length) {
281    return logger.info('��‍ Upstream did not', chalk.yellow('change'), 'any files');
282  }
283
284  for (const entry of summary.changed) {
285    const diffPath = path.join(
286      SDK_DOCS_DIR,
287      options.sdk,
288      'react-native',
289      `${entry}${SUFFIX_CHANGED}`
290    );
291
292    const { output: diff } = await rnDocsRepo.runAsync([
293      'format-patch',
294      `${options.from}..HEAD`,
295      '--relative',
296      `${entry}.md`,
297      '--stdout',
298    ]);
299
300    await fs.writeFile(diffPath, diff.join(''));
301  }
302
303  logger.info(
304    '➗ Upstream',
305    chalk.underline(`changed ${summary.changed.length} files`),
306    `see "*${SUFFIX_CHANGED}" files.`
307  );
308}
309
310function logCompleted(options: Options): void {
311  const versionedDir = path.join(SDK_DOCS_DIR, options.sdk, 'react-native');
312
313  logger.success('\n✅ Update completed.');
314  logger.info('Please check the files in the versioned react-native folder.');
315  logger.info(
316    'To revert the changes, use `git clean -xdf .` and `git checkout .` in the versioned folder:'
317  );
318  logger.info(chalk.dim(`./${path.relative(process.cwd(), versionedDir)}\n`));
319}
320
321export default (program) => {
322  program
323    .command('update-react-native-docs')
324    .option('--sdk <string>', 'SDK version to merge with (e.g. `unversioned` or `37.0.0`)')
325    .option('--from <commit>', 'React Native Docs commit to start from')
326    .option('--to <commit>', 'React Native Docs commit to end at (defaults to `main`)')
327    .description(
328      `Fetches the React Native Docs changes in the commit range and create diffs to manually merge it.`
329    )
330    .asyncAction(action);
331};
332