sample_page.dart 9.63 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
// 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) {
36
  return File(dir + Platform.pathSeparator + name);
37 38 39
}

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

void initialize() {
44 45 46 47
  outputDirectory = Directory('.generated');
  sampleDirectory = Directory('lib');
  testDirectory = Directory('test');
  driverDirectory = Directory('test_driver');
48
  outputDirectory.createSync();
49 50 51 52 53 54
}

// 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'
55
  final RegExp tokenRE = RegExp(r'@\(([\w ]+)\)', multiLine: true);
56 57
  return template.replaceAllMapped(tokenRE, (Match match) {
    if (match.groupCount != 1)
58
      throw SampleError('bad template keyword $match[0]');
59
    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
  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>[];
92
    return classNames.split(',').map<String>((String s) => s.trim()).where((String s) => s.isNotEmpty);
93
  }
94 95 96 97 98 99 100 101 102 103 104

  // 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();

105 106
    final RegExp startRE = RegExp(r'^/\*\s+^Sample\s+Catalog', multiLine: true);
    final RegExp endRE = RegExp(r'^\*/', multiLine: true);
107 108 109 110 111 112 113 114 115 116 117 118
    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)
119
      throw SampleError('did not find any source code in $sourceFile');
120

121
    final RegExp keywordsRE = RegExp(sampleCatalogKeywords, multiLine: true);
122 123
    final List<Match> keywordMatches = keywordsRE.allMatches(comment).toList();
    if (keywordMatches.isEmpty)
124
      throw SampleError('did not find any keywords in the Sample Catalog comment in $sourceFile');
125 126 127 128 129 130 131 132 133 134

    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
      final SampleInfo sample = SampleInfo(entity, commit);
152
      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
  final String entryTemplate = inputFile('bin', 'entry.md.template').readAsStringSync();

  // Write the sample catalog's home page: index.md
166
  final Iterable<String> entries = samples.map<String>((SampleInfo sample) {
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
    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) {
198
    final Iterable<String> entries = classToSamples[className].map<String>((SampleInfo sample) {
199 200 201 202 203 204 205 206 207 208 209 210 211 212 213
      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<String>((SampleInfo page) {
219
        return "import '${page.importPath}' show ${page.sampleClass};\n";
220
      }).join(),
221
      'widgets': samples.map<String>((SampleInfo sample) {
222
        return 'new ${sample.sampleClass}(),\n';
223
      }).join(),
224 225 226
    },
  );

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<String>((SampleInfo sample) {
234
        return "'${outputFile(sample.sourceName + '.png').path}'";
235
      }).join(',\n'),
236 237 238
    },
  );

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