dartdoc.dart 17.6 KB
Newer Older
1 2 3 4 5 6 7 8
// Copyright 2016 The Chromium 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:async';
import 'dart:convert';
import 'dart:io';

9
import 'package:args/args.dart';
10
import 'package:intl/intl.dart';
11
import 'package:path/path.dart' as path;
12
import 'package:process/process.dart';
13

14 15
const String kDocsRoot = 'dev/docs';
const String kPublishRoot = '$kDocsRoot/doc';
16
const String kSnippetsRoot = 'dev/snippets';
Seth Ladd's avatar
Seth Ladd committed
17

18 19 20
/// 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/`.
21
///
Seth Ladd's avatar
Seth Ladd committed
22 23 24 25
/// This script also updates the index.html file so that it can be placed
/// at the root of docs.flutter.io. We are keeping the files inside of
/// docs.flutter.io/flutter for now, so we need to manipulate paths
/// a bit. See https://github.com/flutter/flutter/issues/3900 for more info.
26 27 28 29 30
///
/// 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.
31
Future<void> main(List<String> arguments) async {
32 33 34 35 36 37 38
  final ArgParser argParser = _createArgsParser();
  final ArgResults args = argParser.parse(arguments);
  if (args['help']) {
    print ('Usage:');
    print (argParser.usage);
    exit(0);
  }
39 40 41 42
  // 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;

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 54 55 56 57 58
  // TODO(dnfield): We should make DartDoc able to avoid emitting this. If we
  // use the real value here, every file will get marked as new instead of only
  // files that have otherwise changed. Instead, we replace it dynamically using
  // JavaScript so that fewer files get marked as changed.
  // https://github.com/dart-lang/dartdoc/issues/1982
  buf.writeln('version: 0.0.0');
59
  buf.writeln('dependencies:');
Seth Ladd's avatar
Seth Ladd committed
60
  for (String package in findPackageNames()) {
61
    buf.writeln('  $package:');
62
    buf.writeln('    sdk: flutter');
63
  }
64 65 66 67
  buf.writeln('  platform_integration: 0.0.1');
  buf.writeln('dependency_overrides:');
  buf.writeln('  platform_integration:');
  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 73
  libDir.createSync();

74
  final StringBuffer contents = StringBuffer('library temp_doc;\n\n');
Seth Ladd's avatar
Seth Ladd committed
75
  for (String libraryRef in libraryRefs()) {
76 77
    contents.writeln('import \'package:$libraryRef\';');
  }
78
  File('$kDocsRoot/lib/temp_doc.dart').writeAsStringSync(contents.toString());
79

80 81 82 83 84 85 86
  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';
87
  if (Directory(pubCachePath).existsSync()) {
88 89 90 91 92
    pubEnvironment['PUB_CACHE'] = pubCachePath;
  }

  final String pubExecutable = '$flutterRoot/bin/cache/dart-sdk/bin/pub';

93
  // Run pub.
94
  ProcessWrapper process = ProcessWrapper(await Process.start(
95
    pubExecutable,
96
    <String>['get'],
97
    workingDirectory: kDocsRoot,
98
    environment: pubEnvironment,
99
  ));
100 101
  printStream(process.stdout, prefix: 'pub:stdout: ');
  printStream(process.stderr, prefix: 'pub:stderr: ');
102
  final int code = await process.done;
103 104 105
  if (code != 0)
    exit(code);

106
  createFooter('$kDocsRoot/lib/', version);
107
  copyAssets();
108
  createSearchMetadata('$kDocsRoot/lib/opensearch.xml', '$kDocsRoot/doc/opensearch.xml');
109
  cleanOutSnippets();
110

111 112 113 114 115 116
  final List<String> dartdocBaseArgs = <String>['global', 'run'];
  if (args['checked']) {
    dartdocBaseArgs.add('-c');
  }
  dartdocBaseArgs.add('dartdoc');

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 127
  dartdocBaseArgs.add('--allow-tools');

128 129 130
  if (args['json']) {
    dartdocBaseArgs.add('--json');
  }
131 132 133 134 135
  if (args['validate-links']) {
    dartdocBaseArgs.add('--validate-links');
  } else {
    dartdocBaseArgs.add('--no-validate-links');
  }
136 137
  dartdocBaseArgs.addAll(<String>['--link-to-source-excludes', '../../bin/cache',
                                  '--link-to-source-root', '../..',
Dan Field's avatar
Dan Field committed
138
                                  '--link-to-source-uri-template', 'https://github.com/flutter/flutter/blob/master/%f%#L%l%']);
139
  // Generate the documentation.
140 141
  // 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
142 143
  final List<String> dartdocArgs = <String>[
    ...dartdocBaseArgs,
144
    '--inject-html',
145 146
    '--header', 'styles.html',
    '--header', 'analytics.html',
147
    '--header', 'survey.html',
148
    '--header', 'snippets.html',
149
    '--header', 'opensearch.html',
150
    '--footer-text', 'lib/footer.html',
151 152
    '--allow-warnings-in-packages',
    <String>[
153 154 155 156 157 158 159
      'Flutter',
      'flutter',
      'platform_integration',
      'flutter_test',
      'flutter_driver',
      'flutter_localizations',
    ].join(','),
160
    '--exclude-packages',
161 162 163 164 165 166 167
    <String>[
      'analyzer',
      'args',
      'barback',
      'cli_util',
      'csslib',
      'flutter_goldens',
168
      'flutter_goldens_client',
169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190
      '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(','),
191
    '--exclude',
192 193 194 195 196 197 198 199 200 201
    <String>[
      '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(','),
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 217
  printStream(process.stdout, prefix: args['json'] ? '' : 'dartdoc:stdout: ',
    filter: args['verbose'] ? const <Pattern>[] : <Pattern>[
218 219
      RegExp(r'^generating docs for library '), // unnecessary verbosity
      RegExp(r'^pars'), // unnecessary verbosity
220 221
    ],
  );
222 223
  printStream(process.stderr, prefix: args['json'] ? '' : 'dartdoc:stderr: ',
    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;
Seth Ladd's avatar
Seth Ladd committed
228 229 230 231

  if (exitCode != 0)
    exit(exitCode);

232 233
  sanityCheckDocs();

Seth Ladd's avatar
Seth Ladd committed
234 235 236
  createIndexAndCleanup();
}

237
ArgParser _createArgsParser() {
238
  final ArgParser parser = ArgParser();
239 240 241 242 243 244 245 246 247 248
  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.');
249 250
  parser.addFlag('validate-links', negatable: true,
      help: 'Display warnings for broken links generated by dartdoc (slow)');
251 252 253
  return parser;
}

254
final RegExp gitBranchRegexp = RegExp(r'^## (.*)');
255

256 257 258 259 260 261 262 263 264
String getBranchName() {
  final ProcessResult gitResult = Process.runSync('git', <String>['status', '-b', '--porcelain']);
  if (gitResult.exitCode != 0)
    throw 'git status exit with non-zero exit code: ${gitResult.exitCode}';
  final Match gitBranchMatch = gitBranchRegexp.firstMatch(
      gitResult.stdout.trim().split('\n').first);
  return gitBranchMatch == null ? '' : gitBranchMatch.group(1).split('...').first;
}

265
String gitRevision() {
266 267
  const int kGitRevisionLength = 10;

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 274
  return gitRevision.length > kGitRevisionLength ? gitRevision.substring(0, kGitRevisionLength) : gitRevision;
}
275

276
void createFooter(String footerPath, String version) {
277
  final String timestamp = DateFormat('yyyy-MM-dd HH:mm').format(DateTime.now());
278
  final String gitBranch = getBranchName();
279 280 281 282
  final String gitBranchOut = gitBranch.isEmpty ? '' : '• $gitBranch';
  File('${footerPath}footer.html').writeAsStringSync('<script src="footer.js"></script>');
  File('$kPublishRoot/api/footer.js')
    ..createSync(recursive: true)
Dan Field's avatar
Dan Field committed
283 284 285 286 287 288 289 290 291 292
    ..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()}/');
  }
})();
293
''');
294 295
}

296 297 298 299 300 301 302 303 304 305 306 307 308 309 310
/// 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://docs.flutter.io/' : 'https://master-docs.flutter.io/',
  );
  Directory(path.dirname(metadataPath)).create(recursive: true);
  File(metadataPath).writeAsStringSync(metadata);
}

311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346
/// 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 onFileCopied(File srcFile, File destFile)]) {
  if (!srcDir.existsSync())
    throw Exception('Source directory "${srcDir.path}" does not exist, nothing to copy');

  if (!destDir.existsSync())
    destDir.createSync(recursive: true);

  for (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}'));
}

347 348
/// Clean out any existing snippets so that we don't publish old files from
/// previous runs accidentally.
349 350 351 352 353 354 355 356 357
void cleanOutSnippets() {
  final Directory snippetsDir = Directory(path.join(kPublishRoot, 'snippets'));
  if (snippetsDir.existsSync()) {
    snippetsDir
      ..deleteSync(recursive: true)
      ..createSync(recursive: true);
  }
}

358
void sanityCheckDocs() {
359
  final List<String> canaries = <String>[
360 361 362 363 364 365 366 367 368
    '$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',
369 370
  ];
  for (String canary in canaries) {
371 372
    if (!File(canary).existsSync())
      throw Exception('Missing "$canary", which probably means the documentation failed to build correctly.');
373 374 375
  }
}

Seth Ladd's avatar
Seth Ladd committed
376 377 378
/// Creates a custom index.html because we try to maintain old
/// paths. Cleanup unused index.html files no longer needed.
void createIndexAndCleanup() {
379
  print('\nCreating a custom index.html in $kPublishRoot/index.html');
380
  removeOldFlutterDocsDir();
Seth Ladd's avatar
Seth Ladd committed
381 382 383
  renameApiDir();
  copyIndexToRootOfDocs();
  addHtmlBaseToIndex();
384
  changePackageToSdkInTitlebar();
Seth Ladd's avatar
Seth Ladd committed
385
  putRedirectInOldIndexLocation();
386
  writeSnippetsIndexFile();
Seth Ladd's avatar
Seth Ladd committed
387 388 389
  print('\nDocs ready to go!');
}

390 391
void removeOldFlutterDocsDir() {
  try {
392
    Directory('$kPublishRoot/flutter').deleteSync(recursive: true);
393
  } on FileSystemException {
394 395 396 397
    // If the directory does not exist, that's OK.
  }
}

Seth Ladd's avatar
Seth Ladd committed
398
void renameApiDir() {
399
  Directory('$kPublishRoot/api').renameSync('$kPublishRoot/flutter');
Seth Ladd's avatar
Seth Ladd committed
400 401
}

402
void copyIndexToRootOfDocs() {
403
  File('$kPublishRoot/flutter/index.html').copySync('$kPublishRoot/index.html');
Seth Ladd's avatar
Seth Ladd committed
404 405
}

406
void changePackageToSdkInTitlebar() {
407
  final File indexFile = File('$kPublishRoot/index.html');
408 409
  String indexContents = indexFile.readAsStringSync();
  indexContents = indexContents.replaceFirst(
410 411
    '<li><a href="https://flutter.dev">Flutter package</a></li>',
    '<li><a href="https://flutter.dev">Flutter SDK</a></li>',
412 413 414 415 416
  );

  indexFile.writeAsStringSync(indexContents);
}

Seth Ladd's avatar
Seth Ladd committed
417
void addHtmlBaseToIndex() {
418
  final File indexFile = File('$kPublishRoot/index.html');
Seth Ladd's avatar
Seth Ladd committed
419
  String indexContents = indexFile.readAsStringSync();
420 421 422 423
  indexContents = indexContents.replaceFirst(
    '</title>\n',
    '</title>\n  <base href="./flutter/">\n',
  );
424 425
  indexContents = indexContents.replaceAll(
    'href="Android/Android-library.html"',
426
    'href="/javadoc/"',
427
  );
428 429
  indexContents = indexContents.replaceAll(
      'href="iOS/iOS-library.html"',
430
      'href="/objcdoc/"',
431 432
  );

Seth Ladd's avatar
Seth Ladd committed
433 434 435 436
  indexFile.writeAsStringSync(indexContents);
}

void 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 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457

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);
  }
}

Seth Ladd's avatar
Seth Ladd committed
458
List<String> findPackageNames() {
459
  return findPackages().map<String>((FileSystemEntity file) => path.basename(file.path)).toList();
460 461
}

462
/// Finds all packages in the Flutter SDK
463
List<FileSystemEntity> findPackages() {
464
  return Directory('packages')
465
    .listSync()
466 467 468
    .where((FileSystemEntity entity) {
      if (entity is! Directory)
        return false;
469
      final File pubspec = File('${entity.path}/pubspec.yaml');
470 471
      // TODO(ianh): Use a real YAML parser here
      return !pubspec.readAsStringSync().contains('nodoc: true');
472
    })
473
    .cast<Directory>()
474 475 476
    .toList();
}

477
/// Returns import or on-disk paths for all libraries in the Flutter SDK.
478
Iterable<String> libraryRefs() sync* {
Seth Ladd's avatar
Seth Ladd committed
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 484
        yield '$dirName/${path.basename(file.path)}';
      }
485 486
    }
  }
487 488

  // Add a fake package for platform integration APIs.
489 490
  yield 'platform_integration/android.dart';
  yield 'platform_integration/ios.dart';
491 492
}

493
void printStream(Stream<List<int>> stream, { String prefix = '', List<Pattern> filter = const <Pattern>[] }) {
494 495
  assert(prefix != null);
  assert(filter != null);
496
  stream
497 498
    .transform<String>(utf8.decoder)
    .transform<String>(const LineSplitter())
499 500 501 502
    .listen((String line) {
      if (!filter.any((Pattern pattern) => line.contains(pattern)))
        print('$prefix$line'.trim());
    });
503
}