Add anchors to samples (#35906)

This adds an "anchor button" to each of the samples so that the user can link to individual samples instead of having to link to just the page. Clicking on the anchor button jumps to the anchor, as well as copying the anchor URL to the clipboard.

There is some oddness in the implementation: because dartdoc uses a <base> tag, the href for the link can't just be "#id", it has to calculate the URL from the current window href. I do that in the onmouseenter and onclick because onload doesn't get triggered for <a> tags (and onmouseenter doesn't get triggered for mobile platforms), but I still want the href to be updated before someone right-clicks it to copy the URL.
diff --git a/dev/docs/assets/snippets.css b/dev/docs/assets/snippets.css
index 4fb200a..e5ee48f 100644
--- a/dev/docs/assets/snippets.css
+++ b/dev/docs/assets/snippets.css
@@ -83,6 +83,26 @@
   font-family: courier, lucidia;
 }
 
+.anchor-container {
+  position: relative;
+}
+
+.anchor-button-overlay {
+  position: absolute;
+  top: 0px;
+  right: 5px;
+  height: 28px;
+  width: 28px;
+  transition: .3s ease;
+  background-color: #2372a3;
+}
+
+.anchor-button {
+  border-style: none;
+  background: none;
+  cursor: pointer;
+}
+
 /* Styles for the copy-to-clipboard button */
 .copyable-container {
   position: relative;
diff --git a/dev/docs/assets/snippets.js b/dev/docs/assets/snippets.js
index 9d4da92..86e9a2c 100644
--- a/dev/docs/assets/snippets.js
+++ b/dev/docs/assets/snippets.js
@@ -57,6 +57,30 @@
       !!document.queryCommandSupported('copy');
 }
 
+// Copies the given string to the clipboard.
+function copyStringToClipboard(string) {
+  var textArea = document.createElement("textarea");
+  textArea.value = string;
+  document.body.appendChild(textArea);
+  textArea.focus();
+  textArea.select();
+
+  if (!supportsCopying()) {
+    alert('Unable to copy to clipboard (not supported by browser)');
+    return;
+  }
+
+  try {
+    document.execCommand('copy');
+  } finally {
+    document.body.removeChild(textArea);
+  }
+}
+
+function fixHref(anchor, id) {
+  anchor.href = window.location.href.replace(/#.*$/, '') + '#' + id;
+}
+
 // Copies the text inside the currently visible snippet to the clipboard, or the
 // given element, if any.
 function copyTextToClipboard(element) {
diff --git a/dev/snippets/config/skeletons/application.html b/dev/snippets/config/skeletons/application.html
index 7d7e17b..56b648c 100644
--- a/dev/snippets/config/skeletons/application.html
+++ b/dev/snippets/config/skeletons/application.html
@@ -1,4 +1,13 @@
 {@inject-html}
+<a name="{{id}}"></a>
+<div class="anchor-container">
+  <a class="anchor-button-overlay anchor-button" title="Copy link to clipboard"
+     onmouseenter="fixHref(this, '{{id}}');"
+     onclick="fixHref(this, '{{id}}'); copyStringToClipboard(this.href);"
+     href="#">
+    <i class="material-icons copy-image">link</i>
+  </a>
+</div>
 <div class="snippet-buttons">
   <script>var visibleSnippet{{serial}} = "shortSnippet{{serial}}";</script>
   <button id="shortSnippet{{serial}}Button"
diff --git a/dev/snippets/config/skeletons/sample.html b/dev/snippets/config/skeletons/sample.html
index 66ae99b..75f8782 100644
--- a/dev/snippets/config/skeletons/sample.html
+++ b/dev/snippets/config/skeletons/sample.html
@@ -1,4 +1,13 @@
 {@inject-html}
+<a name="{{id}}"></a>
+<div class="anchor-container">
+  <a class="anchor-button-overlay anchor-button" title="Copy link to clipboard"
+     onmouseenter="fixHref(this, '{{id}}');"
+     onclick="fixHref(this, '{{id}}'); copyStringToClipboard(this.href);"
+     href="#">
+    <i class="material-icons copy-image">link</i>
+  </a>
+</div>
 <div class="snippet-buttons">
   <button id="shortSnippet{{serial}}Button" selected>Sample</button>
 </div>
diff --git a/dev/snippets/lib/main.dart b/dev/snippets/lib/main.dart
index 786833d..996b186 100644
--- a/dev/snippets/lib/main.dart
+++ b/dev/snippets/lib/main.dart
@@ -152,13 +152,13 @@
     input,
     snippetType,
     template: template,
-    id: id.join('.'),
     output: args[_kOutputOption] != null ? File(args[_kOutputOption]) : null,
     metadata: <String, Object>{
       'sourcePath': environment['SOURCE_PATH'],
       'sourceLine': environment['SOURCE_LINE'] != null
           ? int.tryParse(environment['SOURCE_LINE'])
           : null,
+      'id': id.join('.'),
       'serial': serial,
       'package': packageName,
       'library': libraryName,
diff --git a/dev/snippets/lib/snippets.dart b/dev/snippets/lib/snippets.dart
index 9f69dae..05d54f4 100644
--- a/dev/snippets/lib/snippets.dart
+++ b/dev/snippets/lib/snippets.dart
@@ -5,8 +5,9 @@
 import 'dart:convert';
 import 'dart:io';
 
-import 'package:path/path.dart' as path;
 import 'package:dart_style/dart_style.dart';
+import 'package:meta/meta.dart';
+import 'package:path/path.dart' as path;
 
 import 'configuration.dart';
 
@@ -128,13 +129,12 @@
       'code': htmlEscape.convert(result.join('\n')),
       'language': language ?? 'dart',
       'serial': '',
-      'id': '',
+      'id': metadata['id'],
       'app': '',
     };
     if (type == SnippetType.application) {
       substitutions
-        ..['serial'] = metadata['serial'].toString() ?? '0'
-        ..['id'] = injections.firstWhere((_ComponentTuple tuple) => tuple.name == 'id').mergedContent
+        ..['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) {
@@ -209,9 +209,15 @@
   /// 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, {String template, String id, File output, Map<String, Object> metadata}) {
+  String generate(
+    File input,
+    SnippetType type, {
+    String template,
+    File output,
+    @required Map<String, Object> metadata,
+  }) {
     assert(template != null || type != SnippetType.application);
-    assert(id != null || type != SnippetType.application);
+    assert(metadata != null && metadata['id'] != null);
     assert(input != null);
     final List<_ComponentTuple> snippetData = parseInput(_loadFileAsUtf8(input));
     switch (type) {
@@ -227,7 +233,6 @@
               'The template $template was not found in the templates directory ${templatesDir.path}');
           exit(1);
         }
-        snippetData.add(_ComponentTuple('id', <String>[id]));
         final String templateContents = _loadFileAsUtf8(templateFile);
         String app = interpolateTemplate(snippetData, templateContents);
 
@@ -239,7 +244,7 @@
         }
 
         snippetData.add(_ComponentTuple('app', app.split('\n')));
-        final File outputFile = output ?? getOutputFile(id);
+        final File outputFile = output ?? getOutputFile(metadata['id']);
         stderr.writeln('Writing to ${outputFile.absolute.path}');
         outputFile.writeAsStringSync(app);
 
@@ -252,7 +257,7 @@
         );
         metadata ??= <String, Object>{};
         metadata.addAll(<String, Object>{
-          'id': id,
+          'id': metadata['id'],
           'file': path.basename(outputFile.path),
           'description': description?.mergedContent,
         });
diff --git a/dev/snippets/test/snippets_test.dart b/dev/snippets/test/snippets_test.dart
index 6408623..f3cb758 100644
--- a/dev/snippets/test/snippets_test.dart
+++ b/dev/snippets/test/snippets_test.dart
@@ -74,8 +74,14 @@
 ```
 ''');
 
-      final String html =
-          generator.generate(inputFile, SnippetType.application, template: 'template', id: 'id');
+      final String html = generator.generate(
+        inputFile,
+        SnippetType.application,
+        template: 'template',
+        metadata: <String, Object>{
+          'id': 'id',
+        },
+      );
       expect(html, contains('<div>HTML Bits</div>'));
       expect(html, contains('<div>More HTML Bits</div>'));
       expect(html, contains('print(&#39;The actual \$name.&#39;);'));
@@ -103,7 +109,7 @@
 ```
 ''');
 
-      final String html = generator.generate(inputFile, SnippetType.sample);
+      final String html = generator.generate(inputFile, SnippetType.sample, metadata: <String, Object>{'id': 'id'});
       expect(html, contains('<div>HTML Bits</div>'));
       expect(html, contains('<div>More HTML Bits</div>'));
       expect(html, contains('  print(&#39;The actual \$name.&#39;);'));
@@ -135,9 +141,8 @@
         inputFile,
         SnippetType.application,
         template: 'template',
-        id: 'id',
         output: outputFile,
-        metadata: <String, Object>{'sourcePath': 'some/path.dart'},
+        metadata: <String, Object>{'sourcePath': 'some/path.dart', 'id': 'id'},
       );
       expect(expectedMetadataFile.existsSync(), isTrue);
       final Map<String, dynamic> json = jsonDecode(expectedMetadataFile.readAsStringSync());