snippets.dart 11.5 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4 5 6 7 8
// 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';
9 10
import 'package:meta/meta.dart';
import 'package:path/path.dart' as path;
11 12 13 14 15 16 17 18 19 20 21

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 {
22
  _ComponentTuple(this.name, this.contents, {String language}) : language = language ?? '';
23 24
  final String name;
  final List<String> contents;
25
  final String language;
26 27 28 29 30 31 32
  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})
33
      : configuration = configuration ??
34
            // Flutter's root is four directories up from this script.
35 36
            Configuration(flutterRoot: Directory(Platform.environment['FLUTTER_ROOT']
                ?? path.canonicalize(path.join(path.dirname(path.fromUri(Platform.script)), '..', '..', '..')))) {
37 38 39 40 41 42 43
    this.configuration.createOutputDirectory();
  }

  /// The configuration used to determine where to get/save data for the
  /// snippet.
  final Configuration configuration;

44 45
  static const JsonEncoder jsonEncoder = JsonEncoder.withIndent('    ');

46 47 48 49 50
  /// 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
51
  /// [SnippetType.sample] snippets.
52 53 54 55 56 57 58 59 60 61 62
  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
63
  /// [SnippetType.sample] snippets.
64
  String interpolateTemplate(List<_ComponentTuple> injections, String template, Map<String, Object> metadata) {
65
    final RegExp moustacheRegExp = RegExp('{{([^}]+)}}');
66 67 68 69 70 71 72 73 74 75 76
    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.
77
        while (description.isNotEmpty && description.last == '// ') {
78 79
          description.removeLast();
        }
80
        while (description.isNotEmpty && description.first == '// ') {
81 82 83 84
          description.removeAt(0);
        }
        return description.join('\n').trim();
      } else {
85
        // If the match isn't found in the injections, then just remove the
86
        // mustache reference, since we want to allow the sections to be
87 88
        // "optional" in the input: users shouldn't be forced to add an empty
        // "```dart preamble" section if that section would be empty.
89 90 91
        final _ComponentTuple result = injections
            .firstWhere((_ComponentTuple tuple) => tuple.name == match[1], orElse: () => null);
        return result?.mergedContent ?? (metadata[match[1]] ?? '').toString();
92 93 94 95 96 97 98 99 100 101
      }
    }).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
102
  /// if not a [SnippetType.sample] snippet.
103
  String interpolateSkeleton(SnippetType type, List<_ComponentTuple> injections, String skeleton, Map<String, Object> metadata) {
104
    final List<String> result = <String>[];
105 106
    const HtmlEscape htmlEscape = HtmlEscape();
    String language;
107
    for (final _ComponentTuple injection in injections) {
108 109 110 111
      if (!injection.name.startsWith('code')) {
        continue;
      }
      result.addAll(injection.contents);
112 113 114
      if (injection.language.isNotEmpty) {
        language = injection.language;
      }
115 116 117 118 119
      result.addAll(<String>['', '// ...', '']);
    }
    if (result.length > 3) {
      result.removeRange(result.length - 3, result.length);
    }
120 121 122 123 124 125 126
    // 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>'
        : '';
127 128 129 130 131 132

    // DartPad only supports stable or master as valid channels. Use master
    // if not on stable so that local runs will work (although they will
    // still take their sample code from the master docs server).
    final String channel = metadata['channel'] == 'stable' ? 'stable' : 'master';

133
    final Map<String, String> substitutions = <String, String>{
134
      'description': description,
135 136
      'code': htmlEscape.convert(result.join('\n')),
      'language': language ?? 'dart',
137
      'serial': '',
138
      'id': metadata['id'] as String,
139
      'channel': channel,
140
      'element': metadata['element'] as String ?? '',
141 142
      'app': '',
    };
143
    if (type == SnippetType.sample) {
144
      substitutions
145
        ..['serial'] = metadata['serial']?.toString() ?? '0'
146 147
        ..['app'] = htmlEscape.convert(injections.firstWhere((_ComponentTuple tuple) => tuple.name == 'app').mergedContent);
    }
148
    return skeleton.replaceAllMapped(RegExp('{{(${substitutions.keys.join('|')})}}'), (Match match) {
149 150 151 152 153 154 155
      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) {
156
    bool inCodeBlock = false;
157 158 159
    input = input.trim();
    final List<String> description = <String>[];
    final List<_ComponentTuple> components = <_ComponentTuple>[];
160 161
    String language;
    final RegExp codeStartEnd = RegExp(r'^\s*```([-\w]+|[-\w]+ ([-\w]+))?\s*$');
162
    for (final String line in input.split('\n')) {
163 164 165
      final Match match = codeStartEnd.firstMatch(line);
      if (match != null) { // If we saw the start or end of a code block
        inCodeBlock = !inCodeBlock;
166
        if (match[1] != null) {
167
          language = match[1];
168
          if (match[2] != null) {
169
            components.add(_ComponentTuple('code-${match[2]}', <String>[], language: language));
170
          } else {
171
            components.add(_ComponentTuple('code', <String>[], language: language));
172 173
          }
        } else {
174
          language = null;
175 176 177
        }
        continue;
      }
178
      if (!inCodeBlock) {
179 180
        description.add(line);
      } else {
181
        assert(language != null);
182 183 184 185 186
        components.last.contents.add(line);
      }
    }
    return <_ComponentTuple>[
      _ComponentTuple('description', description),
187 188
      ...components,
    ];
189 190 191 192 193 194
  }

  String _loadFileAsUtf8(File file) {
    return file.readAsStringSync(encoding: Encoding.getByName('utf-8'));
  }

195 196 197
  String _addLineNumbers(String app) {
    final StringBuffer buffer = StringBuffer();
    int count = 0;
198
    for (final String line in app.split('\n')) {
199 200 201 202 203 204
      count++;
      buffer.writeln('${count.toString().padLeft(5, ' ')}: $line');
    }
    return buffer.toString();
  }

205 206 207 208 209 210
  /// 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
211
  /// [SnippetType.sample] or a [SnippetType.snippet].
212
  ///
213 214
  /// [showDartPad] indicates whether DartPad should be shown where possible.
  /// Currently, this value only has an effect if [type] is
215
  /// [SnippetType.sample], in which case an alternate skeleton file is
216 217
  /// used to create the final HTML output.
  ///
218
  /// The [template] must not be null if the [type] is
219
  /// [SnippetType.sample], and specifies the name of the template to use
220 221 222 223
  /// 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
224
  /// [SnippetType.sample].
225 226 227
  String generate(
    File input,
    SnippetType type, {
228
    bool showDartPad = false,
229 230 231 232
    String template,
    File output,
    @required Map<String, Object> metadata,
  }) {
233
    assert(template != null || type != SnippetType.sample);
234
    assert(metadata != null && metadata['id'] != null);
235
    assert(input != null);
236
    assert(!showDartPad || type == SnippetType.sample, 'Only application samples work with dartpad.');
237 238
    final List<_ComponentTuple> snippetData = parseInput(_loadFileAsUtf8(input));
    switch (type) {
239
      case SnippetType.sample:
240 241 242 243 244 245 246 247 248 249 250 251
        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);
252
        String app = interpolateTemplate(snippetData, templateContents, metadata);
253 254 255 256

        try {
          app = formatter.format(app);
        } on FormatterException catch (exception) {
257
          stderr.write('Code to format:\n${_addLineNumbers(app)}\n');
258 259 260 261
          errorExit('Unable to format snippet app template: $exception');
        }

        snippetData.add(_ComponentTuple('app', app.split('\n')));
262
        final File outputFile = output ?? getOutputFile(metadata['id'] as String);
263 264
        stderr.writeln('Writing to ${outputFile.absolute.path}');
        outputFile.writeAsStringSync(app);
265 266 267 268 269 270 271 272 273 274

        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.addAll(<String, Object>{
          'file': path.basename(outputFile.path),
275
          'description': description?.mergedContent,
276 277
        });
        metadataFile.writeAsStringSync(jsonEncoder.convert(metadata));
278
        break;
279
      case SnippetType.snippet:
280 281
        break;
    }
282 283
    final String skeleton =
        _loadFileAsUtf8(configuration.getHtmlSkeletonFile(type, showDartPad: showDartPad));
284
    return interpolateSkeleton(type, snippetData, skeleton, metadata);
285 286
  }
}