| // 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 'dart:math' as math; |
| |
| import 'package:archive/archive_io.dart'; |
| import 'package:args/args.dart'; |
| import 'package:file/file.dart'; |
| import 'package:file/local.dart'; |
| import 'package:http/http.dart' as http; |
| 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 'package:pub_semver/pub_semver.dart'; |
| |
| import 'dartdoc_checker.dart'; |
| |
| const String kDummyPackageName = 'Flutter'; |
| const String kPlatformIntegrationPackageName = 'platform_integration'; |
| |
| class PlatformDocsSection { |
| const PlatformDocsSection({ |
| required this.zipName, |
| required this.sectionName, |
| required this.checkFile, |
| required this.subdir, |
| }); |
| final String zipName; |
| final String sectionName; |
| final String checkFile; |
| final String subdir; |
| } |
| |
| const Map<String, PlatformDocsSection> kPlatformDocs = <String, PlatformDocsSection>{ |
| 'android': PlatformDocsSection( |
| zipName: 'android-javadoc.zip', |
| sectionName: 'Android', |
| checkFile: 'io/flutter/view/FlutterView.html', |
| subdir: 'javadoc', |
| ), |
| 'ios': PlatformDocsSection( |
| zipName: 'ios-docs.zip', |
| sectionName: 'iOS', |
| checkFile: 'interface_flutter_view.html', |
| subdir: 'ios-embedder', |
| ), |
| 'macos': PlatformDocsSection( |
| zipName: 'macos-docs.zip', |
| sectionName: 'macOS', |
| checkFile: 'interface_flutter_view.html', |
| subdir: 'macos-embedder', |
| ), |
| 'linux': PlatformDocsSection( |
| zipName: 'linux-docs.zip', |
| sectionName: 'Linux', |
| checkFile: 'struct___fl_view.html', |
| subdir: 'linux-embedder', |
| ), |
| 'windows': PlatformDocsSection( |
| zipName: 'windows-docs.zip', |
| sectionName: 'Windows', |
| checkFile: 'classflutter_1_1_flutter_view.html', |
| subdir: 'windows-embedder', |
| ), |
| 'impeller': PlatformDocsSection( |
| zipName: 'impeller-docs.zip', |
| sectionName: 'Impeller', |
| checkFile: 'classimpeller_1_1_canvas.html', |
| subdir: 'impeller', |
| ), |
| }; |
| |
| /// This script will generate documentation for the packages in `packages/` and |
| /// write the documentation to the output directory specified on the command |
| /// line. |
| /// |
| /// This script also updates the index.html file so that it can be placed at the |
| /// root of api.flutter.dev. The files are kept inside of |
| /// api.flutter.dev/flutter, 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', |
| /// 'zip', and 'tar' be in the 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 fail if that is absent. |
| Future<void> main(List<String> arguments) async { |
| const FileSystem filesystem = LocalFileSystem(); |
| const ProcessManager processManager = LocalProcessManager(); |
| const Platform platform = LocalPlatform(); |
| |
| // The place to find customization files and configuration files for docs |
| // generation. |
| final Directory docsRoot = |
| FlutterInformation.instance.getFlutterRoot().childDirectory('dev').childDirectory('docs').absolute; |
| final ArgParser argParser = _createArgsParser( |
| publishDefault: docsRoot.childDirectory('doc').path, |
| ); |
| final ArgResults args = argParser.parse(arguments); |
| if (args['help'] as bool) { |
| print('Usage:'); |
| print(argParser.usage); |
| exit(0); |
| } |
| |
| final Directory publishRoot = filesystem.directory(args['output-dir']! as String).absolute; |
| final Directory packageRoot = publishRoot.parent; |
| if (!filesystem.directory(packageRoot).existsSync()) { |
| filesystem.directory(packageRoot).createSync(recursive: true); |
| } |
| |
| if (!filesystem.directory(publishRoot).existsSync()) { |
| filesystem.directory(publishRoot).createSync(recursive: true); |
| } |
| |
| final Configurator configurator = Configurator( |
| publishRoot: publishRoot, |
| packageRoot: packageRoot, |
| docsRoot: docsRoot, |
| filesystem: filesystem, |
| processManager: processManager, |
| platform: platform, |
| ); |
| configurator.generateConfiguration(); |
| |
| final PlatformDocGenerator platformGenerator = PlatformDocGenerator(outputDir: publishRoot, filesystem: filesystem); |
| await platformGenerator.generatePlatformDocs(); |
| |
| final DartdocGenerator dartdocGenerator = DartdocGenerator( |
| publishRoot: publishRoot, |
| packageRoot: packageRoot, |
| docsRoot: docsRoot, |
| filesystem: filesystem, |
| processManager: processManager, |
| useJson: args['json'] as bool? ?? true, |
| validateLinks: args['validate-links']! as bool, |
| verbose: args['verbose'] as bool? ?? false, |
| ); |
| |
| await dartdocGenerator.generateDartdoc(); |
| await configurator.generateOfflineAssetsIfNeeded(); |
| } |
| |
| ArgParser _createArgsParser({required String publishDefault}) { |
| final ArgParser parser = ArgParser(); |
| parser.addFlag('help', abbr: 'h', negatable: false, help: 'Show command help.'); |
| parser.addFlag('verbose', |
| 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('json', help: 'Display json-formatted output from dartdoc and skip stdout/stderr prefixing.'); |
| parser.addFlag('validate-links', help: 'Display warnings for broken links generated by dartdoc (slow)'); |
| parser.addOption('output-dir', defaultsTo: publishDefault, help: 'Sets the output directory for the documentation.'); |
| return parser; |
| } |
| |
| /// A class used to configure the staging area for building the docs in. |
| /// |
| /// The [generateConfiguration] function generates a dummy package with a |
| /// pubspec. It copies any assets and customization files from the framework |
| /// repo. It creates a metadata file for searches. |
| /// |
| /// Once the docs have been generated, [generateOfflineAssetsIfNeeded] will |
| /// create offline assets like Dash/Zeal docsets and an offline ZIP file of the |
| /// site if the build is a CI build that is not a presubmit build. |
| class Configurator { |
| Configurator({ |
| required this.docsRoot, |
| required this.publishRoot, |
| required this.packageRoot, |
| required this.filesystem, |
| required this.processManager, |
| required this.platform, |
| }); |
| |
| /// The root of the directory in the Flutter repo where configuration data is |
| /// stored. |
| final Directory docsRoot; |
| |
| /// The root of the output area for the dartdoc docs. |
| /// |
| /// Typically this is a "doc" subdirectory under the [packageRoot]. |
| final Directory publishRoot; |
| |
| /// The root of the staging area for creating docs. |
| final Directory packageRoot; |
| |
| /// The [FileSystem] object used to create [File] and [Directory] objects. |
| final FileSystem filesystem; |
| |
| /// The [ProcessManager] object used to invoke external processes. |
| /// |
| /// Can be replaced by tests to have a fake process manager. |
| final ProcessManager processManager; |
| |
| /// The [Platform] to use for this run. |
| /// |
| /// Can be replaced by tests to test behavior on different plaforms. |
| final Platform platform; |
| |
| void generateConfiguration() { |
| final Version version = FlutterInformation.instance.getFlutterVersion(); |
| _createDummyPubspec(); |
| _createDummyLibrary(); |
| _createPageFooter(packageRoot, version); |
| _copyCustomizations(); |
| _createSearchMetadata( |
| docsRoot.childDirectory('lib').childFile('opensearch.xml'), publishRoot.childFile('opensearch.xml')); |
| } |
| |
| Future<void> generateOfflineAssetsIfNeeded() async { |
| // Only create the offline docs if we're running in a non-presubmit build: |
| // it takes too long otherwise. |
| if (platform.environment.containsKey('LUCI_CI') && (platform.environment['LUCI_PR'] ?? '').isEmpty) { |
| _createOfflineZipFile(); |
| await _createDocset(); |
| _moveOfflineIntoPlace(); |
| _createRobotsTxt(); |
| } |
| } |
| |
| /// Returns import or on-disk paths for all libraries in the Flutter SDK. |
| Iterable<String> _libraryRefs() sync* { |
| for (final Directory dir in findPackages(filesystem)) { |
| final String dirName = dir.basename; |
| for (final FileSystemEntity file in dir.childDirectory('lib').listSync()) { |
| if (file is File && file.path.endsWith('.dart')) { |
| yield '$dirName/${file.basename}'; |
| } |
| } |
| } |
| |
| // Add a fake package for platform integration APIs. |
| yield '$kPlatformIntegrationPackageName/android.dart'; |
| yield '$kPlatformIntegrationPackageName/ios.dart'; |
| yield '$kPlatformIntegrationPackageName/macos.dart'; |
| yield '$kPlatformIntegrationPackageName/linux.dart'; |
| yield '$kPlatformIntegrationPackageName/windows.dart'; |
| } |
| |
| void _createDummyPubspec() { |
| // Create the pubspec.yaml file. |
| final List<String> pubspec = <String>[ |
| 'name: $kDummyPackageName', |
| 'homepage: https://flutter.dev', |
| 'version: 0.0.0', |
| 'environment:', |
| " sdk: '>=3.2.0-0 <4.0.0'", |
| 'dependencies:', |
| for (final String package in findPackageNames(filesystem)) ' $package:\n sdk: flutter', |
| ' $kPlatformIntegrationPackageName: 0.0.1', |
| 'dependency_overrides:', |
| ' $kPlatformIntegrationPackageName:', |
| ' path: ${docsRoot.childDirectory(kPlatformIntegrationPackageName).path}', |
| ]; |
| |
| packageRoot.childFile('pubspec.yaml').writeAsStringSync(pubspec.join('\n')); |
| } |
| |
| void _createDummyLibrary() { |
| final Directory libDir = packageRoot.childDirectory('lib'); |
| libDir.createSync(); |
| |
| final StringBuffer contents = StringBuffer('library temp_doc;\n\n'); |
| for (final String libraryRef in _libraryRefs()) { |
| contents.writeln("import 'package:$libraryRef';"); |
| } |
| packageRoot.childDirectory('lib') |
| ..createSync(recursive: true) |
| ..childFile('temp_doc.dart').writeAsStringSync(contents.toString()); |
| } |
| |
| void _createPageFooter(Directory footerPath, Version version) { |
| final String timestamp = DateFormat('yyyy-MM-dd HH:mm').format(DateTime.now()); |
| final String gitBranch = FlutterInformation.instance.getBranchName(); |
| final String gitRevision = FlutterInformation.instance.getFlutterRevision(); |
| final String gitBranchOut = gitBranch.isEmpty ? '' : '• $gitBranch'; |
| footerPath.childFile('footer.html').writeAsStringSync('<script src="footer.js"></script>'); |
| publishRoot.childDirectory('flutter').childFile('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/'); |
| } |
| })(); |
| '''); |
| } |
| |
| void _copyCustomizations() { |
| final List<String> files = <String>[ |
| 'README.md', |
| 'analysis_options.yaml', |
| 'dartdoc_options.yaml', |
| ]; |
| for (final String file in files) { |
| final File source = docsRoot.childFile(file); |
| final File destination = packageRoot.childFile(file); |
| // Have to canonicalize because otherwise things like /foo/bar/baz and |
| // /foo/../foo/bar/baz won't compare as identical. |
| if (path.canonicalize(source.absolute.path) != path.canonicalize(destination.absolute.path)) { |
| source.copySync(destination.path); |
| print('Copied ${path.canonicalize(source.absolute.path)} to ${path.canonicalize(destination.absolute.path)}'); |
| } |
| } |
| final Directory assetsDir = filesystem.directory(publishRoot.childDirectory('assets')); |
| final Directory assetSource = docsRoot.childDirectory('assets'); |
| if (path.canonicalize(assetSource.absolute.path) == path.canonicalize(assetsDir.absolute.path)) { |
| // Don't try and copy the directory over itself. |
| return; |
| } |
| if (assetsDir.existsSync()) { |
| assetsDir.deleteSync(recursive: true); |
| } |
| copyDirectorySync( |
| docsRoot.childDirectory('assets'), |
| assetsDir, |
| onFileCopied: (File src, File dest) { |
| print('Copied ${path.canonicalize(src.absolute.path)} to ${path.canonicalize(dest.absolute.path)}'); |
| }, |
| filesystem: filesystem, |
| ); |
| } |
| |
| /// 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(File templatePath, File metadataPath) { |
| final String template = templatePath.readAsStringSync(); |
| final String branch = FlutterInformation.instance.getBranchName(); |
| final String metadata = template.replaceAll( |
| '{SITE_URL}', |
| branch == 'stable' ? 'https://api.flutter.dev/' : 'https://master-api.flutter.dev/', |
| ); |
| metadataPath.parent.create(recursive: true); |
| metadataPath.writeAsStringSync(metadata); |
| } |
| |
| Future<void> _createDocset() async { |
| // Must have dashing installed: go get -u github.com/technosophos/dashing |
| // Dashing produces a LOT of log output (~30MB), so we collect it, and just |
| // show the end of it if there was a problem. |
| print('${DateTime.now().toUtc()}: Building Flutter docset.'); |
| |
| // If dashing gets stuck, Cirrus will time out the build after an hour, and we |
| // never get to see the logs. Thus, we run it in the background and tail the |
| // logs only if it fails. |
| final ProcessWrapper result = ProcessWrapper( |
| await processManager.start( |
| <String>[ |
| 'dashing', |
| 'build', |
| '--source', |
| publishRoot.path, |
| '--config', |
| docsRoot.childFile('dashing.json').path, |
| ], |
| workingDirectory: packageRoot.path, |
| ), |
| ); |
| final List<int> buffer = <int>[]; |
| result.stdout.listen(buffer.addAll); |
| result.stderr.listen(buffer.addAll); |
| // If the dashing process exited with an error, print the last 200 lines of stderr and exit. |
| final int exitCode = await result.done; |
| if (exitCode != 0) { |
| print('Dashing docset generation failed with code $exitCode'); |
| final List<String> output = systemEncoding.decode(buffer).split('\n'); |
| print(output.sublist(math.max(output.length - 200, 0)).join('\n')); |
| exit(exitCode); |
| } |
| buffer.clear(); |
| |
| // Copy the favicon file to the output directory. |
| final File faviconFile = |
| publishRoot.childDirectory('flutter').childDirectory('static-assets').childFile('favicon.png'); |
| final File iconFile = packageRoot.childDirectory('flutter.docset').childFile('icon.png'); |
| faviconFile |
| ..createSync(recursive: true) |
| ..copySync(iconFile.path); |
| |
| // Post-process the dashing output. |
| final File infoPlist = |
| packageRoot.childDirectory('flutter.docset').childDirectory('Contents').childFile('Info.plist'); |
| String contents = infoPlist.readAsStringSync(); |
| |
| // Since I didn't want to add the XML package as a dependency just for this, |
| // I just used a regular expression to make this simple change. |
| final RegExp findRe = RegExp(r'(\s*<key>DocSetPlatformFamily</key>\s*<string>)[^<]+(</string>)', multiLine: true); |
| contents = contents.replaceAllMapped(findRe, (Match match) { |
| return '${match.group(1)}dartlang${match.group(2)}'; |
| }); |
| infoPlist.writeAsStringSync(contents); |
| final Directory offlineDir = publishRoot.childDirectory('offline'); |
| if (!offlineDir.existsSync()) { |
| offlineDir.createSync(recursive: true); |
| } |
| tarDirectory(packageRoot, offlineDir.childFile('flutter.docset.tar.gz'), processManager: processManager); |
| |
| // Write the Dash/Zeal XML feed file. |
| final bool isStable = platform.environment['LUCI_BRANCH'] == 'stable'; |
| offlineDir.childFile('flutter.xml').writeAsStringSync('<entry>\n' |
| ' <version>${FlutterInformation.instance.getFlutterVersion()}</version>\n' |
| ' <url>https://${isStable ? '' : 'master-'}api.flutter.dev/offline/flutter.docset.tar.gz</url>\n' |
| '</entry>\n'); |
| } |
| |
| // Creates the offline ZIP file containing all of the website HTML files. |
| void _createOfflineZipFile() { |
| print('${DateTime.now().toLocal()}: Creating offline docs archive.'); |
| zipDirectory(publishRoot, packageRoot.childFile('flutter.docs.zip'), processManager: processManager); |
| } |
| |
| // Moves the generated offline archives into the publish directory so that |
| // they can be included in the output ZIP file. |
| void _moveOfflineIntoPlace() { |
| print('${DateTime.now().toUtc()}: Moving offline docs into place.'); |
| final Directory offlineDir = publishRoot.childDirectory('offline')..createSync(recursive: true); |
| packageRoot.childFile('flutter.docs.zip').renameSync(offlineDir.childFile('flutter.docs.zip').path); |
| } |
| |
| // Creates a robots.txt file that disallows indexing unless the branch is the |
| // stable branch. |
| void _createRobotsTxt() { |
| final File robotsTxt = publishRoot.childFile('robots.txt'); |
| if (FlutterInformation.instance.getBranchName() == 'stable') { |
| robotsTxt.writeAsStringSync('# All robots welcome!'); |
| } else { |
| robotsTxt.writeAsStringSync('User-agent: *\nDisallow: /'); |
| } |
| } |
| } |
| |
| /// Runs Dartdoc inside of the given pre-prepared staging area, prepared by |
| /// [Configurator.generateConfiguration]. |
| /// |
| /// Performs a sanity check of the output once the generation is complete. |
| class DartdocGenerator { |
| DartdocGenerator({ |
| required this.docsRoot, |
| required this.publishRoot, |
| required this.packageRoot, |
| required this.filesystem, |
| required this.processManager, |
| this.useJson = true, |
| this.validateLinks = true, |
| this.verbose = false, |
| }); |
| |
| /// The root of the directory in the Flutter repo where configuration data is |
| /// stored. |
| final Directory docsRoot; |
| |
| /// The root of the output area for the dartdoc docs. |
| /// |
| /// Typically this is a "doc" subdirectory under the [packageRoot]. |
| final Directory publishRoot; |
| |
| /// The root of the staging area for creating docs. |
| final Directory packageRoot; |
| |
| /// The [FileSystem] object used to create [File] and [Directory] objects. |
| final FileSystem filesystem; |
| |
| /// The [ProcessManager] object used to invoke external processes. |
| /// |
| /// Can be replaced by tests to have a fake process manager. |
| final ProcessManager processManager; |
| |
| /// Whether or not dartdoc should output an index.json file of the |
| /// documentation. |
| final bool useJson; |
| |
| // Whether or not to have dartdoc validate its own links. |
| final bool validateLinks; |
| |
| /// Whether or not to filter overly verbose log output from dartdoc. |
| final bool verbose; |
| |
| Future<void> generateDartdoc() async { |
| final Directory flutterRoot = FlutterInformation.instance.getFlutterRoot(); |
| final Map<String, String> pubEnvironment = <String, String>{ |
| 'FLUTTER_ROOT': flutterRoot.absolute.path, |
| }; |
| |
| // If there's a .pub-cache dir in the Flutter root, use that. |
| final File pubCache = flutterRoot.childFile('.pub-cache'); |
| if (pubCache.existsSync()) { |
| pubEnvironment['PUB_CACHE'] = pubCache.path; |
| } |
| |
| // Run pub. |
| ProcessWrapper process = ProcessWrapper(await runPubProcess( |
| arguments: <String>['get'], |
| workingDirectory: packageRoot, |
| environment: pubEnvironment, |
| filesystem: filesystem, |
| processManager: processManager, |
| )); |
| printStream(process.stdout, prefix: 'pub:stdout: '); |
| printStream(process.stderr, prefix: 'pub:stderr: '); |
| final int code = await process.done; |
| if (code != 0) { |
| exit(code); |
| } |
| |
| final Version version = FlutterInformation.instance.getFlutterVersion(); |
| |
| // Verify which version of snippets and dartdoc we're using. |
| final ProcessResult snippetsResult = processManager.runSync( |
| <String>[ |
| FlutterInformation.instance.getFlutterBinaryPath().path, |
| 'pub', |
| 'global', |
| 'list', |
| ], |
| workingDirectory: packageRoot.path, |
| 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. |
| final List<String> flutterPackages = <String>[ |
| kDummyPackageName, |
| kPlatformIntegrationPackageName, |
| ...findPackageNames(filesystem), |
| // TODO(goderbauer): Figure out how to only include `dart:ui` of |
| // `sky_engine` below, https://github.com/dart-lang/dartdoc/issues/2278. |
| // 'sky_engine', |
| ]; |
| |
| // 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 packageRoot |
| final List<String> dartdocArgs = <String>[ |
| 'global', |
| 'run', |
| '--enable-asserts', |
| 'dartdoc', |
| '--output', |
| publishRoot.childDirectory('flutter').path, |
| '--allow-tools', |
| if (useJson) '--json', |
| if (validateLinks) '--validate-links' else '--no-validate-links', |
| '--link-to-source-excludes', |
| flutterRoot.childDirectory('bin').childDirectory('cache').path, |
| '--link-to-source-root', |
| flutterRoot.path, |
| '--link-to-source-uri-template', |
| 'https://github.com/flutter/flutter/blob/master/%f%#L%l%', |
| '--inject-html', |
| '--use-base-href', |
| '--header', |
| docsRoot.childFile('styles.html').path, |
| '--header', |
| docsRoot.childFile('analytics-header.html').path, |
| '--header', |
| docsRoot.childFile('survey.html').path, |
| '--header', |
| docsRoot.childFile('snippets.html').path, |
| '--header', |
| docsRoot.childFile('opensearch.html').path, |
| '--footer', |
| docsRoot.childFile('analytics-footer.html').path, |
| '--footer-text', |
| packageRoot.childFile('footer.html').path, |
| '--allow-warnings-in-packages', |
| flutterPackages.join(','), |
| '--exclude-packages', |
| <String>[ |
| 'analyzer', |
| 'args', |
| 'barback', |
| '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', |
| docsRoot.childFile('favicon.ico').absolute.path, |
| '--package-order', |
| 'flutter,Dart,$kPlatformIntegrationPackageName,flutter_test,flutter_driver', |
| '--auto-include-dependencies', |
| ]; |
| |
| String quote(String arg) => arg.contains(' ') ? "'$arg'" : arg; |
| print('Executing: (cd "${packageRoot.path}" ; ' |
| '${FlutterInformation.instance.getDartBinaryPath().path} ' |
| '${dartdocArgs.map<String>(quote).join(' ')})'); |
| |
| process = ProcessWrapper(await runPubProcess( |
| arguments: dartdocArgs, |
| workingDirectory: packageRoot, |
| environment: pubEnvironment, |
| processManager: processManager, |
| )); |
| printStream( |
| process.stdout, |
| prefix: useJson ? '' : 'dartdoc:stdout: ', |
| filter: <Pattern>[ |
| if (!verbose) RegExp(r'^Generating docs for library '), // Unnecessary verbosity |
| ], |
| ); |
| printStream( |
| process.stderr, |
| prefix: useJson ? '' : 'dartdoc:stderr: ', |
| filter: <Pattern>[ |
| if (!verbose) |
| RegExp( |
| // Remove warnings from packages outside our control |
| r'^ warning: .+: \(.+[\\/]\.pub-cache[\\/]hosted[\\/]pub.dartlang.org[\\/].+\)', |
| ), |
| ], |
| ); |
| final int exitCode = await process.done; |
| |
| if (exitCode != 0) { |
| exit(exitCode); |
| } |
| |
| _sanityCheckDocs(); |
| checkForUnresolvedDirectives(publishRoot.childDirectory('flutter')); |
| |
| _createIndexAndCleanup(); |
| |
| print('Documentation written to ${publishRoot.path}'); |
| } |
| |
| void _sanityCheckExample(String fileString, String regExpString) { |
| final File file = filesystem.file(fileString); |
| if (file.existsSync()) { |
| final RegExp regExp = RegExp(regExpString, dotAll: true); |
| final String contents = file.readAsStringSync(); |
| if (!regExp.hasMatch(contents)) { |
| throw Exception("Missing example code matching '$regExpString' in ${file.path}."); |
| } |
| } else { |
| throw Exception( |
| "Missing example code sanity test file ${file.path}. Either it didn't get published, or you might have to update the test to look at a different file."); |
| } |
| } |
| |
| /// A subset of all generated doc files for [_sanityCheckDocs]. |
| @visibleForTesting |
| List<File> get canaries { |
| final Directory flutterDirectory = publishRoot.childDirectory('flutter'); |
| final Directory widgetsDirectory = flutterDirectory.childDirectory('widgets'); |
| |
| return <File>[ |
| publishRoot.childDirectory('assets').childFile('overrides.css'), |
| flutterDirectory.childDirectory('dart-io').childFile('File-class.html'), |
| flutterDirectory.childDirectory('dart-ui').childFile('Canvas-class.html'), |
| flutterDirectory.childDirectory('dart-ui').childDirectory('Canvas').childFile('drawRect.html'), |
| flutterDirectory |
| .childDirectory('flutter_driver') |
| .childDirectory('FlutterDriver') |
| .childFile('FlutterDriver.connectedTo.html'), |
| flutterDirectory.childDirectory('flutter_test').childDirectory('WidgetTester').childFile('pumpWidget.html'), |
| flutterDirectory.childDirectory('material').childFile('Material-class.html'), |
| flutterDirectory.childDirectory('material').childFile('Tooltip-class.html'), |
| widgetsDirectory.childFile('Widget-class.html'), |
| widgetsDirectory.childFile('Listener-class.html'), |
| ]; |
| } |
| |
| /// Runs a sanity check by running a test. |
| void _sanityCheckDocs([Platform platform = const LocalPlatform()]) { |
| for (final File canary in canaries) { |
| if (!canary.existsSync()) { |
| throw Exception('Missing "${canary.path}", which probably means the documentation failed to build correctly.'); |
| } |
| } |
| // Make sure at least one example of each kind includes source code. |
| final Directory widgetsDirectory = publishRoot |
| .childDirectory('flutter') |
| .childDirectory('widgets'); |
| |
| // Check a "sample" example, any one will do. |
| _sanityCheckExample( |
| widgetsDirectory.childFile('showGeneralDialog.html').path, |
| r'\s*<pre\s+id="longSnippet1".*<code\s+class="language-dart">\s*import 'package:flutter/material.dart';', |
| ); |
| |
| // Check a "snippet" example, any one will do. |
| _sanityCheckExample( |
| widgetsDirectory.childDirectory('ModalRoute').childFile('barrierColor.html').path, |
| r'\s*<pre.*id="sample-code">.*Color\s+get\s+barrierColor.*</pre>', |
| ); |
| |
| // Check a "dartpad" example, any one will do, and check for the correct URL |
| // arguments. |
| // Just use "master" for any branch other than the LUCI_BRANCH. |
| final String? luciBranch = platform.environment['LUCI_BRANCH']?.trim(); |
| final String expectedBranch = luciBranch != null && luciBranch.isNotEmpty ? luciBranch : 'master'; |
| final List<String> argumentRegExps = <String>[ |
| r'split=\d+', |
| r'run=true', |
| r'sample_id=widgets\.Listener\.\d+', |
| 'sample_channel=$expectedBranch', |
| 'channel=$expectedBranch', |
| ]; |
| for (final String argumentRegExp in argumentRegExps) { |
| _sanityCheckExample( |
| widgetsDirectory.childFile('Listener-class.html').path, |
| r'\s*<iframe\s+class="snippet-dartpad"\s+src="' |
| r'https:\/\/dartpad.dev\/embed-flutter.html\?.*?\b' |
| '$argumentRegExp' |
| r'\b.*">\s*<\/iframe>', |
| ); |
| } |
| } |
| |
| /// 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 ${publishRoot.childFile('index.html').path}'); |
| _copyIndexToRootOfDocs(); |
| _addHtmlBaseToIndex(); |
| _changePackageToSdkInTitlebar(); |
| _putRedirectInOldIndexLocation(); |
| _writeSnippetsIndexFile(); |
| print('\nDocs ready to go!'); |
| } |
| |
| void _copyIndexToRootOfDocs() { |
| publishRoot.childDirectory('flutter').childFile('index.html').copySync(publishRoot.childFile('index.html').path); |
| } |
| |
| void _changePackageToSdkInTitlebar() { |
| final File indexFile = publishRoot.childFile('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 = publishRoot.childFile('index.html'); |
| String indexContents = indexFile.readAsStringSync(); |
| indexContents = indexContents.replaceFirst( |
| '</title>\n', |
| '</title>\n <base href="./flutter/">\n', |
| ); |
| |
| for (final String platform in kPlatformDocs.keys) { |
| final String sectionName = kPlatformDocs[platform]!.sectionName; |
| final String subdir = kPlatformDocs[platform]!.subdir; |
| indexContents = indexContents.replaceAll( |
| 'href="$sectionName/$sectionName-library.html"', |
| 'href="../$subdir/index.html"', |
| ); |
| } |
| |
| indexFile.writeAsStringSync(indexContents); |
| } |
| |
| void _putRedirectInOldIndexLocation() { |
| const String metaTag = '<meta http-equiv="refresh" content="0;URL=../index.html">'; |
| publishRoot.childDirectory('flutter').childFile('index.html').writeAsStringSync(metaTag); |
| } |
| |
| void _writeSnippetsIndexFile() { |
| final Directory snippetsDir = publishRoot.childDirectory('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); |
| snippetsDir.childFile('index.json').writeAsStringSync(jsonArray); |
| } |
| } |
| } |
| |
| /// Downloads and unpacks the platform specific documentation generated by the |
| /// engine build. |
| /// |
| /// Unpacks and massages the data so that it can be properly included in the |
| /// output archive. |
| class PlatformDocGenerator { |
| PlatformDocGenerator({required this.outputDir, required this.filesystem}); |
| |
| final FileSystem filesystem; |
| final Directory outputDir; |
| final String engineRevision = FlutterInformation.instance.getEngineRevision(); |
| final String engineRealm = FlutterInformation.instance.getEngineRealm(); |
| |
| /// This downloads an archive of platform docs for the engine from the artifact |
| /// store and extracts them to the location used for Dartdoc. |
| Future<void> generatePlatformDocs() async { |
| final String realm = engineRealm.isNotEmpty ? '$engineRealm/' : ''; |
| |
| for (final String platform in kPlatformDocs.keys) { |
| final String zipFile = kPlatformDocs[platform]!.zipName; |
| final String url = |
| 'https://storage.googleapis.com/${realm}flutter_infra_release/flutter/$engineRevision/$zipFile'; |
| await _extractDocs(url, platform, kPlatformDocs[platform]!, outputDir); |
| } |
| } |
| |
| /// Fetches the zip archive at the specified url. |
| /// |
| /// Returns null if the archive fails to download after [maxTries] attempts. |
| Future<Archive?> _fetchArchive(String url, int maxTries) async { |
| List<int>? responseBytes; |
| for (int i = 0; i < maxTries; i++) { |
| final http.Response response = await http.get(Uri.parse(url)); |
| if (response.statusCode == 200) { |
| responseBytes = response.bodyBytes; |
| break; |
| } |
| stderr.writeln('Failed attempt ${i + 1} to fetch $url.'); |
| |
| // On failure print a short snipped from the body in case it's helpful. |
| final int bodyLength = math.min(1024, response.body.length); |
| stderr.writeln('Response status code ${response.statusCode}. Body: ${response.body.substring(0, bodyLength)}'); |
| sleep(const Duration(seconds: 1)); |
| } |
| return responseBytes == null ? null : ZipDecoder().decodeBytes(responseBytes); |
| } |
| |
| Future<void> _extractDocs(String url, String name, PlatformDocsSection platform, Directory outputDir) async { |
| const int maxTries = 5; |
| final Archive? archive = await _fetchArchive(url, maxTries); |
| if (archive == null) { |
| stderr.writeln('Failed to fetch zip archive from: $url after $maxTries attempts. Giving up.'); |
| exit(1); |
| } |
| |
| final Directory output = outputDir.childDirectory(platform.subdir); |
| print('Extracting ${platform.zipName} to ${output.path}'); |
| output.createSync(recursive: true); |
| |
| for (final ArchiveFile af in archive) { |
| if (!af.name.endsWith('/')) { |
| final File file = filesystem.file('${output.path}/${af.name}'); |
| file.createSync(recursive: true); |
| file.writeAsBytesSync(af.content as List<int>); |
| } |
| } |
| |
| final File testFile = output.childFile(platform.checkFile); |
| if (!testFile.existsSync()) { |
| print('Expected file ${testFile.path} not found'); |
| exit(1); |
| } |
| print('${platform.sectionName} ready to go!'); |
| } |
| } |
| |
| /// 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, required FileSystem filesystem}) { |
| 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 = filesystem.file(newPath); |
| entity.copySync(newPath); |
| onFileCopied?.call(entity, newFile); |
| } else if (entity is Directory) { |
| copyDirectorySync(entity, filesystem.directory(newPath), filesystem: filesystem); |
| } else { |
| throw Exception('${entity.path} is neither File nor Directory'); |
| } |
| } |
| } |
| |
| void printStream(Stream<List<int>> stream, {String prefix = '', List<Pattern> filter = const <Pattern>[]}) { |
| stream.transform<String>(utf8.decoder).transform<String>(const LineSplitter()).listen((String line) { |
| if (!filter.any((Pattern pattern) => line.contains(pattern))) { |
| print('$prefix$line'.trim()); |
| } |
| }); |
| } |
| |
| void zipDirectory(Directory src, File output, {required ProcessManager processManager}) { |
| // We would use the archive package to do this in one line, but it |
| // is a lot slower, and doesn't do compression nearly as well. |
| final ProcessResult zipProcess = processManager.runSync( |
| <String>[ |
| 'zip', |
| '-r', |
| '-9', |
| '-q', |
| output.path, |
| '.', |
| ], |
| workingDirectory: src.path, |
| ); |
| |
| if (zipProcess.exitCode != 0) { |
| print('Creating offline ZIP archive ${output.path} failed:'); |
| print(zipProcess.stderr); |
| exit(1); |
| } |
| } |
| |
| void tarDirectory(Directory src, File output, {required ProcessManager processManager}) { |
| // We would use the archive package to do this in one line, but it |
| // is a lot slower, and doesn't do compression nearly as well. |
| final ProcessResult tarProcess = processManager.runSync( |
| <String>[ |
| 'tar', |
| 'cf', |
| output.path, |
| '--use-compress-program', |
| 'gzip --best', |
| 'flutter.docset', |
| ], |
| workingDirectory: src.path, |
| ); |
| |
| if (tarProcess.exitCode != 0) { |
| print('Creating a tarball ${output.path} failed:'); |
| print(tarProcess.stderr); |
| exit(1); |
| } |
| } |
| |
| Future<Process> runPubProcess({ |
| required List<String> arguments, |
| Directory? workingDirectory, |
| Map<String, String>? environment, |
| @visibleForTesting ProcessManager processManager = const LocalProcessManager(), |
| @visibleForTesting FileSystem filesystem = const LocalFileSystem(), |
| }) { |
| return processManager.start( |
| <Object>[FlutterInformation.instance.getFlutterBinaryPath().path, 'pub', ...arguments], |
| workingDirectory: (workingDirectory ?? filesystem.currentDirectory).path, |
| environment: environment, |
| ); |
| } |
| |
| List<String> findPackageNames(FileSystem filesystem) { |
| return findPackages(filesystem).map<String>((FileSystemEntity file) => path.basename(file.path)).toList(); |
| } |
| |
| /// Finds all packages in the Flutter SDK |
| List<Directory> findPackages(FileSystem filesystem) { |
| return FlutterInformation.instance |
| .getFlutterRoot() |
| .childDirectory('packages') |
| .listSync() |
| .where((FileSystemEntity entity) { |
| if (entity is! Directory) { |
| return false; |
| } |
| final File pubspec = entity.childFile('pubspec.yaml'); |
| if (!pubspec.existsSync()) { |
| print("Unexpected package '${entity.path}' found in packages directory"); |
| return false; |
| } |
| // Would be nice to use a real YAML parser here, but we don't want to |
| // depend on a whole package for it, and this is sufficient. |
| return !pubspec.readAsStringSync().contains('nodoc: true'); |
| }) |
| .cast<Directory>() |
| .toList(); |
| } |
| |
| /// An exception class used to indicate problems when collecting information. |
| class FlutterInformationException implements Exception { |
| FlutterInformationException(this.message); |
| final String message; |
| |
| @override |
| String toString() { |
| return '$runtimeType: $message'; |
| } |
| } |
| |
| /// A singleton used to consolidate the way in which information about the |
| /// Flutter repo and environment is collected. |
| /// |
| /// Collects the information once, and caches it for any later access. |
| /// |
| /// The singleton instance can be overridden by tests by setting [instance]. |
| class FlutterInformation { |
| FlutterInformation({ |
| this.platform = const LocalPlatform(), |
| this.processManager = const LocalProcessManager(), |
| this.filesystem = const LocalFileSystem(), |
| }); |
| |
| final Platform platform; |
| final ProcessManager processManager; |
| final FileSystem filesystem; |
| |
| static FlutterInformation? _instance; |
| |
| static FlutterInformation get instance => _instance ??= FlutterInformation(); |
| |
| @visibleForTesting |
| static set instance(FlutterInformation? value) => _instance = value; |
| |
| /// The path to the Dart binary in the Flutter repo. |
| /// |
| /// This is probably a shell script. |
| File getDartBinaryPath() { |
| return getFlutterRoot().childDirectory('bin').childFile('dart'); |
| } |
| |
| /// The path to the Dart binary in the Flutter repo. |
| /// |
| /// This is probably a shell script. |
| File getFlutterBinaryPath() { |
| return getFlutterRoot().childDirectory('bin').childFile('flutter'); |
| } |
| |
| /// The path to the Flutter repo root directory. |
| /// |
| /// If the environment variable `FLUTTER_ROOT` is set, will use that instead |
| /// of looking for it. |
| /// |
| /// Otherwise, uses the output of `flutter --version --machine` to find the |
| /// Flutter root. |
| Directory getFlutterRoot() { |
| if (platform.environment['FLUTTER_ROOT'] != null) { |
| return filesystem.directory(platform.environment['FLUTTER_ROOT']); |
| } |
| return getFlutterInformation()['flutterRoot']! as Directory; |
| } |
| |
| /// Gets the semver version of the Flutter framework in the repo. |
| Version getFlutterVersion() => getFlutterInformation()['frameworkVersion']! as Version; |
| |
| /// Gets the git hash of the engine used by the Flutter framework in the repo. |
| String getEngineRevision() => getFlutterInformation()['engineRevision']! as String; |
| |
| /// Gets the value stored in bin/internal/engine.realm used by the Flutter |
| /// framework repo. |
| String getEngineRealm() => getFlutterInformation()['engineRealm']! as String; |
| |
| /// Gets the git hash of the Flutter framework in the repo. |
| String getFlutterRevision() => getFlutterInformation()['flutterGitRevision']! as String; |
| |
| /// Gets the name of the current branch in the Flutter framework in the repo. |
| String getBranchName() => getFlutterInformation()['branchName']! as String; |
| |
| Map<String, Object>? _cachedFlutterInformation; |
| |
| /// Gets a Map of various kinds of information about the Flutter repo. |
| Map<String, Object> getFlutterInformation() { |
| if (_cachedFlutterInformation != null) { |
| return _cachedFlutterInformation!; |
| } |
| |
| String flutterVersionJson; |
| if (platform.environment['FLUTTER_VERSION'] != null) { |
| flutterVersionJson = platform.environment['FLUTTER_VERSION']!; |
| } else { |
| // Determine which flutter command to run, which will determine which |
| // flutter root is eventually used. If the FLUTTER_ROOT is set, then use |
| // that flutter command, otherwise use the first one in the PATH. |
| String flutterCommand; |
| if (platform.environment['FLUTTER_ROOT'] != null) { |
| flutterCommand = filesystem |
| .directory(platform.environment['FLUTTER_ROOT']) |
| .childDirectory('bin') |
| .childFile('flutter') |
| .absolute |
| .path; |
| } else { |
| flutterCommand = 'flutter'; |
| } |
| ProcessResult result; |
| try { |
| result = processManager.runSync( |
| <String>[flutterCommand, '--version', '--machine'], |
| stdoutEncoding: utf8, |
| ); |
| } on ProcessException catch (e) { |
| throw FlutterInformationException( |
| 'Unable to determine Flutter information. Either set FLUTTER_ROOT, or place the ' |
| 'flutter command in your PATH.\n$e'); |
| } |
| if (result.exitCode != 0) { |
| throw FlutterInformationException( |
| 'Unable to determine Flutter information, because of abnormal exit of flutter command.'); |
| } |
| // Strip out any non-JSON that might be printed along with the command |
| // output. |
| flutterVersionJson = (result.stdout as String) |
| .replaceAll('Waiting for another flutter command to release the startup lock...', ''); |
| } |
| |
| final Map<String, dynamic> flutterVersion = json.decode(flutterVersionJson) as Map<String, dynamic>; |
| if (flutterVersion['flutterRoot'] == null || |
| flutterVersion['frameworkVersion'] == null || |
| flutterVersion['dartSdkVersion'] == null) { |
| throw FlutterInformationException( |
| 'Flutter command output has unexpected format, unable to determine flutter root location.'); |
| } |
| |
| final Map<String, Object> info = <String, Object>{}; |
| final Directory flutterRoot = filesystem.directory(flutterVersion['flutterRoot']! as String); |
| info['flutterRoot'] = flutterRoot; |
| info['frameworkVersion'] = Version.parse(flutterVersion['frameworkVersion'] as String); |
| info['engineRevision'] = flutterVersion['engineRevision'] as String; |
| final File engineRealm = flutterRoot.childDirectory('bin').childDirectory('internal').childFile('engine.realm'); |
| info['engineRealm'] = engineRealm.existsSync() ? engineRealm.readAsStringSync().trim() : ''; |
| |
| final RegExpMatch? dartVersionRegex = RegExp(r'(?<base>[\d.]+)(?:\s+\(build (?<detail>[-.\w]+)\))?') |
| .firstMatch(flutterVersion['dartSdkVersion'] as String); |
| if (dartVersionRegex == null) { |
| throw FlutterInformationException( |
| 'Flutter command output has unexpected format, unable to parse dart SDK version ${flutterVersion['dartSdkVersion']}.'); |
| } |
| info['dartSdkVersion'] = |
| Version.parse(dartVersionRegex.namedGroup('detail') ?? dartVersionRegex.namedGroup('base')!); |
| |
| info['branchName'] = _getBranchName(); |
| info['flutterGitRevision'] = _getFlutterGitRevision(); |
| _cachedFlutterInformation = info; |
| |
| return info; |
| } |
| |
| // 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() { |
| 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 RegExp gitBranchRegexp = RegExp(r'^## (.*)'); |
| final RegExpMatch? gitBranchMatch = |
| gitBranchRegexp.firstMatch((gitResult.stdout as String).trim().split('\n').first); |
| return gitBranchMatch == null ? '' : gitBranchMatch.group(1)!.split('...').first; |
| } |
| |
| // Get the git revision for the repo. |
| String _getFlutterGitRevision() { |
| const int kGitRevisionLength = 10; |
| |
| final ProcessResult gitResult = processManager.runSync(<String>['git', '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; |
| } |
| } |