| // Copyright 2014 The Flutter Authors. All rights reserved. | |
| // Use of this source code is governed by a BSD-style license that can be | |
| // found in the LICENSE file. | |
| import 'dart:convert'; | |
| import 'dart:io'; | |
| import 'package:args/args.dart'; | |
| import 'package:intl/intl.dart'; | |
| import 'package:meta/meta.dart'; | |
| import 'package:path/path.dart' as path; | |
| import 'package:platform/platform.dart'; | |
| import 'package:process/process.dart'; | |
| import 'dartdoc_checker.dart'; | |
| const String kDocsRoot = 'dev/docs'; | |
| const String kPublishRoot = '$kDocsRoot/doc'; | |
| const String kSnippetsRoot = 'dev/snippets'; | |
| const String kDummyPackageName = 'Flutter'; | |
| const String kPlatformIntegrationPackageName = 'platform_integration'; | |
| /// This script expects to run with the cwd as the root of the flutter repo. It | |
| /// will generate documentation for the packages in `//packages/` and write the | |
| /// documentation to `//dev/docs/doc/api/`. | |
| /// | |
| /// This script also updates the index.html file so that it can be placed | |
| /// at the root of api.flutter.dev. We are keeping the files inside of | |
| /// api.flutter.dev/flutter for now, so we need to manipulate paths | |
| /// a bit. See https://github.com/flutter/flutter/issues/3900 for more info. | |
| /// | |
| /// This will only work on UNIX systems, not Windows. It requires that 'git' be | |
| /// in your path. It requires that 'flutter' has been run previously. It uses | |
| /// the version of Dart downloaded by the 'flutter' tool in this repository and | |
| /// will crash if that is absent. | |
| Future<void> main(List<String> arguments) async { | |
| final ArgParser argParser = _createArgsParser(); | |
| final ArgResults args = argParser.parse(arguments); | |
| if (args['help'] as bool) { | |
| print ('Usage:'); | |
| print (argParser.usage); | |
| exit(0); | |
| } | |
| // If we're run from the `tools` dir, set the cwd to the repo root. | |
| if (path.basename(Directory.current.path) == 'tools') | |
| Directory.current = Directory.current.parent.parent; | |
| final ProcessResult flutter = Process.runSync('flutter', <String>[]); | |
| final File versionFile = File('version'); | |
| if (flutter.exitCode != 0 || !versionFile.existsSync()) | |
| throw Exception('Failed to determine Flutter version.'); | |
| final String version = versionFile.readAsStringSync(); | |
| // Create the pubspec.yaml file. | |
| final StringBuffer buf = StringBuffer(); | |
| buf.writeln('name: $kDummyPackageName'); | |
| buf.writeln('homepage: https://flutter.dev'); | |
| buf.writeln('version: 0.0.0'); | |
| buf.writeln('environment:'); | |
| buf.writeln(" sdk: '>=2.10.0 <3.0.0'"); | |
| buf.writeln('dependencies:'); | |
| for (final String package in findPackageNames()) { | |
| buf.writeln(' $package:'); | |
| buf.writeln(' sdk: flutter'); | |
| } | |
| buf.writeln(' $kPlatformIntegrationPackageName: 0.0.1'); | |
| buf.writeln('dependency_overrides:'); | |
| buf.writeln(' $kPlatformIntegrationPackageName:'); | |
| buf.writeln(' path: $kPlatformIntegrationPackageName'); | |
| File('$kDocsRoot/pubspec.yaml').writeAsStringSync(buf.toString()); | |
| // Create the library file. | |
| final Directory libDir = Directory('$kDocsRoot/lib'); | |
| libDir.createSync(); | |
| final StringBuffer contents = StringBuffer('library temp_doc;\n\n'); | |
| for (final String libraryRef in libraryRefs()) { | |
| contents.writeln("import 'package:$libraryRef';"); | |
| } | |
| File('$kDocsRoot/lib/temp_doc.dart').writeAsStringSync(contents.toString()); | |
| final String flutterRoot = Directory.current.path; | |
| final Map<String, String> pubEnvironment = <String, String>{ | |
| 'FLUTTER_ROOT': flutterRoot, | |
| }; | |
| // If there's a .pub-cache dir in the flutter root, use that. | |
| final String pubCachePath = '$flutterRoot/.pub-cache'; | |
| if (Directory(pubCachePath).existsSync()) { | |
| pubEnvironment['PUB_CACHE'] = pubCachePath; | |
| } | |
| final String pubExecutable = '$flutterRoot/bin/cache/dart-sdk/bin/pub'; | |
| // Run pub. | |
| ProcessWrapper process = ProcessWrapper(await Process.start( | |
| pubExecutable, | |
| <String>['get'], | |
| workingDirectory: kDocsRoot, | |
| environment: pubEnvironment, | |
| )); | |
| printStream(process.stdout, prefix: 'pub:stdout: '); | |
| printStream(process.stderr, prefix: 'pub:stderr: '); | |
| final int code = await process.done; | |
| if (code != 0) | |
| exit(code); | |
| createFooter('$kDocsRoot/lib/', version); | |
| copyAssets(); | |
| createSearchMetadata('$kDocsRoot/lib/opensearch.xml', '$kDocsRoot/doc/opensearch.xml'); | |
| cleanOutSnippets(); | |
| final List<String> dartdocBaseArgs = <String>[ | |
| 'global', | |
| 'run', | |
| if (args['checked'] as bool) '-c', | |
| 'dartdoc', | |
| ]; | |
| // Verify which version of snippets and dartdoc we're using. | |
| final ProcessResult snippetsResult = Process.runSync( | |
| pubExecutable, | |
| <String>[ | |
| 'global', | |
| 'list', | |
| ], | |
| workingDirectory: kDocsRoot, | |
| environment: pubEnvironment, | |
| stdoutEncoding: utf8, | |
| ); | |
| print(''); | |
| final Iterable<RegExpMatch> versionMatches = RegExp(r'^(?<name>snippets|dartdoc) (?<version>[^\s]+)', multiLine: true) | |
| .allMatches(snippetsResult.stdout as String); | |
| for (final RegExpMatch match in versionMatches) { | |
| print('${match.namedGroup('name')} version: ${match.namedGroup('version')}'); | |
| } | |
| print('flutter version: $version\n'); | |
| // Dartdoc warnings and errors in these packages are considered fatal. | |
| // All packages owned by flutter should be in the list. | |
| // TODO(goderbauer): Figure out how to add 'dart:ui'. | |
| final List<String> flutterPackages = <String>[ | |
| kDummyPackageName, | |
| kPlatformIntegrationPackageName, | |
| ...findPackageNames(), | |
| ]; | |
| // Generate the documentation. | |
| // We don't need to exclude flutter_tools in this list because it's not in the | |
| // recursive dependencies of the package defined at dev/docs/pubspec.yaml | |
| final List<String> dartdocArgs = <String>[ | |
| ...dartdocBaseArgs, | |
| '--allow-tools', | |
| if (args['json'] as bool) '--json', | |
| if (args['validate-links'] as bool) '--validate-links' else '--no-validate-links', | |
| '--link-to-source-excludes', '../../bin/cache', | |
| '--link-to-source-root', '../..', | |
| '--link-to-source-uri-template', 'https://github.com/flutter/flutter/blob/master/%f%#L%l%', | |
| '--inject-html', | |
| '--use-base-href', | |
| '--header', 'styles.html', | |
| '--header', 'analytics.html', | |
| '--header', 'survey.html', | |
| '--header', 'snippets.html', | |
| '--header', 'opensearch.html', | |
| '--footer-text', 'lib/footer.html', | |
| '--allow-warnings-in-packages', flutterPackages.join(','), | |
| '--exclude-packages', | |
| <String>[ | |
| 'analyzer', | |
| 'args', | |
| 'barback', | |
| 'cli_util', | |
| 'csslib', | |
| 'flutter_goldens', | |
| 'flutter_goldens_client', | |
| 'front_end', | |
| 'fuchsia_remote_debug_protocol', | |
| 'glob', | |
| 'html', | |
| 'http_multi_server', | |
| 'io', | |
| 'isolate', | |
| 'js', | |
| 'kernel', | |
| 'logging', | |
| 'mime', | |
| 'mockito', | |
| 'node_preamble', | |
| 'plugin', | |
| 'shelf', | |
| 'shelf_packages_handler', | |
| 'shelf_static', | |
| 'shelf_web_socket', | |
| 'utf', | |
| 'watcher', | |
| 'yaml', | |
| ].join(','), | |
| '--exclude', | |
| <String>[ | |
| 'dart:io/network_policy.dart', // dart-lang/dartdoc#2437 | |
| 'package:Flutter/temp_doc.dart', | |
| 'package:http/browser_client.dart', | |
| 'package:intl/intl_browser.dart', | |
| 'package:matcher/mirror_matchers.dart', | |
| 'package:quiver/io.dart', | |
| 'package:quiver/mirrors.dart', | |
| 'package:vm_service_client/vm_service_client.dart', | |
| 'package:web_socket_channel/html.dart', | |
| ].join(','), | |
| '--favicon=favicon.ico', | |
| '--package-order', 'flutter,Dart,$kPlatformIntegrationPackageName,flutter_test,flutter_driver', | |
| '--auto-include-dependencies', | |
| ]; | |
| String quote(String arg) => arg.contains(' ') ? "'$arg'" : arg; | |
| print('Executing: (cd $kDocsRoot ; $pubExecutable ${dartdocArgs.map<String>(quote).join(' ')})'); | |
| process = ProcessWrapper(await Process.start( | |
| pubExecutable, | |
| dartdocArgs, | |
| workingDirectory: kDocsRoot, | |
| environment: pubEnvironment, | |
| )); | |
| printStream(process.stdout, prefix: args['json'] as bool ? '' : 'dartdoc:stdout: ', | |
| filter: args['verbose'] as bool ? const <Pattern>[] : <Pattern>[ | |
| RegExp(r'^generating docs for library '), // unnecessary verbosity | |
| RegExp(r'^pars'), // unnecessary verbosity | |
| ], | |
| ); | |
| printStream(process.stderr, prefix: args['json'] as bool ? '' : 'dartdoc:stderr: ', | |
| filter: args['verbose'] as bool ? const <Pattern>[] : <Pattern>[ | |
| RegExp(r'^ warning: .+: \(.+/\.pub-cache/hosted/pub.dartlang.org/.+\)'), // packages outside our control | |
| ], | |
| ); | |
| final int exitCode = await process.done; | |
| if (exitCode != 0) | |
| exit(exitCode); | |
| sanityCheckDocs(); | |
| checkForUnresolvedDirectives('$kPublishRoot/api'); | |
| createIndexAndCleanup(); | |
| } | |
| ArgParser _createArgsParser() { | |
| final ArgParser parser = ArgParser(); | |
| parser.addFlag('help', abbr: 'h', negatable: false, | |
| help: 'Show command help.'); | |
| parser.addFlag('verbose', negatable: true, defaultsTo: true, | |
| help: 'Whether to report all error messages (on) or attempt to ' | |
| 'filter out some known false positives (off). Shut this off ' | |
| 'locally if you want to address Flutter-specific issues.'); | |
| parser.addFlag('checked', abbr: 'c', negatable: true, | |
| help: 'Run dartdoc in checked mode.'); | |
| parser.addFlag('json', negatable: true, | |
| help: 'Display json-formatted output from dartdoc and skip stdout/stderr prefixing.'); | |
| parser.addFlag('validate-links', negatable: true, | |
| help: 'Display warnings for broken links generated by dartdoc (slow)'); | |
| return parser; | |
| } | |
| final RegExp gitBranchRegexp = RegExp(r'^## (.*)'); | |
| /// Get the name of the release branch. | |
| /// | |
| /// On LUCI builds, the git HEAD is detached, so first check for the env | |
| /// variable "LUCI_BRANCH"; if it is not set, fall back to calling git. | |
| String getBranchName({ | |
| @visibleForTesting | |
| Platform platform = const LocalPlatform(), | |
| @visibleForTesting | |
| ProcessManager processManager = const LocalProcessManager(), | |
| }) { | |
| final String? luciBranch = platform.environment['LUCI_BRANCH']; | |
| if (luciBranch != null && luciBranch.trim().isNotEmpty) { | |
| return luciBranch.trim(); | |
| } | |
| final ProcessResult gitResult = processManager.runSync(<String>['git', 'status', '-b', '--porcelain']); | |
| if (gitResult.exitCode != 0) | |
| throw 'git status exit with non-zero exit code: ${gitResult.exitCode}'; | |
| final RegExpMatch? gitBranchMatch = gitBranchRegexp.firstMatch( | |
| (gitResult.stdout as String).trim().split('\n').first); | |
| return gitBranchMatch == null ? '' : gitBranchMatch.group(1)!.split('...').first; | |
| } | |
| String gitRevision() { | |
| const int kGitRevisionLength = 10; | |
| final ProcessResult gitResult = Process.runSync('git', <String>['rev-parse', 'HEAD']); | |
| if (gitResult.exitCode != 0) | |
| throw 'git rev-parse exit with non-zero exit code: ${gitResult.exitCode}'; | |
| final String gitRevision = (gitResult.stdout as String).trim(); | |
| return gitRevision.length > kGitRevisionLength ? gitRevision.substring(0, kGitRevisionLength) : gitRevision; | |
| } | |
| void createFooter(String footerPath, String version) { | |
| final String timestamp = DateFormat('yyyy-MM-dd HH:mm').format(DateTime.now()); | |
| final String gitBranch = getBranchName(); | |
| final String gitBranchOut = gitBranch.isEmpty ? '' : '• $gitBranch'; | |
| File('${footerPath}footer.html').writeAsStringSync('<script src="footer.js"></script>'); | |
| File('$kPublishRoot/api/footer.js') | |
| ..createSync(recursive: true) | |
| ..writeAsStringSync(''' | |
| (function() { | |
| var span = document.querySelector('footer>span'); | |
| if (span) { | |
| span.innerText = 'Flutter $version • $timestamp • ${gitRevision()} $gitBranchOut'; | |
| } | |
| var sourceLink = document.querySelector('a.source-link'); | |
| if (sourceLink) { | |
| sourceLink.href = sourceLink.href.replace('/master/', '/${gitRevision()}/'); | |
| } | |
| })(); | |
| '''); | |
| } | |
| /// Generates an OpenSearch XML description that can be used to add a custom | |
| /// search for Flutter API docs to the browser. Unfortunately, it has to know | |
| /// the URL to which site to search, so we customize it here based upon the | |
| /// branch name. | |
| void createSearchMetadata(String templatePath, String metadataPath) { | |
| final String template = File(templatePath).readAsStringSync(); | |
| final String branch = getBranchName(); | |
| final String metadata = template.replaceAll( | |
| '{SITE_URL}', | |
| branch == 'stable' ? 'https://api.flutter.dev/' : 'https://master-api.flutter.dev/', | |
| ); | |
| Directory(path.dirname(metadataPath)).create(recursive: true); | |
| File(metadataPath).writeAsStringSync(metadata); | |
| } | |
| /// Recursively copies `srcDir` to `destDir`, invoking [onFileCopied], if | |
| /// specified, for each source/destination file pair. | |
| /// | |
| /// Creates `destDir` if needed. | |
| void copyDirectorySync(Directory srcDir, Directory destDir, [void Function(File srcFile, File destFile)? onFileCopied]) { | |
| if (!srcDir.existsSync()) | |
| throw Exception('Source directory "${srcDir.path}" does not exist, nothing to copy'); | |
| if (!destDir.existsSync()) | |
| destDir.createSync(recursive: true); | |
| for (final FileSystemEntity entity in srcDir.listSync()) { | |
| final String newPath = path.join(destDir.path, path.basename(entity.path)); | |
| if (entity is File) { | |
| final File newFile = File(newPath); | |
| entity.copySync(newPath); | |
| onFileCopied?.call(entity, newFile); | |
| } else if (entity is Directory) { | |
| copyDirectorySync(entity, Directory(newPath)); | |
| } else { | |
| throw Exception('${entity.path} is neither File nor Directory'); | |
| } | |
| } | |
| } | |
| void copyAssets() { | |
| final Directory assetsDir = Directory(path.join(kPublishRoot, 'assets')); | |
| if (assetsDir.existsSync()) { | |
| assetsDir.deleteSync(recursive: true); | |
| } | |
| copyDirectorySync( | |
| Directory(path.join(kDocsRoot, 'assets')), | |
| Directory(path.join(kPublishRoot, 'assets')), | |
| (File src, File dest) => print('Copied ${src.path} to ${dest.path}')); | |
| } | |
| /// Clean out any existing snippets so that we don't publish old files from | |
| /// previous runs accidentally. | |
| void cleanOutSnippets() { | |
| final Directory snippetsDir = Directory(path.join(kPublishRoot, 'snippets')); | |
| if (snippetsDir.existsSync()) { | |
| snippetsDir | |
| ..deleteSync(recursive: true) | |
| ..createSync(recursive: true); | |
| } | |
| } | |
| void sanityCheckDocs() { | |
| final List<String> canaries = <String>[ | |
| '$kPublishRoot/assets/overrides.css', | |
| '$kPublishRoot/api/dart-io/File-class.html', | |
| '$kPublishRoot/api/dart-ui/Canvas-class.html', | |
| '$kPublishRoot/api/dart-ui/Canvas/drawRect.html', | |
| '$kPublishRoot/api/flutter_driver/FlutterDriver/FlutterDriver.connectedTo.html', | |
| '$kPublishRoot/api/flutter_test/WidgetTester/pumpWidget.html', | |
| '$kPublishRoot/api/material/Material-class.html', | |
| '$kPublishRoot/api/material/Tooltip-class.html', | |
| '$kPublishRoot/api/widgets/Widget-class.html', | |
| ]; | |
| for (final String canary in canaries) { | |
| if (!File(canary).existsSync()) | |
| throw Exception('Missing "$canary", which probably means the documentation failed to build correctly.'); | |
| } | |
| } | |
| /// Creates a custom index.html because we try to maintain old | |
| /// paths. Cleanup unused index.html files no longer needed. | |
| void createIndexAndCleanup() { | |
| print('\nCreating a custom index.html in $kPublishRoot/index.html'); | |
| removeOldFlutterDocsDir(); | |
| renameApiDir(); | |
| copyIndexToRootOfDocs(); | |
| addHtmlBaseToIndex(); | |
| changePackageToSdkInTitlebar(); | |
| putRedirectInOldIndexLocation(); | |
| writeSnippetsIndexFile(); | |
| print('\nDocs ready to go!'); | |
| } | |
| void removeOldFlutterDocsDir() { | |
| try { | |
| Directory('$kPublishRoot/flutter').deleteSync(recursive: true); | |
| } on FileSystemException { | |
| // If the directory does not exist, that's OK. | |
| } | |
| } | |
| void renameApiDir() { | |
| Directory('$kPublishRoot/api').renameSync('$kPublishRoot/flutter'); | |
| } | |
| void copyIndexToRootOfDocs() { | |
| File('$kPublishRoot/flutter/index.html').copySync('$kPublishRoot/index.html'); | |
| } | |
| void changePackageToSdkInTitlebar() { | |
| final File indexFile = File('$kPublishRoot/index.html'); | |
| String indexContents = indexFile.readAsStringSync(); | |
| indexContents = indexContents.replaceFirst( | |
| '<li><a href="https://flutter.dev">Flutter package</a></li>', | |
| '<li><a href="https://flutter.dev">Flutter SDK</a></li>', | |
| ); | |
| indexFile.writeAsStringSync(indexContents); | |
| } | |
| void addHtmlBaseToIndex() { | |
| final File indexFile = File('$kPublishRoot/index.html'); | |
| String indexContents = indexFile.readAsStringSync(); | |
| indexContents = indexContents.replaceFirst( | |
| '</title>\n', | |
| '</title>\n <base href="./flutter/">\n', | |
| ); | |
| indexContents = indexContents.replaceAll( | |
| 'href="Android/Android-library.html"', | |
| 'href="/javadoc/"', | |
| ); | |
| indexContents = indexContents.replaceAll( | |
| 'href="iOS/iOS-library.html"', | |
| 'href="/objcdoc/"', | |
| ); | |
| indexFile.writeAsStringSync(indexContents); | |
| } | |
| void putRedirectInOldIndexLocation() { | |
| const String metaTag = '<meta http-equiv="refresh" content="0;URL=../index.html">'; | |
| File('$kPublishRoot/flutter/index.html').writeAsStringSync(metaTag); | |
| } | |
| void writeSnippetsIndexFile() { | |
| final Directory snippetsDir = Directory(path.join(kPublishRoot, 'snippets')); | |
| if (snippetsDir.existsSync()) { | |
| const JsonEncoder jsonEncoder = JsonEncoder.withIndent(' '); | |
| final Iterable<File> files = snippetsDir | |
| .listSync() | |
| .whereType<File>() | |
| .where((File file) => path.extension(file.path) == '.json'); | |
| // Combine all the metadata into a single JSON array. | |
| final Iterable<String> fileContents = files.map((File file) => file.readAsStringSync()); | |
| final List<dynamic> metadataObjects = fileContents.map<dynamic>(json.decode).toList(); | |
| final String jsonArray = jsonEncoder.convert(metadataObjects); | |
| File('$kPublishRoot/snippets/index.json').writeAsStringSync(jsonArray); | |
| } | |
| } | |
| List<String> findPackageNames() { | |
| return findPackages().map<String>((FileSystemEntity file) => path.basename(file.path)).toList(); | |
| } | |
| /// Finds all packages in the Flutter SDK | |
| List<Directory> findPackages() { | |
| return Directory('packages') | |
| .listSync() | |
| .where((FileSystemEntity entity) { | |
| if (entity is! Directory) | |
| return false; | |
| final File pubspec = File('${entity.path}/pubspec.yaml'); | |
| if (!pubspec.existsSync()) { | |
| print("Unexpected package '${entity.path}' found in packages directory"); | |
| return false; | |
| } | |
| // TODO(ianh): Use a real YAML parser here | |
| return !pubspec.readAsStringSync().contains('nodoc: true'); | |
| }) | |
| .cast<Directory>() | |
| .toList(); | |
| } | |
| /// Returns import or on-disk paths for all libraries in the Flutter SDK. | |
| Iterable<String> libraryRefs() sync* { | |
| for (final Directory dir in findPackages()) { | |
| final String dirName = path.basename(dir.path); | |
| for (final FileSystemEntity file in Directory('${dir.path}/lib').listSync()) { | |
| if (file is File && file.path.endsWith('.dart')) { | |
| yield '$dirName/${path.basename(file.path)}'; | |
| } | |
| } | |
| } | |
| // Add a fake package for platform integration APIs. | |
| yield '$kPlatformIntegrationPackageName/android.dart'; | |
| yield '$kPlatformIntegrationPackageName/ios.dart'; | |
| } | |
| void printStream(Stream<List<int>> stream, { String prefix = '', List<Pattern> filter = const <Pattern>[] }) { | |
| assert(prefix != null); | |
| assert(filter != null); | |
| stream | |
| .transform<String>(utf8.decoder) | |
| .transform<String>(const LineSplitter()) | |
| .listen((String line) { | |
| if (!filter.any((Pattern pattern) => line.contains(pattern))) | |
| print('$prefix$line'.trim()); | |
| }); | |
| } |