1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
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
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
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
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
// Copyright 2018 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.
import 'dart:convert';
import 'dart:io';
import 'package:dart_style/dart_style.dart';
import 'package:meta/meta.dart';
import 'package:path/path.dart' as path;
import 'configuration.dart';
void errorExit(String message) {
stderr.writeln(message);
exit(1);
}
// A Tuple containing the name and contents associated with a code block in a
// snippet.
class _ComponentTuple {
_ComponentTuple(this.name, this.contents, {String language}) : language = language ?? '';
final String name;
final List<String> contents;
final String language;
String get mergedContent => contents.join('\n').trim();
}
/// Generates the snippet HTML, as well as saving the output snippet main to
/// the output directory.
class SnippetGenerator {
SnippetGenerator({Configuration configuration})
: configuration = configuration ??
// Flutter's root is four directories up from this script.
Configuration(flutterRoot: Directory(Platform.environment['FLUTTER_ROOT']
?? path.canonicalize(path.join(path.dirname(path.fromUri(Platform.script)), '..', '..', '..')))) {
this.configuration.createOutputDirectory();
}
/// The configuration used to determine where to get/save data for the
/// snippet.
final Configuration configuration;
static const JsonEncoder jsonEncoder = JsonEncoder.withIndent(' ');
/// A Dart formatted used to format the snippet code and finished application
/// code.
static DartFormatter formatter = DartFormatter(pageWidth: 80, fixes: StyleFix.all);
/// This returns the output file for a given snippet ID. Only used for
/// [SnippetType.application] snippets.
File getOutputFile(String id) => File(path.join(configuration.outputDirectory.path, '$id.dart'));
/// Gets the path to the template file requested.
File getTemplatePath(String templateName, {Directory templatesDir}) {
final Directory templateDir = templatesDir ?? configuration.templatesDirectory;
final File templateFile = File(path.join(templateDir.path, '$templateName.tmpl'));
return templateFile.existsSync() ? templateFile : null;
}
/// Injects the [injections] into the [template], and turning the
/// "description" injection into a comment. Only used for
/// [SnippetType.application] snippets.
String interpolateTemplate(List<_ComponentTuple> injections, String template) {
final RegExp moustacheRegExp = RegExp('{{([^}]+)}}');
return template.replaceAllMapped(moustacheRegExp, (Match match) {
if (match[1] == 'description') {
// Place the description into a comment.
final List<String> description = injections
.firstWhere((_ComponentTuple tuple) => tuple.name == match[1])
.contents
.map<String>((String line) => '// $line')
.toList();
// Remove any leading/trailing empty comment lines.
// We don't want to remove ALL empty comment lines, only the ones at the
// beginning and the end.
while (description.isNotEmpty && description.last == '// ') {
description.removeLast();
}
while (description.isNotEmpty && description.first == '// ') {
description.removeAt(0);
}
return description.join('\n').trim();
} else {
// If the match isn't found in the injections, then just remove the
// mustache reference, since we want to allow the sections to be
// "optional" in the input: users shouldn't be forced to add an empty
// "```dart preamble" section if that section would be empty.
return injections
.firstWhere((_ComponentTuple tuple) => tuple.name == match[1], orElse: () => null)
?.mergedContent ?? '';
}
}).trim();
}
/// Interpolates the [injections] into an HTML skeleton file.
///
/// Similar to interpolateTemplate, but we are only looking for `code-`
/// components, and we care about the order of the injections.
///
/// Takes into account the [type] and doesn't substitute in the id and the app
/// if not a [SnippetType.application] snippet.
String interpolateSkeleton(SnippetType type, List<_ComponentTuple> injections, String skeleton, Map<String, Object> metadata) {
final List<String> result = <String>[];
const HtmlEscape htmlEscape = HtmlEscape();
String language;
for (_ComponentTuple injection in injections) {
if (!injection.name.startsWith('code')) {
continue;
}
result.addAll(injection.contents);
if (injection.language.isNotEmpty) {
language = injection.language;
}
result.addAll(<String>['', '// ...', '']);
}
if (result.length > 3) {
result.removeRange(result.length - 3, result.length);
}
// Only insert a div for the description if there actually is some text there.
// This means that the {{description}} marker in the skeleton needs to
// be inside of an {@inject-html} block.
String description = injections.firstWhere((_ComponentTuple tuple) => tuple.name == 'description').mergedContent;
description = description.trim().isNotEmpty
? '<div class="snippet-description">{@end-inject-html}$description{@inject-html}</div>'
: '';
final Map<String, String> substitutions = <String, String>{
'description': description,
'code': htmlEscape.convert(result.join('\n')),
'language': language ?? 'dart',
'serial': '',
'id': metadata['id'],
'app': '',
};
if (type == SnippetType.application) {
substitutions
..['serial'] = metadata['serial']?.toString() ?? '0'
..['app'] = htmlEscape.convert(injections.firstWhere((_ComponentTuple tuple) => tuple.name == 'app').mergedContent);
}
return skeleton.replaceAllMapped(RegExp('{{(${substitutions.keys.join('|')})}}'), (Match match) {
return substitutions[match[1]];
});
}
/// Parses the input for the various code and description segments, and
/// returns them in the order found.
List<_ComponentTuple> parseInput(String input) {
bool inCodeBlock = false;
input = input.trim();
final List<String> description = <String>[];
final List<_ComponentTuple> components = <_ComponentTuple>[];
String language;
final RegExp codeStartEnd = RegExp(r'^\s*```([-\w]+|[-\w]+ ([-\w]+))?\s*$');
for (String line in input.split('\n')) {
final Match match = codeStartEnd.firstMatch(line);
if (match != null) { // If we saw the start or end of a code block
inCodeBlock = !inCodeBlock;
if (match[1] != null) {
language = match[1];
if (match[2] != null) {
components.add(_ComponentTuple('code-${match[2]}', <String>[], language: language));
} else {
components.add(_ComponentTuple('code', <String>[], language: language));
}
} else {
language = null;
}
continue;
}
if (!inCodeBlock) {
description.add(line);
} else {
assert(language != null);
components.last.contents.add(line);
}
}
return <_ComponentTuple>[
_ComponentTuple('description', description),
...components,
];
}
String _loadFileAsUtf8(File file) {
return file.readAsStringSync(encoding: Encoding.getByName('utf-8'));
}
String _addLineNumbers(String app) {
final StringBuffer buffer = StringBuffer();
int count = 0;
for (String line in app.split('\n')) {
count++;
buffer.writeln('${count.toString().padLeft(5, ' ')}: $line');
}
return buffer.toString();
}
/// The main routine for generating snippets.
///
/// The [input] is the file containing the dartdoc comments (minus the leading
/// comment markers).
///
/// The [type] is the type of snippet to create: either a
/// [SnippetType.application] or a [SnippetType.sample].
///
/// [showDartPad] indicates whether DartPad should be shown where possible.
/// Currently, this value only has an effect if [type] is
/// [SnippetType.application], in which case an alternate skeleton file is
/// used to create the final HTML output.
///
/// The [template] must not be null if the [type] is
/// [SnippetType.application], and specifies the name of the template to use
/// for the application code.
///
/// The [id] is a string ID to use for the output file, and to tell the user
/// about in the `flutter create` hint. It must not be null if the [type] is
/// [SnippetType.application].
String generate(
File input,
SnippetType type, {
bool showDartPad = false,
String template,
File output,
@required Map<String, Object> metadata,
}) {
assert(template != null || type != SnippetType.application);
assert(metadata != null && metadata['id'] != null);
assert(input != null);
assert(!showDartPad || type == SnippetType.application,
'Only application snippets work with dartpad.');
final List<_ComponentTuple> snippetData = parseInput(_loadFileAsUtf8(input));
switch (type) {
case SnippetType.application:
final Directory templatesDir = configuration.templatesDirectory;
if (templatesDir == null) {
stderr.writeln('Unable to find the templates directory.');
exit(1);
}
final File templateFile = getTemplatePath(template, templatesDir: templatesDir);
if (templateFile == null) {
stderr.writeln(
'The template $template was not found in the templates directory ${templatesDir.path}');
exit(1);
}
final String templateContents = _loadFileAsUtf8(templateFile);
String app = interpolateTemplate(snippetData, templateContents);
try {
app = formatter.format(app);
} on FormatterException catch (exception) {
stderr.write('Code to format:\n${_addLineNumbers(app)}\n');
errorExit('Unable to format snippet app template: $exception');
}
snippetData.add(_ComponentTuple('app', app.split('\n')));
final File outputFile = output ?? getOutputFile(metadata['id']);
stderr.writeln('Writing to ${outputFile.absolute.path}');
outputFile.writeAsStringSync(app);
final File metadataFile = File(path.join(path.dirname(outputFile.path),
'${path.basenameWithoutExtension(outputFile.path)}.json'));
stderr.writeln('Writing metadata to ${metadataFile.absolute.path}');
final _ComponentTuple description = snippetData.firstWhere(
(_ComponentTuple data) => data.name == 'description',
orElse: () => null,
);
metadata ??= <String, Object>{};
metadata.addAll(<String, Object>{
'id': metadata['id'],
'file': path.basename(outputFile.path),
'description': description?.mergedContent,
});
metadataFile.writeAsStringSync(jsonEncoder.convert(metadata));
break;
case SnippetType.sample:
break;
}
final String skeleton =
_loadFileAsUtf8(configuration.getHtmlSkeletonFile(type, showDartPad: showDartPad));
return interpolateSkeleton(type, snippetData, skeleton, metadata);
}
}