• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1// Copyright 2017 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5// This application generates markdown pages and screenshots for each
6// sample app. For more information see ../README.md.
7
8import 'dart:io';
9
10import 'package:path/path.dart';
11
12class SampleError extends Error {
13  SampleError(this.message);
14  final String message;
15  @override
16  String toString() => 'SampleError($message)';
17}
18
19// Sample apps are .dart files in the lib directory which contain a block
20// 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// beginning of lines within the comment. A keyword's value is all of
23// the following text up to the next keyword or the end of the comment,
24// sans leading and trailing whitespace.
25const String sampleCatalogKeywords = r'^Title:|^Summary:|^Description:|^Classes:|^Sample:|^See also:';
26
27Directory outputDirectory;
28Directory sampleDirectory;
29Directory testDirectory;
30Directory driverDirectory;
31
32void logMessage(String s) { print(s); }
33void logError(String s) { print(s); }
34
35File inputFile(String dir, String name) {
36  return File(dir + Platform.pathSeparator + name);
37}
38
39File outputFile(String name, [Directory directory]) {
40  return File((directory ?? outputDirectory).path + Platform.pathSeparator + name);
41}
42
43void initialize() {
44  outputDirectory = Directory('.generated');
45  sampleDirectory = Directory('lib');
46  testDirectory = Directory('test');
47  driverDirectory = Directory('test_driver');
48  outputDirectory.createSync();
49}
50
51// Return a copy of template with each occurrence of @(foo) replaced
52// by values[foo].
53String expandTemplate(String template, Map<String, String> values) {
54  // Matches @(foo), match[1] == 'foo'
55  final RegExp tokenRE = RegExp(r'@\(([\w ]+)\)', multiLine: true);
56  return template.replaceAllMapped(tokenRE, (Match match) {
57    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
64void writeExpandedTemplate(File output, String template, Map<String, String> values) {
65  output.writeAsStringSync(expandTemplate(template, values));
66  logMessage('wrote $output');
67}
68
69class SampleInfo {
70  SampleInfo(this.sourceFile, this.commit);
71
72  final File sourceFile;
73  final String commit;
74  String sourceCode;
75  Map<String, String> commentValues;
76
77  // If sourceFile is lib/foo.dart then sourceName is foo. The sourceName
78  // is used to create derived filenames like foo.md or foo.png.
79  String get sourceName => basenameWithoutExtension(sourceFile.path);
80
81  // The website's link to this page will be /catalog/samples/@(link)/.
82  String get link => sourceName.replaceAll('_', '-');
83
84  // The name of the widget class that defines this sample app, like 'FooSample'.
85  String get sampleClass => commentValues['sample'];
86
87  // The value of the 'Classes:' comment as a list of class names.
88  Iterable<String> get highlightedClasses {
89    final String classNames = commentValues['classes'];
90    if (classNames == null)
91      return const <String>[];
92    return classNames.split(',').map<String>((String s) => s.trim()).where((String s) => s.isNotEmpty);
93  }
94
95  // The relative import path for this sample, like '../lib/foo.dart'.
96  String get importPath => '..' + Platform.pathSeparator + sourceFile.path;
97
98  // Return true if we're able to find the "Sample Catalog" comment in the
99  // sourceFile, and we're able to load its keyword/value pairs into
100  // the commentValues Map. The rest of the file's contents are saved
101  // in sourceCode.
102  bool initialize() {
103    final String contents = sourceFile.readAsStringSync();
104
105    final RegExp startRE = RegExp(r'^/\*\s+^Sample\s+Catalog', multiLine: true);
106    final RegExp endRE = RegExp(r'^\*/', multiLine: true);
107    final Match startMatch = startRE.firstMatch(contents);
108    if (startMatch == null)
109      return false;
110
111    final int startIndex = startMatch.end;
112    final Match endMatch = endRE.firstMatch(contents.substring(startIndex));
113    if (endMatch == null)
114      return false;
115
116    final String comment = contents.substring(startIndex, startIndex + endMatch.start);
117    sourceCode = contents.substring(0, startMatch.start) + contents.substring(startIndex + endMatch.end);
118    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    final List<Match> keywordMatches = keywordsRE.allMatches(comment).toList();
123    if (keywordMatches.isEmpty)
124      throw SampleError('did not find any keywords in the Sample Catalog comment in $sourceFile');
125
126    commentValues = <String, String>{};
127    for (int i = 0; i < keywordMatches.length; i += 1) {
128      final String keyword = comment.substring(keywordMatches[i].start, keywordMatches[i].end - 1);
129      final String value = comment.substring(
130        keywordMatches[i].end,
131        i == keywordMatches.length - 1 ? null : keywordMatches[i + 1].start,
132      );
133      commentValues[keyword.toLowerCase()] = value.trim();
134    }
135    commentValues['name'] = sourceName;
136    commentValues['path'] = 'examples/catalog/${sourceFile.path}';
137    commentValues['source'] = sourceCode.trim();
138    commentValues['link'] = link;
139    commentValues['android screenshot'] = 'https://storage.googleapis.com/flutter-catalog/$commit/${sourceName}_small.png';
140
141    return true;
142  }
143}
144
145void generate(String commit) {
146  initialize();
147
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        samples.add(sample);
154    }
155  }
156
157  // Causes the generated imports to appear in alphabetical order.
158  // Avoid complaints from flutter lint.
159  samples.sort((SampleInfo a, SampleInfo b) {
160    return a.sourceName.compareTo(b.sourceName);
161  });
162
163  final String entryTemplate = inputFile('bin', 'entry.md.template').readAsStringSync();
164
165  // Write the sample catalog's home page: index.md
166  final Iterable<String> entries = samples.map<String>((SampleInfo sample) {
167    return expandTemplate(entryTemplate, sample.commentValues);
168  });
169  writeExpandedTemplate(
170    outputFile('index.md'),
171    inputFile('bin', 'index.md.template').readAsStringSync(),
172    <String, String>{
173      'entries': entries.join('\n'),
174    },
175  );
176
177  // Write the sample app files, like animated_list.md
178  for (SampleInfo sample in samples) {
179    writeExpandedTemplate(
180      outputFile(sample.sourceName + '.md'),
181      inputFile('bin', 'sample_page.md.template').readAsStringSync(),
182      sample.commentValues,
183    );
184  }
185
186  // For each unique class listened in a sample app's "Classes:" list, generate
187  // a file that's structurally the same as index.md but only contains samples
188  // that feature one class. For example AnimatedList_index.md would only
189  // include samples that had AnimatedList in their "Classes:" list.
190  final Map<String, List<SampleInfo>> classToSamples = <String, List<SampleInfo>>{};
191  for (SampleInfo sample in samples) {
192    for (String className in sample.highlightedClasses) {
193      classToSamples[className] ??= <SampleInfo>[];
194      classToSamples[className].add(sample);
195    }
196  }
197  for (String className in classToSamples.keys) {
198    final Iterable<String> entries = classToSamples[className].map<String>((SampleInfo sample) {
199      return expandTemplate(entryTemplate, sample.commentValues);
200    });
201    writeExpandedTemplate(
202      outputFile('${className}_index.md'),
203      inputFile('bin', 'class_index.md.template').readAsStringSync(),
204      <String, String>{
205        'class': '$className',
206        'entries': entries.join('\n'),
207        'link': '${className}_index',
208      },
209    );
210  }
211
212  // Write screenshot.dart, a "test" app that displays each sample
213  // app in turn when the app is tapped.
214  writeExpandedTemplate(
215    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  // Write screenshot_test.dart, a test driver for screenshot.dart
228  // that collects screenshots of each app and saves them.
229  writeExpandedTemplate(
230    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  // For now, the website's index.json file must be updated by hand.
240  logMessage('The following entries must appear in _data/catalog/widgets.json');
241  for (String className in classToSamples.keys)
242    logMessage('"sample": "${className}_index"');
243}
244
245void main(List<String> args) {
246  if (args.length != 1) {
247    logError(
248      'Usage (cd examples/catalog/; dart bin/sample_page.dart commit)\n'
249      'The flutter commit hash locates screenshots on storage.googleapis.com/flutter-catalog/'
250    );
251    exit(255);
252  }
253  try {
254    generate(args[0]);
255  } catch (error) {
256    logError(
257      'Error: sample_page.dart failed: $error\n'
258      'This sample_page.dart app expects to be run from the examples/catalog directory. '
259      'More information can be found in examples/catalog/README.md.'
260    );
261    exit(255);
262  }
263  exit(0);
264}
265