Adding youtube directive for embedding YouTube videos (#1947)

* Adding youtube directive for embedding YouTube videos

* ++

* ++

* Simplify URL format

* fix test counter

* bump pupspec and Changelog

* typo

* Improve regex + text

* grind build

* remove extra print

* Always use all available horizontal space

* Always use all available horizontal space

* feedback
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b010001..c1eeb36 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,7 @@
+## 0.28.3
+* Support a new `{@youtube}` directive in documentation comments to embed
+  YouTube videos.
+
 ## 0.28.2
 * Add empty CSS classes in spans around the names of entities so Dashing can pick
   them up.  (flutter/flutter#27654, #1929)
diff --git a/dartdoc_options.yaml b/dartdoc_options.yaml
index 0bacf62..797bade 100644
--- a/dartdoc_options.yaml
+++ b/dartdoc_options.yaml
@@ -1,4 +1,4 @@
 dartdoc:
   linkToSource:
     root: '.'
-    uriTemplate: 'https://github.com/dart-lang/dartdoc/blob/v0.28.2/%f%#L%l%'
+    uriTemplate: 'https://github.com/dart-lang/dartdoc/blob/v0.28.3/%f%#L%l%'
diff --git a/lib/src/model.dart b/lib/src/model.dart
index 8e03022..baa36d9 100644
--- a/lib/src/model.dart
+++ b/lib/src/model.dart
@@ -3270,6 +3270,7 @@
       _rawDocs = documentationComment ?? '';
       _rawDocs = stripComments(_rawDocs) ?? '';
       _rawDocs = _injectExamples(_rawDocs);
+      _rawDocs = _injectYouTube(_rawDocs);
       _rawDocs = _injectAnimations(_rawDocs);
       _rawDocs = _stripHtmlAndAddToIndex(_rawDocs);
     }
@@ -3291,6 +3292,7 @@
       // Must evaluate tools first, in case they insert any other directives.
       _rawDocs = await _evaluateTools(_rawDocs);
       _rawDocs = _injectExamples(_rawDocs);
+      _rawDocs = _injectYouTube(_rawDocs);
       _rawDocs = _injectAnimations(_rawDocs);
       _rawDocs = _stripMacroTemplatesAndAddToIndex(_rawDocs);
       _rawDocs = _stripHtmlAndAddToIndex(_rawDocs);
@@ -4054,6 +4056,108 @@
     }
   }
 
+  /// Replace {@youtube ...} in API comments with some HTML to embed
+  /// a YouTube video.
+  ///
+  /// Syntax:
+  ///
+  ///     {@youtube WIDTH HEIGHT URL}
+  ///
+  /// Example:
+  ///
+  ///     {@youtube 560 315 https://www.youtube.com/watch?v=oHg5SJYRHA0}
+  ///
+  /// Which will embed a YouTube player into the page that plays the specified
+  /// video.
+  ///
+  /// The width and height must be positive integers specifying the dimensions
+  /// of the video in pixels. The height and width are used to calculate the
+  /// aspect ratio of the video; the video is always rendered to take up all
+  /// available horizontal space to accommodate different screen sizes on
+  /// desktop and mobile.
+  ///
+  /// The video URL must have the following format:
+  /// https://www.youtube.com/watch?v=oHg5SJYRHA0. This format can usually be
+  /// found in the address bar of the browser when viewing a YouTube video.
+  String _injectYouTube(String rawDocs) {
+    // Matches all youtube directives (even some invalid ones). This is so
+    // we can give good error messages if the directive is malformed, instead of
+    // just silently emitting it as-is.
+    final RegExp basicAnimationRegExp = new RegExp(r'''{@youtube\s+([^}]+)}''');
+
+    // Matches YouTube IDs from supported YouTube URLs.
+    final RegExp validYouTubeUrlRegExp =
+        new RegExp('https://www\.youtube\.com/watch\\?v=([^&]+)\$');
+
+    return rawDocs.replaceAllMapped(basicAnimationRegExp, (basicMatch) {
+      final ArgParser parser = new ArgParser();
+      final ArgResults args = _parseArgs(basicMatch[1], parser, 'youtube');
+      if (args == null) {
+        // Already warned about an invalid parameter if this happens.
+        return '';
+      }
+      final List<String> positionalArgs = args.rest.sublist(0);
+      if (positionalArgs.length != 3) {
+        warn(PackageWarning.invalidParameter,
+            message: 'Invalid @youtube directive, "${basicMatch[0]}"\n'
+                'YouTube directives must be of the form "{@youtube WIDTH '
+                'HEIGHT URL}"');
+        return '';
+      }
+
+      final int width = int.tryParse(positionalArgs[0]);
+      if (width == null || width <= 0) {
+        warn(PackageWarning.invalidParameter,
+            message: 'A @youtube directive has an invalid width, '
+                '"${positionalArgs[0]}". The width must be a positive integer.');
+      }
+
+      final int height = int.tryParse(positionalArgs[1]);
+      if (height == null || height <= 0) {
+        warn(PackageWarning.invalidParameter,
+            message: 'A @youtube directive has an invalid height, '
+                '"${positionalArgs[1]}". The height must be a positive integer.');
+      }
+
+      final Match url = validYouTubeUrlRegExp.firstMatch(positionalArgs[2]);
+      if (url == null) {
+        warn(PackageWarning.invalidParameter,
+            message: 'A @youtube directive has an invalid URL: '
+                '"${positionalArgs[2]}". Supported YouTube URLs have the '
+                'follwing format: https://www.youtube.com/watch?v=oHg5SJYRHA0.');
+        return '';
+      }
+      final String youTubeId = url.group(url.groupCount);
+      final String aspectRatio = (height / width * 100).toStringAsFixed(2);
+
+      // Blank lines before and after, and no indenting at the beginning and end
+      // is needed so that Markdown doesn't confuse this with code, so be
+      // careful of whitespace here.
+      return '''
+
+<p style="position: relative;
+          padding-top: $aspectRatio%;">
+  <iframe src="https://www.youtube.com/embed/$youTubeId?rel=0"
+          frameborder="0"
+          allow="accelerometer;
+                 autoplay;
+                 encrypted-media;
+                 gyroscope;
+                 picture-in-picture"
+          allowfullscreen
+          style="position: absolute;
+                 top: 0;
+                 left: 0;
+                 width: 100%;
+                 height: 100%;">
+  </iframe>
+</p>
+
+'''; // String must end at beginning of line, or following inline text will be
+      // indented.
+    });
+  }
+
   /// Replace &#123;@animation ...&#125; in API comments with some HTML to manage an
   /// MPEG 4 video as an animation.
   ///
diff --git a/lib/src/version.dart b/lib/src/version.dart
index ba71677..156b762 100644
--- a/lib/src/version.dart
+++ b/lib/src/version.dart
@@ -1,2 +1,2 @@
 // Generated code. Do not modify.
-const packageVersion = '0.28.2';
+const packageVersion = '0.28.3';
diff --git a/pubspec.yaml b/pubspec.yaml
index acb8f14..1936e70 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,6 +1,6 @@
 name: dartdoc
 # Run `grind build` after updating.
-version: 0.28.2
+version: 0.28.3
 author: Dart Team <misc@dartlang.org>
 description: A documentation generator for Dart.
 homepage: https://github.com/dart-lang/dartdoc
diff --git a/test/model_test.dart b/test/model_test.dart
index 6e2e8f5..450ffa8 100644
--- a/test/model_test.dart
+++ b/test/model_test.dart
@@ -897,6 +897,123 @@
     });
   });
 
+  group('YouTube Errors', () {
+    Class documentationErrors;
+    Method withYouTubeWrongParams;
+    Method withYouTubeBadWidth;
+    Method withYouTubeBadHeight;
+    Method withYouTubeInvalidUrl;
+    Method withYouTubeUrlWithAdditionalParameters;
+
+    setUpAll(() {
+      documentationErrors = errorLibrary.classes
+          .firstWhere((c) => c.name == 'DocumentationErrors')
+            ..documentation;
+      withYouTubeWrongParams = documentationErrors.allInstanceMethods
+          .firstWhere((m) => m.name == 'withYouTubeWrongParams')
+            ..documentation;
+      withYouTubeBadWidth = documentationErrors.allInstanceMethods
+          .firstWhere((m) => m.name == 'withYouTubeBadWidth')
+            ..documentation;
+      withYouTubeBadHeight = documentationErrors.allInstanceMethods
+          .firstWhere((m) => m.name == 'withYouTubeBadHeight')
+            ..documentation;
+      withYouTubeInvalidUrl = documentationErrors.allInstanceMethods
+          .firstWhere((m) => m.name == 'withYouTubeInvalidUrl')
+            ..documentation;
+      withYouTubeUrlWithAdditionalParameters = documentationErrors.allInstanceMethods
+          .firstWhere((m) => m.name == 'withYouTubeUrlWithAdditionalParameters')
+        ..documentation;
+    });
+
+    test("warns on youtube video with missing parameters", () {
+      expect(
+          packageGraphErrors.packageWarningCounter.hasWarning(
+              withYouTubeWrongParams,
+              PackageWarning.invalidParameter,
+              'Invalid @youtube directive, "{@youtube https://youtu.be/oHg5SJYRHA0}"\n'
+              'YouTube directives must be of the form "{@youtube WIDTH HEIGHT URL}"'),
+          isTrue);
+    });
+    test("warns on youtube video with non-integer width", () {
+      expect(
+          packageGraphErrors.packageWarningCounter.hasWarning(
+              withYouTubeBadWidth,
+              PackageWarning.invalidParameter,
+              'A @youtube directive has an invalid width, "100px". The width '
+              'must be a positive integer.'),
+          isTrue);
+    });
+    test("warns on youtube video with non-integer height", () {
+      expect(
+          packageGraphErrors.packageWarningCounter.hasWarning(
+              withYouTubeBadHeight,
+              PackageWarning.invalidParameter,
+              'A @youtube directive has an invalid height, "100px". The height '
+              'must be a positive integer.'),
+          isTrue);
+    });
+    test("warns on youtube video with invalid video URL", () {
+      expect(
+          packageGraphErrors.packageWarningCounter.hasWarning(
+              withYouTubeInvalidUrl,
+              PackageWarning.invalidParameter,
+              'A @youtube directive has an invalid URL: '
+              '"http://host/path/to/video.mp4". Supported YouTube URLs have '
+              'the follwing format: '
+              'https://www.youtube.com/watch?v=oHg5SJYRHA0.'),
+          isTrue);
+    });
+    test("warns on youtube video with extra parameters in URL", () {
+      expect(
+          packageGraphErrors.packageWarningCounter.hasWarning(
+              withYouTubeUrlWithAdditionalParameters,
+              PackageWarning.invalidParameter,
+              'A @youtube directive has an invalid URL: '
+              '"https://www.youtube.com/watch?v=yI-8QHpGIP4&list=PLjxrf2q8roU23XGwz3Km7sQZFTdB996iG&index=5". '
+              'Supported YouTube URLs have the follwing format: '
+              'https://www.youtube.com/watch?v=oHg5SJYRHA0.'),
+          isTrue);
+    });
+  });
+
+  group('YouTube', () {
+    Class dog;
+    Method withYouTubeWatchUrl;
+    Method withYouTubeInOneLineDoc;
+    Method withYouTubeInline;
+
+    setUpAll(() {
+      dog = exLibrary.classes.firstWhere((c) => c.name == 'Dog');
+      withYouTubeWatchUrl = dog.allInstanceMethods
+          .firstWhere((m) => m.name == 'withYouTubeWatchUrl');
+      withYouTubeInOneLineDoc = dog.allInstanceMethods
+          .firstWhere((m) => m.name == 'withYouTubeInOneLineDoc');
+      withYouTubeInline = dog.allInstanceMethods
+          .firstWhere((m) => m.name == 'withYouTubeInline');
+    });
+
+    test("renders a YouTube video within the method documentation with correct aspect ratio", () {
+      expect(withYouTubeWatchUrl.documentation,
+          contains('<iframe src="https://www.youtube.com/embed/oHg5SJYRHA0?rel=0"'));
+      // Video is 560x315, which means height is 56.25% of width.
+      expect(withYouTubeWatchUrl.documentation, contains('padding-top: 56.25%;'));
+    });
+    test("Doesn't place YouTube video in one line doc", () {
+      expect(
+          withYouTubeInOneLineDoc.oneLineDoc,
+          isNot(contains(
+              '<iframe src="https://www.youtube.com/embed/oHg5SJYRHA0?rel=0"')));
+      expect(withYouTubeInOneLineDoc.documentation,
+          contains('<iframe src="https://www.youtube.com/embed/oHg5SJYRHA0?rel=0"'));
+    });
+    test("Handles YouTube video inline properly", () {
+      // Make sure it doesn't have a double-space before the continued line,
+      // which would indicate to Markdown to indent the line.
+      expect(withYouTubeInline.documentation, isNot(contains('  works')));
+    });
+  });
+
   group('Animation Errors', () {
     Class documentationErrors;
     Method withInvalidNamedAnimation;
@@ -1790,7 +1907,7 @@
     });
 
     test('get methods', () {
-      expect(Dog.publicInstanceMethods, hasLength(19));
+      expect(Dog.publicInstanceMethods, hasLength(22));
     });
 
     test('get operators', () {
@@ -1865,7 +1982,10 @@
             'withNamedAnimation',
             'withPrivateMacro',
             'withQuotedNamedAnimation',
-            'withUndefinedMacro'
+            'withUndefinedMacro',
+            'withYouTubeInline',
+            'withYouTubeInOneLineDoc',
+            'withYouTubeWatchUrl',
           ]));
     });
 
diff --git a/testing/test_package/lib/example.dart b/testing/test_package/lib/example.dart
index 7929db7..5344182 100644
--- a/testing/test_package/lib/example.dart
+++ b/testing/test_package/lib/example.dart
@@ -354,6 +354,23 @@
   /// Don't define this:  {@macro ThatDoesNotExist}
   void withUndefinedMacro() {}
 
+  /// YouTube video method
+  ///
+  /// {@youtube 560 315 https://www.youtube.com/watch?v=oHg5SJYRHA0}
+  /// More docs
+  void withYouTubeWatchUrl() {}
+
+  /// YouTube video in one line doc {@youtube 100 100 https://www.youtube.com/watch?v=oHg5SJYRHA0}
+  ///
+  /// This tests to see that we do the right thing if the animation is in
+  /// the one line doc above.
+  void withYouTubeInOneLineDoc() {}
+
+  /// YouTube video inline in text.
+  ///
+  /// Tests to see that an inline {@youtube 100 100 https://www.youtube.com/watch?v=oHg5SJYRHA0} works as expected.
+  void withYouTubeInline() {}
+
   /// Animation method
   ///
   /// {@animation 100 100 http://host/path/to/video.mp4}
diff --git a/testing/test_package_doc_errors/lib/doc_errors.dart b/testing/test_package_doc_errors/lib/doc_errors.dart
index 7cb5ed3..6e7cdcd 100644
--- a/testing/test_package_doc_errors/lib/doc_errors.dart
+++ b/testing/test_package_doc_errors/lib/doc_errors.dart
@@ -4,6 +4,34 @@
 // This is the place to put documentation that has errors in it:
 // This package is expected to fail.
 abstract class DocumentationErrors {
+  /// Malformed YouTube video method with wrong parameters
+  ///
+  /// {@youtube https://youtu.be/oHg5SJYRHA0}
+  /// More docs
+  void withYouTubeWrongParams() {}
+
+  /// Malformed YouTube video method with non-integer width
+  ///
+  /// {@youtube 100px 100 https://youtu.be/oHg5SJYRHA0}
+  /// More docs
+  void withYouTubeBadWidth() {}
+
+  /// Malformed YouTube video method with non-integer height
+  ///
+  /// {@youtube 100 100px https://youtu.be/oHg5SJYRHA0}
+  /// More docs
+  void withYouTubeBadHeight() {}
+
+  /// YouTube video with an invalid URL.
+  ///
+  /// {@youtube 100 100 http://host/path/to/video.mp4}
+  void withYouTubeInvalidUrl() {}
+
+  /// YouTube video with extra parameters in URL.
+  ///
+  /// {@youtube 100 100 https://www.youtube.com/watch?v=yI-8QHpGIP4&list=PLjxrf2q8roU23XGwz3Km7sQZFTdB996iG&index=5}
+  void withYouTubeUrlWithAdditionalParameters() {}
+
   /// Animation method with invalid name
   ///
   /// {@animation 100 100 http://host/path/to/video.mp4 id=2isNot-A-ValidName}