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