// 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. import 'dart:convert'; import 'package:file/file.dart'; import 'package:file/local.dart'; import 'package:path/path.dart' as path; import 'configuration.dart'; import 'data_types.dart'; import 'util.dart'; /// Generates the snippet HTML, as well as saving the output snippet main to /// the output directory. class SnippetGenerator { SnippetGenerator({ SnippetConfiguration? configuration, FileSystem filesystem = const LocalFileSystem(), Directory? flutterRoot, }) : flutterRoot = flutterRoot ?? FlutterInformation.instance.getFlutterRoot(), configuration = configuration ?? FlutterRepoSnippetConfiguration( filesystem: filesystem, flutterRoot: flutterRoot ?? FlutterInformation.instance.getFlutterRoot(), ); final Directory flutterRoot; /// The configuration used to determine where to get/save data for the /// snippet. final SnippetConfiguration configuration; static const JsonEncoder jsonEncoder = JsonEncoder.withIndent(' '); /// Interpolates the [injections] into an HTML skeleton file. /// /// The order of the injections is important. /// /// Takes into account the [type] and doesn't substitute in the id and the app /// if not a [SnippetType.sample] snippet. String interpolateSkeleton(CodeSample sample, String skeleton) { final codeParts = []; const htmlEscape = HtmlEscape(); String? language; for (final SkeletonInjection injection in sample.parts) { if (!injection.name.startsWith('code')) { continue; } codeParts.addAll(injection.stringContents); if (injection.language.isNotEmpty) { language = injection.language; } codeParts.addAll(['', '// ...', '']); } if (codeParts.length > 3) { codeParts.removeRange(codeParts.length - 3, codeParts.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. final description = sample.description.trim().isNotEmpty ? '
{@end-inject-html}${sample.description.trim()}{@inject-html}
' : ''; // DartPad only supports stable or main as valid channels. Use main // if not on stable so that local runs will work (although they will // still take their sample code from the master docs server). final channel = sample.metadata['channel'] == 'stable' ? 'stable' : 'main'; final substitutions = { 'description': description, 'code': htmlEscape.convert(codeParts.join('\n')), 'language': language ?? 'dart', 'serial': '', 'id': sample.metadata['id']! as String, 'channel': channel, 'element': sample.metadata['element'] as String? ?? sample.element, 'app': '', }; if (sample is ApplicationSample) { substitutions ..['serial'] = sample.metadata['serial']?.toString() ?? '0' ..['app'] = htmlEscape.convert(sample.output); } 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 a set of skeleton injections in the order found. List parseInput(CodeSample sample) { var inCodeBlock = false; final description = []; final components = []; String? language; final codeStartEnd = RegExp(r'^\s*```(?[-\w]+|[-\w]+ (?
[-\w]+))?\s*$'); for (final SourceLine line in sample.input) { final RegExpMatch? match = codeStartEnd.firstMatch(line.text); if (match != null) { // If we saw the start or end of a code block inCodeBlock = !inCodeBlock; if (match.namedGroup('language') != null) { language = match[1]; if (match.namedGroup('section') != null) { components.add( SkeletonInjection( 'code-${match.namedGroup('section')}', [], language: language!, ), ); } else { components.add(SkeletonInjection('code', [], language: language!)); } } else { language = null; } continue; } if (!inCodeBlock) { description.add(line); } else { assert(language != null); components.last.contents.add(line); } } final descriptionLines = []; var lastWasWhitespace = false; for (final String line in description.map((SourceLine line) => line.text.trimRight())) { final bool onlyWhitespace = line.trim().isEmpty; if (onlyWhitespace && descriptionLines.isEmpty) { // Don't add whitespace lines until we see something without whitespace. lastWasWhitespace = onlyWhitespace; continue; } if (onlyWhitespace && lastWasWhitespace) { // Don't add more than one whitespace line in a row. continue; } descriptionLines.add(line); lastWasWhitespace = onlyWhitespace; } sample.description = descriptionLines.join('\n').trimRight(); sample.parts = [ if (sample is SnippetSample) SkeletonInjection('#assumptions', sample.assumptions), ...components, ]; return sample.parts; } String _loadFileAsUtf8(File file) { return file.readAsStringSync(); } /// Generate the HTML using the skeleton file for the type of the given sample. /// /// Returns a string with the HTML needed to embed in a web page for showing a /// sample on the web page. String generateHtml(CodeSample sample) { final String skeleton = _loadFileAsUtf8(configuration.getHtmlSkeletonFile(sample.type)); return interpolateSkeleton(sample, skeleton); } // Sets the description string on the sample and in the sample metadata to a // comment version of the description. // Trims lines of extra whitespace, and strips leading and trailing blank // lines. String _getDescription(CodeSample sample) { return sample.description.splitMapJoin( '\n', onMatch: (Match match) => match.group(0)!, onNonMatch: (String nonmatch) => nonmatch.trimRight().isEmpty ? '//' : '// ${nonmatch.trimRight()}', ); } /// The main routine for generating code samples from the source code doc comments. /// /// The `sample` is the block of sample code from a dartdoc comment. /// /// The optional `output` is the file to write the generated sample code to. /// /// If `includeAssumptions` is true, then the block in the "Examples can /// assume:" block will also be included in the output. /// /// Returns a string containing the resulting code sample. String generateCode( CodeSample sample, { File? output, String? copyright, String? description, bool includeAssumptions = false, }) { sample.metadata['copyright'] ??= copyright; final List snippetData = parseInput(sample); sample.description = description ?? sample.description; sample.metadata['description'] = _getDescription(sample); switch (sample) { case DartpadSample _: case ApplicationSample _: final String app = sample.sourceFileContents; sample.output = app; if (output != null) { output.writeAsStringSync(sample.output); final File metadataFile = configuration.filesystem.file( path.join( path.dirname(output.path), '${path.basenameWithoutExtension(output.path)}.json', ), ); sample.metadata['file'] = path.basename(output.path); final Map metadata = sample.metadata; if (metadata.containsKey('description')) { metadata['description'] = (metadata['description']! as String).replaceAll( RegExp(r'^// ?', multiLine: true), '', ); } metadataFile.writeAsStringSync(jsonEncoder.convert(metadata)); } case SnippetSample _: String app; if (sample.sourceFile == null) { String templateContents; if (includeAssumptions) { templateContents = '${headers.map((SourceLine line) { return line.text; }).join('\n')}\n{{#assumptions}}\n{{description}}\n{{code}}'; } else { templateContents = '{{description}}\n{{code}}'; } app = interpolateTemplate( snippetData, templateContents, sample.metadata, addCopyright: copyright != null, ); } else { app = sample.inputAsString; } sample.output = app; } return sample.output; } /// Computes the headers needed for each snippet file. /// /// Not used for "sample" and "dartpad" samples, which use their own template. List get headers { return _headers ??= [ '// generated code', '// ignore_for_file: unused_import', '// ignore_for_file: unused_element', '// ignore_for_file: unused_local_variable', "import 'dart:async';", "import 'dart:convert';", "import 'dart:math' as math;", "import 'dart:typed_data';", "import 'dart:ui' as ui;", "import 'package:flutter_test/flutter_test.dart';", for (final File file in _listDartFiles( FlutterInformation.instance .getFlutterRoot() .childDirectory('packages') .childDirectory('flutter') .childDirectory('lib'), )) ...[ '', '// ${file.path}', "import 'package:flutter/${path.basename(file.path)}';", ], ].map((String code) => SourceLine(code)).toList(); } List? _headers; static List _listDartFiles(Directory directory, {bool recursive = false}) { return directory .listSync(recursive: recursive, followLinks: false) .whereType() .where((File file) => path.extension(file.path) == '.dart') .toList(); } }