| // 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. |
| |
| // This application generates markdown pages and screenshots for each |
| // sample app. For more information see ../README.md. |
| |
| import 'dart:io'; |
| |
| import 'package:path/path.dart'; |
| |
| class SampleError extends Error { |
| SampleError(this.message); |
| final String message; |
| @override |
| String toString() => 'SampleError($message)'; |
| } |
| |
| // 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 |
| // that just contains '*/'. The following keywords may appear at the |
| // 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 File(dir + Platform.pathSeparator + name); |
| } |
| |
| File outputFile(String name, [Directory directory]) { |
| return File((directory ?? outputDirectory).path + Platform.pathSeparator + name); |
| } |
| |
| void initialize() { |
| outputDirectory = Directory('.generated'); |
| sampleDirectory = Directory('lib'); |
| testDirectory = Directory('test'); |
| driverDirectory = Directory('test_driver'); |
| outputDirectory.createSync(); |
| } |
| |
| // 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 = RegExp(r'@\(([\w ]+)\)', multiLine: true); |
| return template.replaceAllMapped(tokenRE, (Match match) { |
| if (match.groupCount != 1) |
| throw SampleError('bad template keyword $match[0]'); |
| final String keyword = match[1]; |
| return values[keyword] ?? ''; |
| }); |
| } |
| |
| void writeExpandedTemplate(File output, String template, Map<String, String> values) { |
| output.writeAsStringSync(expandTemplate(template, values)); |
| logMessage('wrote $output'); |
| } |
| |
| class SampleInfo { |
| SampleInfo(this.sourceFile, this.commit); |
| |
| final File sourceFile; |
| final String commit; |
| 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. |
| String get sourceName => basenameWithoutExtension(sourceFile.path); |
| |
| // The website's link to this page will be /catalog/samples/@(link)/. |
| String get link => sourceName.replaceAll('_', '-'); |
| |
| // The name of the widget class that defines this sample app, like 'FooSample'. |
| 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>((String s) => s.trim()).where((String s) => s.isNotEmpty); |
| } |
| |
| // 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 = RegExp(r'^/\*\s+^Sample\s+Catalog', multiLine: true); |
| final RegExp endRE = 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 SampleError('did not find any source code in $sourceFile'); |
| |
| final RegExp keywordsRE = RegExp(sampleCatalogKeywords, multiLine: true); |
| final List<Match> keywordMatches = keywordsRE.allMatches(comment).toList(); |
| if (keywordMatches.isEmpty) |
| throw 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(); |
| } |
| commentValues['name'] = sourceName; |
| commentValues['path'] = 'examples/catalog/${sourceFile.path}'; |
| commentValues['source'] = sourceCode.trim(); |
| commentValues['link'] = link; |
| commentValues['android screenshot'] = 'https://storage.googleapis.com/flutter-catalog/$commit/${sourceName}_small.png'; |
| |
| return true; |
| } |
| } |
| |
| void generate(String commit) { |
| initialize(); |
| |
| final List<SampleInfo> samples = <SampleInfo>[]; |
| for (final FileSystemEntity entity in sampleDirectory.listSync()) { |
| if (entity is File && entity.path.endsWith('.dart')) { |
| final SampleInfo sample = SampleInfo(entity, commit); |
| if (sample.initialize()) // skip files that lack the Sample Catalog comment |
| samples.add(sample); |
| } |
| } |
| |
| // Causes the generated imports to appear in alphabetical order. |
| // Avoid complaints from flutter lint. |
| samples.sort((SampleInfo a, SampleInfo b) { |
| return a.sourceName.compareTo(b.sourceName); |
| }); |
| |
| final String entryTemplate = inputFile('bin', 'entry.md.template').readAsStringSync(); |
| |
| // Write the sample catalog's home page: index.md |
| final Iterable<String> entries = samples.map<String>((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 (final 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 (final SampleInfo sample in samples) { |
| for (final String className in sample.highlightedClasses) { |
| classToSamples[className] ??= <SampleInfo>[]; |
| classToSamples[className].add(sample); |
| } |
| } |
| for (final String className in classToSamples.keys) { |
| final Iterable<String> entries = classToSamples[className].map<String>((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. |
| writeExpandedTemplate( |
| outputFile('screenshot.dart', driverDirectory), |
| inputFile('bin', 'screenshot.dart.template').readAsStringSync(), |
| <String, String>{ |
| 'imports': samples.map<String>((SampleInfo page) { |
| return "import '${page.importPath}' show ${page.sampleClass};\n"; |
| }).join(), |
| 'widgets': samples.map<String>((SampleInfo sample) { |
| return 'new ${sample.sampleClass}(),\n'; |
| }).join(), |
| }, |
| ); |
| |
| // Write screenshot_test.dart, a test driver for screenshot.dart |
| // that collects screenshots of each app and saves them. |
| writeExpandedTemplate( |
| outputFile('screenshot_test.dart', driverDirectory), |
| inputFile('bin', 'screenshot_test.dart.template').readAsStringSync(), |
| <String, String>{ |
| 'paths': samples.map<String>((SampleInfo sample) { |
| return "'${outputFile(sample.sourceName + '.png').path}'"; |
| }).join(',\n'), |
| }, |
| ); |
| |
| // 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 (final String className in classToSamples.keys) |
| logMessage('"sample": "${className}_index"'); |
| } |
| |
| void main(List<String> args) { |
| 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); |
| } |
| try { |
| generate(args[0]); |
| } 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); |
| } |