1// Copyright 2016 The Chromium Authors. All rights reserved. 2// Use of this source code is governed by a BSD-style license that can be 3// found in the LICENSE file. 4 5import 'dart:async'; 6import 'dart:convert'; 7import 'dart:io'; 8 9import 'package:args/args.dart'; 10import 'package:intl/intl.dart'; 11import 'package:path/path.dart' as path; 12import 'package:process/process.dart'; 13 14const String kDocsRoot = 'dev/docs'; 15const String kPublishRoot = '$kDocsRoot/doc'; 16const String kSnippetsRoot = 'dev/snippets'; 17 18/// This script expects to run with the cwd as the root of the flutter repo. It 19/// will generate documentation for the packages in `//packages/` and write the 20/// documentation to `//dev/docs/doc/api/`. 21/// 22/// This script also updates the index.html file so that it can be placed 23/// at the root of docs.flutter.io. We are keeping the files inside of 24/// docs.flutter.io/flutter for now, so we need to manipulate paths 25/// a bit. See https://github.com/flutter/flutter/issues/3900 for more info. 26/// 27/// This will only work on UNIX systems, not Windows. It requires that 'git' be 28/// in your path. It requires that 'flutter' has been run previously. It uses 29/// the version of Dart downloaded by the 'flutter' tool in this repository and 30/// will crash if that is absent. 31Future<void> main(List<String> arguments) async { 32 final ArgParser argParser = _createArgsParser(); 33 final ArgResults args = argParser.parse(arguments); 34 if (args['help']) { 35 print ('Usage:'); 36 print (argParser.usage); 37 exit(0); 38 } 39 // If we're run from the `tools` dir, set the cwd to the repo root. 40 if (path.basename(Directory.current.path) == 'tools') 41 Directory.current = Directory.current.parent.parent; 42 43 final ProcessResult flutter = Process.runSync('flutter', <String>[]); 44 final File versionFile = File('version'); 45 if (flutter.exitCode != 0 || !versionFile.existsSync()) 46 throw Exception('Failed to determine Flutter version.'); 47 final String version = versionFile.readAsStringSync(); 48 49 // Create the pubspec.yaml file. 50 final StringBuffer buf = StringBuffer(); 51 buf.writeln('name: Flutter'); 52 buf.writeln('homepage: https://flutter.dev'); 53 // TODO(dnfield): We should make DartDoc able to avoid emitting this. If we 54 // use the real value here, every file will get marked as new instead of only 55 // files that have otherwise changed. Instead, we replace it dynamically using 56 // JavaScript so that fewer files get marked as changed. 57 // https://github.com/dart-lang/dartdoc/issues/1982 58 buf.writeln('version: 0.0.0'); 59 buf.writeln('dependencies:'); 60 for (String package in findPackageNames()) { 61 buf.writeln(' $package:'); 62 buf.writeln(' sdk: flutter'); 63 } 64 buf.writeln(' platform_integration: 0.0.1'); 65 buf.writeln('dependency_overrides:'); 66 buf.writeln(' platform_integration:'); 67 buf.writeln(' path: platform_integration'); 68 File('$kDocsRoot/pubspec.yaml').writeAsStringSync(buf.toString()); 69 70 // Create the library file. 71 final Directory libDir = Directory('$kDocsRoot/lib'); 72 libDir.createSync(); 73 74 final StringBuffer contents = StringBuffer('library temp_doc;\n\n'); 75 for (String libraryRef in libraryRefs()) { 76 contents.writeln('import \'package:$libraryRef\';'); 77 } 78 File('$kDocsRoot/lib/temp_doc.dart').writeAsStringSync(contents.toString()); 79 80 final String flutterRoot = Directory.current.path; 81 final Map<String, String> pubEnvironment = <String, String>{ 82 'FLUTTER_ROOT': flutterRoot, 83 }; 84 85 // If there's a .pub-cache dir in the flutter root, use that. 86 final String pubCachePath = '$flutterRoot/.pub-cache'; 87 if (Directory(pubCachePath).existsSync()) { 88 pubEnvironment['PUB_CACHE'] = pubCachePath; 89 } 90 91 final String pubExecutable = '$flutterRoot/bin/cache/dart-sdk/bin/pub'; 92 93 // Run pub. 94 ProcessWrapper process = ProcessWrapper(await Process.start( 95 pubExecutable, 96 <String>['get'], 97 workingDirectory: kDocsRoot, 98 environment: pubEnvironment, 99 )); 100 printStream(process.stdout, prefix: 'pub:stdout: '); 101 printStream(process.stderr, prefix: 'pub:stderr: '); 102 final int code = await process.done; 103 if (code != 0) 104 exit(code); 105 106 createFooter('$kDocsRoot/lib/', version); 107 copyAssets(); 108 createSearchMetadata('$kDocsRoot/lib/opensearch.xml', '$kDocsRoot/doc/opensearch.xml'); 109 cleanOutSnippets(); 110 111 final List<String> dartdocBaseArgs = <String>['global', 'run']; 112 if (args['checked']) { 113 dartdocBaseArgs.add('-c'); 114 } 115 dartdocBaseArgs.add('dartdoc'); 116 117 // Verify which version of dartdoc we're using. 118 final ProcessResult result = Process.runSync( 119 pubExecutable, 120 <String>[...dartdocBaseArgs, '--version'], 121 workingDirectory: kDocsRoot, 122 environment: pubEnvironment, 123 ); 124 print('\n${result.stdout}flutter version: $version\n'); 125 126 dartdocBaseArgs.add('--allow-tools'); 127 128 if (args['json']) { 129 dartdocBaseArgs.add('--json'); 130 } 131 if (args['validate-links']) { 132 dartdocBaseArgs.add('--validate-links'); 133 } else { 134 dartdocBaseArgs.add('--no-validate-links'); 135 } 136 dartdocBaseArgs.addAll(<String>['--link-to-source-excludes', '../../bin/cache', 137 '--link-to-source-root', '../..', 138 '--link-to-source-uri-template', 'https://github.com/flutter/flutter/blob/master/%f%#L%l%']); 139 // Generate the documentation. 140 // We don't need to exclude flutter_tools in this list because it's not in the 141 // recursive dependencies of the package defined at dev/docs/pubspec.yaml 142 final List<String> dartdocArgs = <String>[ 143 ...dartdocBaseArgs, 144 '--inject-html', 145 '--header', 'styles.html', 146 '--header', 'analytics.html', 147 '--header', 'survey.html', 148 '--header', 'snippets.html', 149 '--header', 'opensearch.html', 150 '--footer-text', 'lib/footer.html', 151 '--allow-warnings-in-packages', 152 <String>[ 153 'Flutter', 154 'flutter', 155 'platform_integration', 156 'flutter_test', 157 'flutter_driver', 158 'flutter_localizations', 159 ].join(','), 160 '--exclude-packages', 161 <String>[ 162 'analyzer', 163 'args', 164 'barback', 165 'cli_util', 166 'csslib', 167 'flutter_goldens', 168 'flutter_goldens_client', 169 'front_end', 170 'fuchsia_remote_debug_protocol', 171 'glob', 172 'html', 173 'http_multi_server', 174 'io', 175 'isolate', 176 'js', 177 'kernel', 178 'logging', 179 'mime', 180 'mockito', 181 'node_preamble', 182 'plugin', 183 'shelf', 184 'shelf_packages_handler', 185 'shelf_static', 186 'shelf_web_socket', 187 'utf', 188 'watcher', 189 'yaml', 190 ].join(','), 191 '--exclude', 192 <String>[ 193 'package:Flutter/temp_doc.dart', 194 'package:http/browser_client.dart', 195 'package:intl/intl_browser.dart', 196 'package:matcher/mirror_matchers.dart', 197 'package:quiver/io.dart', 198 'package:quiver/mirrors.dart', 199 'package:vm_service_client/vm_service_client.dart', 200 'package:web_socket_channel/html.dart', 201 ].join(','), 202 '--favicon=favicon.ico', 203 '--package-order', 'flutter,Dart,platform_integration,flutter_test,flutter_driver', 204 '--auto-include-dependencies', 205 ]; 206 207 String quote(String arg) => arg.contains(' ') ? "'$arg'" : arg; 208 print('Executing: (cd $kDocsRoot ; $pubExecutable ${dartdocArgs.map<String>(quote).join(' ')})'); 209 210 process = ProcessWrapper(await Process.start( 211 pubExecutable, 212 dartdocArgs, 213 workingDirectory: kDocsRoot, 214 environment: pubEnvironment, 215 )); 216 printStream(process.stdout, prefix: args['json'] ? '' : 'dartdoc:stdout: ', 217 filter: args['verbose'] ? const <Pattern>[] : <Pattern>[ 218 RegExp(r'^generating docs for library '), // unnecessary verbosity 219 RegExp(r'^pars'), // unnecessary verbosity 220 ], 221 ); 222 printStream(process.stderr, prefix: args['json'] ? '' : 'dartdoc:stderr: ', 223 filter: args['verbose'] ? const <Pattern>[] : <Pattern>[ 224 RegExp(r'^ warning: .+: \(.+/\.pub-cache/hosted/pub.dartlang.org/.+\)'), // packages outside our control 225 ], 226 ); 227 final int exitCode = await process.done; 228 229 if (exitCode != 0) 230 exit(exitCode); 231 232 sanityCheckDocs(); 233 234 createIndexAndCleanup(); 235} 236 237ArgParser _createArgsParser() { 238 final ArgParser parser = ArgParser(); 239 parser.addFlag('help', abbr: 'h', negatable: false, 240 help: 'Show command help.'); 241 parser.addFlag('verbose', negatable: true, defaultsTo: true, 242 help: 'Whether to report all error messages (on) or attempt to ' 243 'filter out some known false positives (off). Shut this off ' 244 'locally if you want to address Flutter-specific issues.'); 245 parser.addFlag('checked', abbr: 'c', negatable: true, 246 help: 'Run dartdoc in checked mode.'); 247 parser.addFlag('json', negatable: true, 248 help: 'Display json-formatted output from dartdoc and skip stdout/stderr prefixing.'); 249 parser.addFlag('validate-links', negatable: true, 250 help: 'Display warnings for broken links generated by dartdoc (slow)'); 251 return parser; 252} 253 254final RegExp gitBranchRegexp = RegExp(r'^## (.*)'); 255 256String getBranchName() { 257 final ProcessResult gitResult = Process.runSync('git', <String>['status', '-b', '--porcelain']); 258 if (gitResult.exitCode != 0) 259 throw 'git status exit with non-zero exit code: ${gitResult.exitCode}'; 260 final Match gitBranchMatch = gitBranchRegexp.firstMatch( 261 gitResult.stdout.trim().split('\n').first); 262 return gitBranchMatch == null ? '' : gitBranchMatch.group(1).split('...').first; 263} 264 265String gitRevision() { 266 const int kGitRevisionLength = 10; 267 268 final ProcessResult gitResult = Process.runSync('git', <String>['rev-parse', 'HEAD']); 269 if (gitResult.exitCode != 0) 270 throw 'git rev-parse exit with non-zero exit code: ${gitResult.exitCode}'; 271 final String gitRevision = gitResult.stdout.trim(); 272 273 return gitRevision.length > kGitRevisionLength ? gitRevision.substring(0, kGitRevisionLength) : gitRevision; 274} 275 276void createFooter(String footerPath, String version) { 277 final String timestamp = DateFormat('yyyy-MM-dd HH:mm').format(DateTime.now()); 278 final String gitBranch = getBranchName(); 279 final String gitBranchOut = gitBranch.isEmpty ? '' : '• $gitBranch'; 280 File('${footerPath}footer.html').writeAsStringSync('<script src="footer.js"></script>'); 281 File('$kPublishRoot/api/footer.js') 282 ..createSync(recursive: true) 283 ..writeAsStringSync('''(function() { 284 var span = document.querySelector('footer>span'); 285 if (span) { 286 span.innerText = 'Flutter $version • $timestamp • ${gitRevision()} $gitBranchOut'; 287 } 288 var sourceLink = document.querySelector('a.source-link'); 289 if (sourceLink) { 290 sourceLink.href = sourceLink.href.replace('/master/', '/${gitRevision()}/'); 291 } 292})(); 293'''); 294} 295 296/// Generates an OpenSearch XML description that can be used to add a custom 297/// search for Flutter API docs to the browser. Unfortunately, it has to know 298/// the URL to which site to search, so we customize it here based upon the 299/// branch name. 300void createSearchMetadata(String templatePath, String metadataPath) { 301 final String template = File(templatePath).readAsStringSync(); 302 final String branch = getBranchName(); 303 final String metadata = template.replaceAll( 304 '{SITE_URL}', 305 branch == 'stable' ? 'https://docs.flutter.io/' : 'https://master-docs.flutter.io/', 306 ); 307 Directory(path.dirname(metadataPath)).create(recursive: true); 308 File(metadataPath).writeAsStringSync(metadata); 309} 310 311/// Recursively copies `srcDir` to `destDir`, invoking [onFileCopied], if 312/// specified, for each source/destination file pair. 313/// 314/// Creates `destDir` if needed. 315void copyDirectorySync(Directory srcDir, Directory destDir, [void onFileCopied(File srcFile, File destFile)]) { 316 if (!srcDir.existsSync()) 317 throw Exception('Source directory "${srcDir.path}" does not exist, nothing to copy'); 318 319 if (!destDir.existsSync()) 320 destDir.createSync(recursive: true); 321 322 for (FileSystemEntity entity in srcDir.listSync()) { 323 final String newPath = path.join(destDir.path, path.basename(entity.path)); 324 if (entity is File) { 325 final File newFile = File(newPath); 326 entity.copySync(newPath); 327 onFileCopied?.call(entity, newFile); 328 } else if (entity is Directory) { 329 copyDirectorySync(entity, Directory(newPath)); 330 } else { 331 throw Exception('${entity.path} is neither File nor Directory'); 332 } 333 } 334} 335 336void copyAssets() { 337 final Directory assetsDir = Directory(path.join(kPublishRoot, 'assets')); 338 if (assetsDir.existsSync()) { 339 assetsDir.deleteSync(recursive: true); 340 } 341 copyDirectorySync( 342 Directory(path.join(kDocsRoot, 'assets')), 343 Directory(path.join(kPublishRoot, 'assets')), 344 (File src, File dest) => print('Copied ${src.path} to ${dest.path}')); 345} 346 347/// Clean out any existing snippets so that we don't publish old files from 348/// previous runs accidentally. 349void cleanOutSnippets() { 350 final Directory snippetsDir = Directory(path.join(kPublishRoot, 'snippets')); 351 if (snippetsDir.existsSync()) { 352 snippetsDir 353 ..deleteSync(recursive: true) 354 ..createSync(recursive: true); 355 } 356} 357 358void sanityCheckDocs() { 359 final List<String> canaries = <String>[ 360 '$kPublishRoot/assets/overrides.css', 361 '$kPublishRoot/api/dart-io/File-class.html', 362 '$kPublishRoot/api/dart-ui/Canvas-class.html', 363 '$kPublishRoot/api/dart-ui/Canvas/drawRect.html', 364 '$kPublishRoot/api/flutter_driver/FlutterDriver/FlutterDriver.connectedTo.html', 365 '$kPublishRoot/api/flutter_test/WidgetTester/pumpWidget.html', 366 '$kPublishRoot/api/material/Material-class.html', 367 '$kPublishRoot/api/material/Tooltip-class.html', 368 '$kPublishRoot/api/widgets/Widget-class.html', 369 ]; 370 for (String canary in canaries) { 371 if (!File(canary).existsSync()) 372 throw Exception('Missing "$canary", which probably means the documentation failed to build correctly.'); 373 } 374} 375 376/// Creates a custom index.html because we try to maintain old 377/// paths. Cleanup unused index.html files no longer needed. 378void createIndexAndCleanup() { 379 print('\nCreating a custom index.html in $kPublishRoot/index.html'); 380 removeOldFlutterDocsDir(); 381 renameApiDir(); 382 copyIndexToRootOfDocs(); 383 addHtmlBaseToIndex(); 384 changePackageToSdkInTitlebar(); 385 putRedirectInOldIndexLocation(); 386 writeSnippetsIndexFile(); 387 print('\nDocs ready to go!'); 388} 389 390void removeOldFlutterDocsDir() { 391 try { 392 Directory('$kPublishRoot/flutter').deleteSync(recursive: true); 393 } on FileSystemException { 394 // If the directory does not exist, that's OK. 395 } 396} 397 398void renameApiDir() { 399 Directory('$kPublishRoot/api').renameSync('$kPublishRoot/flutter'); 400} 401 402void copyIndexToRootOfDocs() { 403 File('$kPublishRoot/flutter/index.html').copySync('$kPublishRoot/index.html'); 404} 405 406void changePackageToSdkInTitlebar() { 407 final File indexFile = File('$kPublishRoot/index.html'); 408 String indexContents = indexFile.readAsStringSync(); 409 indexContents = indexContents.replaceFirst( 410 '<li><a href="https://flutter.dev">Flutter package</a></li>', 411 '<li><a href="https://flutter.dev">Flutter SDK</a></li>', 412 ); 413 414 indexFile.writeAsStringSync(indexContents); 415} 416 417void addHtmlBaseToIndex() { 418 final File indexFile = File('$kPublishRoot/index.html'); 419 String indexContents = indexFile.readAsStringSync(); 420 indexContents = indexContents.replaceFirst( 421 '</title>\n', 422 '</title>\n <base href="./flutter/">\n', 423 ); 424 indexContents = indexContents.replaceAll( 425 'href="Android/Android-library.html"', 426 'href="/javadoc/"', 427 ); 428 indexContents = indexContents.replaceAll( 429 'href="iOS/iOS-library.html"', 430 'href="/objcdoc/"', 431 ); 432 433 indexFile.writeAsStringSync(indexContents); 434} 435 436void putRedirectInOldIndexLocation() { 437 const String metaTag = '<meta http-equiv="refresh" content="0;URL=../index.html">'; 438 File('$kPublishRoot/flutter/index.html').writeAsStringSync(metaTag); 439} 440 441 442void writeSnippetsIndexFile() { 443 final Directory snippetsDir = Directory(path.join(kPublishRoot, 'snippets')); 444 if (snippetsDir.existsSync()) { 445 const JsonEncoder jsonEncoder = JsonEncoder.withIndent(' '); 446 final Iterable<File> files = snippetsDir 447 .listSync() 448 .whereType<File>() 449 .where((File file) => path.extension(file.path) == '.json'); 450 // Combine all the metadata into a single JSON array. 451 final Iterable<String> fileContents = files.map((File file) => file.readAsStringSync()); 452 final List<dynamic> metadataObjects = fileContents.map<dynamic>(json.decode).toList(); 453 final String jsonArray = jsonEncoder.convert(metadataObjects); 454 File('$kPublishRoot/snippets/index.json').writeAsStringSync(jsonArray); 455 } 456} 457 458List<String> findPackageNames() { 459 return findPackages().map<String>((FileSystemEntity file) => path.basename(file.path)).toList(); 460} 461 462/// Finds all packages in the Flutter SDK 463List<FileSystemEntity> findPackages() { 464 return Directory('packages') 465 .listSync() 466 .where((FileSystemEntity entity) { 467 if (entity is! Directory) 468 return false; 469 final File pubspec = File('${entity.path}/pubspec.yaml'); 470 // TODO(ianh): Use a real YAML parser here 471 return !pubspec.readAsStringSync().contains('nodoc: true'); 472 }) 473 .cast<Directory>() 474 .toList(); 475} 476 477/// Returns import or on-disk paths for all libraries in the Flutter SDK. 478Iterable<String> libraryRefs() sync* { 479 for (Directory dir in findPackages()) { 480 final String dirName = path.basename(dir.path); 481 for (FileSystemEntity file in Directory('${dir.path}/lib').listSync()) { 482 if (file is File && file.path.endsWith('.dart')) { 483 yield '$dirName/${path.basename(file.path)}'; 484 } 485 } 486 } 487 488 // Add a fake package for platform integration APIs. 489 yield 'platform_integration/android.dart'; 490 yield 'platform_integration/ios.dart'; 491} 492 493void printStream(Stream<List<int>> stream, { String prefix = '', List<Pattern> filter = const <Pattern>[] }) { 494 assert(prefix != null); 495 assert(filter != null); 496 stream 497 .transform<String>(utf8.decoder) 498 .transform<String>(const LineSplitter()) 499 .listen((String line) { 500 if (!filter.any((Pattern pattern) => line.contains(pattern))) 501 print('$prefix$line'.trim()); 502 }); 503} 504