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