sample_page.dart 9.66 KB
Newer Older
1 2 3 4 5 6 7 8 9
// Copyright 2017 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.

// This application generates markdown pages and screenshots for each
// sample app. For more information see ../README.md.

import 'dart:io';

10 11
import 'package:path/path.dart';

12 13 14 15
class SampleError extends Error {
  SampleError(this.message);
  final String message;
  @override
16
  String toString() => 'SampleError($message)';
17 18 19 20
}

// Sample apps are .dart files in the lib directory which contain a block
// comment that begins with a '/* Sample Catalog' line, and ends with a line
21
// that just contains '*/'. The following keywords may appear at the
22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47
// beginning of lines within the comment. A keyword's value is all of
// the following text up to the next keyword or the end of the comment,
// sans leading and trailing whitespace.
const String sampleCatalogKeywords = r'^Title:|^Summary:|^Description:|^Classes:|^Sample:|^See also:';

Directory outputDirectory;
Directory sampleDirectory;
Directory testDirectory;
Directory driverDirectory;

void logMessage(String s) { print(s); }
void logError(String s) { print(s); }

File inputFile(String dir, String name) {
  return new File(dir + Platform.pathSeparator + name);
}

File outputFile(String name, [Directory directory]) {
  return new File((directory ?? outputDirectory).path + Platform.pathSeparator + name);
}

void initialize() {
  outputDirectory = new Directory('.generated');
  sampleDirectory = new Directory('lib');
  testDirectory = new Directory('test');
  driverDirectory = new Directory('test_driver');
48
  outputDirectory.createSync();
49 50 51 52 53 54 55 56 57 58 59
}

// Return a copy of template with each occurrence of @(foo) replaced
// by values[foo].
String expandTemplate(String template, Map<String, String> values) {
  // Matches @(foo), match[1] == 'foo'
  final RegExp tokenRE = new RegExp(r'@\(([\w ]+)\)', multiLine: true);
  return template.replaceAllMapped(tokenRE, (Match match) {
    if (match.groupCount != 1)
      throw new SampleError('bad template keyword $match[0]');
    final String keyword = match[1];
60
    return values[keyword] ?? '';
61 62 63 64 65 66 67 68
  });
}

void writeExpandedTemplate(File output, String template, Map<String, String> values) {
  output.writeAsStringSync(expandTemplate(template, values));
  logMessage('wrote $output');
}

69 70
class SampleInfo {
  SampleInfo(this.sourceFile, this.commit);
71 72

  final File sourceFile;
73
  final String commit;
74 75 76 77 78
  String sourceCode;
  Map<String, String> commentValues;

  // If sourceFile is lib/foo.dart then sourceName is foo. The sourceName
  // is used to create derived filenames like foo.md or foo.png.
79
  String get sourceName => basenameWithoutExtension(sourceFile.path);
80

81 82 83
  // The website's link to this page will be /catalog/samples/@(link)/.
  String get link => sourceName.replaceAll('_', '-');

84
  // The name of the widget class that defines this sample app, like 'FooSample'.
85 86 87 88 89 90 91 92 93
  String get sampleClass => commentValues['sample'];

  // The value of the 'Classes:' comment as a list of class names.
  Iterable<String> get highlightedClasses {
    final String classNames = commentValues['classes'];
    if (classNames == null)
      return const <String>[];
    return classNames.split(',').map((String s) => s.trim()).where((String s) => s.isNotEmpty);
  }
94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134

  // The relative import path for this sample, like '../lib/foo.dart'.
  String get importPath => '..' + Platform.pathSeparator + sourceFile.path;

  // Return true if we're able to find the "Sample Catalog" comment in the
  // sourceFile, and we're able to load its keyword/value pairs into
  // the commentValues Map. The rest of the file's contents are saved
  // in sourceCode.
  bool initialize() {
    final String contents = sourceFile.readAsStringSync();

    final RegExp startRE = new RegExp(r'^/\*\s+^Sample\s+Catalog', multiLine: true);
    final RegExp endRE = new RegExp(r'^\*/', multiLine: true);
    final Match startMatch = startRE.firstMatch(contents);
    if (startMatch == null)
      return false;

    final int startIndex = startMatch.end;
    final Match endMatch = endRE.firstMatch(contents.substring(startIndex));
    if (endMatch == null)
      return false;

    final String comment = contents.substring(startIndex, startIndex + endMatch.start);
    sourceCode = contents.substring(0, startMatch.start) + contents.substring(startIndex + endMatch.end);
    if (sourceCode.trim().isEmpty)
      throw new SampleError('did not find any source code in $sourceFile');

    final RegExp keywordsRE = new RegExp(sampleCatalogKeywords, multiLine: true);
    final List<Match> keywordMatches = keywordsRE.allMatches(comment).toList();
    if (keywordMatches.isEmpty)
      throw new SampleError('did not find any keywords in the Sample Catalog comment in $sourceFile');

    commentValues = <String, String>{};
    for (int i = 0; i < keywordMatches.length; i += 1) {
      final String keyword = comment.substring(keywordMatches[i].start, keywordMatches[i].end - 1);
      final String value = comment.substring(
        keywordMatches[i].end,
        i == keywordMatches.length - 1 ? null : keywordMatches[i + 1].start,
      );
      commentValues[keyword.toLowerCase()] = value.trim();
    }
135 136
    commentValues['name'] = sourceName;
    commentValues['path'] = 'examples/catalog/${sourceFile.path}';
137
    commentValues['source'] = sourceCode.trim();
138 139
    commentValues['link'] = link;
    commentValues['android screenshot'] = 'https://storage.googleapis.com/flutter-catalog/$commit/${sourceName}_small.png';
140 141 142 143 144

    return true;
  }
}

145
void generate(String commit) {
146 147
  initialize();

148
  final List<SampleInfo> samples = <SampleInfo>[];
149
  for (FileSystemEntity entity in sampleDirectory.listSync()) {
150
    if (entity is File && entity.path.endsWith('.dart')) {
151 152
      final SampleInfo sample = new SampleInfo(entity, commit);
      if (sample.initialize()) // skip files that lack the Sample Catalog comment
153 154
        samples.add(sample);
    }
155
  }
156

157 158
  // Causes the generated imports to appear in alphabetical order.
  // Avoid complaints from flutter lint.
159
  samples.sort((SampleInfo a, SampleInfo b) {
160 161 162
    return a.sourceName.compareTo(b.sourceName);
  });

163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213
  final String entryTemplate = inputFile('bin', 'entry.md.template').readAsStringSync();

  // Write the sample catalog's home page: index.md
  final Iterable<String> entries = samples.map((SampleInfo sample) {
    return expandTemplate(entryTemplate, sample.commentValues);
  });
  writeExpandedTemplate(
    outputFile('index.md'),
    inputFile('bin', 'index.md.template').readAsStringSync(),
    <String, String>{
      'entries': entries.join('\n'),
    },
  );

  // Write the sample app files, like animated_list.md
  for (SampleInfo sample in samples) {
    writeExpandedTemplate(
      outputFile(sample.sourceName + '.md'),
      inputFile('bin', 'sample_page.md.template').readAsStringSync(),
      sample.commentValues,
    );
  }

  // For each unique class listened in a sample app's "Classes:" list, generate
  // a file that's structurally the same as index.md but only contains samples
  // that feature one class. For example AnimatedList_index.md would only
  // include samples that had AnimatedList in their "Classes:" list.
  final Map<String, List<SampleInfo>> classToSamples = <String, List<SampleInfo>>{};
  for (SampleInfo sample in samples) {
    for (String className in sample.highlightedClasses) {
      classToSamples[className] ??= <SampleInfo>[];
      classToSamples[className].add(sample);
    }
  }
  for (String className in classToSamples.keys) {
    final Iterable<String> entries = classToSamples[className].map((SampleInfo sample) {
      return expandTemplate(entryTemplate, sample.commentValues);
    });
    writeExpandedTemplate(
      outputFile('${className}_index.md'),
      inputFile('bin', 'class_index.md.template').readAsStringSync(),
      <String, String>{
        'class': '$className',
        'entries': entries.join('\n'),
        'link': '${className}_index',
      },
    );
  }

  // Write screenshot.dart, a "test" app that displays each sample
  // app in turn when the app is tapped.
214 215
  writeExpandedTemplate(
    outputFile('screenshot.dart', driverDirectory),
216
    inputFile('bin', 'screenshot.dart.template').readAsStringSync(),
217
    <String, String>{
218
      'imports': samples.map((SampleInfo page) {
219 220
        return "import '${page.importPath}' show ${page.sampleClass};\n";
      }).toList().join(),
221
      'widgets': samples.map((SampleInfo sample) {
222 223 224 225 226
        return 'new ${sample.sampleClass}(),\n';
      }).toList().join(),
    },
  );

227 228
  // Write screenshot_test.dart, a test driver for screenshot.dart
  // that collects screenshots of each app and saves them.
229 230
  writeExpandedTemplate(
    outputFile('screenshot_test.dart', driverDirectory),
231
    inputFile('bin', 'screenshot_test.dart.template').readAsStringSync(),
232
    <String, String>{
233
      'paths': samples.map((SampleInfo sample) {
234
        return "'${outputFile(sample.sourceName + '.png').path}'";
235 236 237 238
      }).toList().join(',\n'),
    },
  );

239 240 241 242
  // For now, the website's index.json file must be updated by hand.
  logMessage('The following entries must appear in _data/catalog/widgets.json');
  for (String className in classToSamples.keys)
    logMessage('"sample": "${className}_index"');
243 244 245
}

void main(List<String> args) {
246 247 248 249 250 251 252
  if (args.length != 1) {
    logError(
      'Usage (cd examples/catalog/; dart bin/sample_page.dart commit)\n'
      'The flutter commit hash locates screenshots on storage.googleapis.com/flutter-catalog/'
    );
    exit(255);
  }
253
  try {
254
    generate(args[0]);
255 256 257 258 259 260 261 262 263 264
  } catch (error) {
    logError(
      'Error: sample_page.dart failed: $error\n'
      'This sample_page.dart app expects to be run from the examples/catalog directory. '
      'More information can be found in examples/catalog/README.md.'
    );
    exit(255);
  }
  exit(0);
}