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