Add manually-specified source code links to code (#1913)

* Link to source now works, minus icons

* Add material icons

* Refactor dartdoc_test to avoid asynchronous delete collisions

* dartfmt

* Fix windows

* dartfmt

* Tweak padding and change icon to description

* Review comments

* Add example and use it in the dartdoc package.

* dartfmt
diff --git a/README.md b/README.md
index 5880cb5..503487f 100644
--- a/README.md
+++ b/README.md
@@ -163,6 +163,19 @@
         No branch is considered to be "stable".
       * `%n%`: The name of this package, as defined in pubspec.yaml.
       * `%v%`: The version of this package as defined in pubspec.yaml.
+  * **linkToSource**: Generate links to a source code repository based on given templates and
+    revision information.
+    * **excludes**: A list of directories to exclude from processing source links.
+    * **root**: The directory to consider the 'root' for inserting relative paths into the template.
+      Source code outside the root directory will not be linked.
+    * **uriTemplate**: A template to substitute revision and file path information.  If revision
+      is present in the template but not specified, or if root is not specified, dartdoc will
+      throw an exception.  To hard-code a revision, don't specify it with `%r%`.
+      
+      The following strings will be substituted in to complete the URL:
+      * `%f%`:  Relative path of file to the repository root
+      * `%r%`:  Revision
+      * `%l%`:  Line number
   * **warnings**:  Specify otherwise ignored or set-to-error warnings to simply warn.  See the lists
     of valid warnings in the command line help for `--errors`, `--warnings`, and `--ignore`.
 
@@ -391,6 +404,35 @@
 If `--auto-include-dependencies` flag is provided, dartdoc tries to automatically add
 all the used libraries, even from other packages, to the list of the documented libraries.
 
+### Using link-to-source
+
+The source linking feature in dartdoc is a little tricky to use, since pub packages do not actually
+include enough information to link back to source code and that's the context in which documentation
+is generated for the pub site.  This means that for now, it must be manually specified in
+dartdoc_options.yaml what revision to use.  It is currently recommended practice to
+specify a revision in dartdoc_options.yaml that points to the same revision as your public package.
+If you're using a documentation staging system outside of Dart's pub site, override the template and
+revision on the command line with the head revision number.  You can use the branch name,
+but generated docs will generate locations that may start drifting with further changes to the branch.
+
+Example dartdoc_options.yaml:
+```yaml
+link-to-source:
+  root: '.'
+  uriTemplate: 'https://github.com/dart-lang/dartdoc/blob/v0.28.0/%f%#L%l%'
+```
+
+Example staging command line:
+```bash
+pub global run dartdoc --link-to-source-root '.' --link-to-source-revision 6fac6f770d271312c88e8ae881861702a9a605be --link-to-source-uri-template 'https://github.com/dart-lang/dartdoc/blob/%r%/%f#L%l%'
+```
+
+This gets more complicated with `--auto-include-dependencies` as these command line flags
+will override all settings from individual packages.  In that case, to preserve
+source links from third party packages it may be necessary to generate
+dartdoc_options.yaml options for each package you are intending to add source links
+to yourself.
+
 ## Issues and bugs
 
 Please file reports on the [GitHub Issue Tracker][].  Issues are labeled with
diff --git a/dartdoc_options.yaml b/dartdoc_options.yaml
new file mode 100644
index 0000000..658ddd1
--- /dev/null
+++ b/dartdoc_options.yaml
@@ -0,0 +1,4 @@
+dartdoc:
+  linkToSource:
+    root: '.'
+    uriTemplate: 'https://github.com/dart-lang/dartdoc/blob/v0.28.0/%f%#L%l%'
diff --git a/lib/resources/styles.css b/lib/resources/styles.css
index 7d8cc1d..59e0590 100644
--- a/lib/resources/styles.css
+++ b/lib/resources/styles.css
@@ -437,6 +437,29 @@
   vertical-align: middle;
 }
 
+.source-link {
+  padding: 18px 4px;
+  font-size: 12px;
+  vertical-align: middle;
+}
+
+@media (max-width: 768px) {
+  .source-link {
+    padding: 7px 2px;
+    font-size: 10px;
+  }
+}
+
+#external-links {
+  float: right;
+}
+
+.btn-group {
+  position: relative;
+  display: inline-flex;
+  vertical-align: middle;
+}
+
 p.firstline {
   font-weight: bold;
 }
diff --git a/lib/src/dartdoc_options.dart b/lib/src/dartdoc_options.dart
index e97fe4f..13b9d88 100644
--- a/lib/src/dartdoc_options.dart
+++ b/lib/src/dartdoc_options.dart
@@ -22,6 +22,7 @@
 import 'package:dartdoc/dartdoc.dart';
 import 'package:dartdoc/src/experiment_options.dart';
 import 'package:dartdoc/src/io_utils.dart';
+import 'package:dartdoc/src/source_linker.dart';
 import 'package:dartdoc/src/tool_runner.dart';
 import 'package:dartdoc/src/tuple.dart';
 import 'package:dartdoc/src/warnings.dart';
@@ -1272,7 +1273,10 @@
 /// a single [ModelElement], [Package], [Category] and so forth has a single context
 /// and so this can be made a member variable of those structures.
 class DartdocOptionContext extends DartdocOptionContextBase
-    with DartdocExperimentOptionContext, PackageWarningOptionContext {
+    with
+        DartdocExperimentOptionContext,
+        PackageWarningOptionContext,
+        SourceLinkerOptionContext {
   @override
   final DartdocOptionSet optionSet;
   @override
@@ -1573,5 +1577,6 @@
     // each DartdocOptionContext that traverses the inheritance tree itself.
   ]
     ..addAll(await createExperimentOptions())
-    ..addAll(await createPackageWarningOptions());
+    ..addAll(await createPackageWarningOptions())
+    ..addAll(await createSourceLinkerOptions());
 }
diff --git a/lib/src/html/templates.dart b/lib/src/html/templates.dart
index 178c40e..ee30023 100644
--- a/lib/src/html/templates.dart
+++ b/lib/src/html/templates.dart
@@ -30,6 +30,7 @@
   'sidebar_for_category',
   'sidebar_for_enum',
   'source_code',
+  'source_link',
   'sidebar_for_library',
   'accessor_getter',
   'accessor_setter',
diff --git a/lib/src/model.dart b/lib/src/model.dart
index 9bfe5fe..0e5cc90 100644
--- a/lib/src/model.dart
+++ b/lib/src/model.dart
@@ -56,6 +56,7 @@
 import 'package:dartdoc/src/markdown_processor.dart' show Documentation;
 import 'package:dartdoc/src/model_utils.dart';
 import 'package:dartdoc/src/package_meta.dart' show PackageMeta, FileContents;
+import 'package:dartdoc/src/source_linker.dart';
 import 'package:dartdoc/src/special_elements.dart';
 import 'package:dartdoc/src/tuple.dart';
 import 'package:dartdoc/src/utils.dart';
@@ -3215,6 +3216,13 @@
     return _documentationFrom;
   }
 
+  bool get hasSourceHref => sourceHref.isNotEmpty;
+  String _sourceHref;
+  String get sourceHref {
+    _sourceHref ??= new SourceLinker.fromElement(this).href();
+    return _sourceHref;
+  }
+
   /// Returns the ModelElement(s) from which we will get documentation.
   /// Can be more than one if this is a Field composing documentation from
   /// multiple Accessors.
diff --git a/lib/src/source_linker.dart b/lib/src/source_linker.dart
new file mode 100644
index 0000000..220b518
--- /dev/null
+++ b/lib/src/source_linker.dart
@@ -0,0 +1,119 @@
+// Copyright (c) 2019, the Dart project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+/// A library for getting external source code links for Dartdoc.
+library dartdoc.source_linker;
+
+import 'package:dartdoc/src/dartdoc_options.dart';
+import 'package:dartdoc/src/model.dart';
+import 'package:path/path.dart' as pathLib;
+
+final uriTemplateRegexp = new RegExp(r'(%[frl]%)');
+
+abstract class SourceLinkerOptionContext implements DartdocOptionContextBase {
+  List<String> get linkToSourceExcludes =>
+      optionSet['linkToSource']['excludes'].valueAt(context);
+  String get linkToSourceRevision =>
+      optionSet['linkToSource']['revision'].valueAt(context);
+  String get linkToSourceRoot =>
+      optionSet['linkToSource']['root'].valueAt(context);
+  String get linkToSourceUriTemplate =>
+      optionSet['linkToSource']['uriTemplate'].valueAt(context);
+}
+
+Future<List<DartdocOption>> createSourceLinkerOptions() async {
+  return <DartdocOption>[
+    new DartdocOptionSet('linkToSource')
+      ..addAll([
+        new DartdocOptionArgFile<List<String>>('excludes', [],
+            isDir: true,
+            help:
+                'A list of directories to exclude from linking to a source code repository.'),
+        // TODO(jcollins-g): Use [DartdocOptionArgSynth], possibly in combination with a repository type and the root directory, and get revision number automatically
+        new DartdocOptionArgOnly<String>('revision', null,
+            help: 'Revision number to insert into the URI.'),
+        new DartdocOptionArgFile<String>('root', null,
+            isDir: true,
+            help:
+                'Path to a local directory that is the root of the repository we link to.  All source code files under this directory will be linked.'),
+        new DartdocOptionArgFile<String>('uriTemplate', null,
+            help:
+                '''Substitute into this template to generate a uri for an element's source code.
+             Dartdoc dynamically substitutes the following fields into the template:
+               %f%:  Relative path of file to the repository root
+               %r%:  Revision number
+               %l%:  Line number'''),
+      ])
+  ];
+}
+
+class SourceLinker {
+  final List<String> excludes;
+  final int lineNumber;
+  final String sourceFileName;
+  final String revision;
+  final String root;
+  final String uriTemplate;
+
+  /// Most users of this class should use the [SourceLinker.fromElement] factory
+  /// instead.  This constructor is public for testing.
+  SourceLinker(
+      {List<String> this.excludes,
+      int this.lineNumber,
+      String this.sourceFileName,
+      String this.revision,
+      String this.root,
+      String this.uriTemplate}) {
+    assert(excludes != null, 'linkToSource excludes can not be null');
+    if (revision != null || root != null || uriTemplate != null) {
+      if (root == null || uriTemplate == null) {
+        throw DartdocOptionError(
+            'linkToSource root and uriTemplate must both be specified to generate repository links');
+      }
+      if (uriTemplate.contains('%r%') && revision == null) {
+        throw DartdocOptionError(
+            r'%r% specified in uriTemplate, but no revision available');
+      }
+    }
+  }
+
+  /// Build a SourceLinker from a ModelElement.
+  factory SourceLinker.fromElement(ModelElement element) {
+    SourceLinkerOptionContext config = element.config;
+    return new SourceLinker(
+      excludes: config.linkToSourceExcludes,
+      // TODO(jcollins-g): disallow defaulting?  Some elements come back without
+      // a line number right now.
+      lineNumber: element.lineAndColumn?.item1 ?? 1,
+      sourceFileName: element.sourceFileName,
+      revision: config.linkToSourceRevision,
+      root: config.linkToSourceRoot,
+      uriTemplate: config.linkToSourceUriTemplate,
+    );
+  }
+
+  String href() {
+    if (sourceFileName == null || root == null || uriTemplate == null)
+      return '';
+    if (!pathLib.isWithin(root, sourceFileName) ||
+        excludes
+            .any((String exclude) => pathLib.isWithin(exclude, sourceFileName)))
+      return '';
+    return uriTemplate.replaceAllMapped(uriTemplateRegexp, (match) {
+      switch (match[1]) {
+        case '%f%':
+          var urlContext = new pathLib.Context(style: pathLib.Style.url);
+          return urlContext.joinAll(
+              pathLib.split(pathLib.relative(sourceFileName, from: root)));
+          break;
+        case '%r%':
+          return revision;
+          break;
+        case '%l%':
+          return lineNumber.toString();
+          break;
+      }
+    });
+  }
+}
diff --git a/lib/src/warnings.dart b/lib/src/warnings.dart
index 93c5e61..1ac42e8 100644
--- a/lib/src/warnings.dart
+++ b/lib/src/warnings.dart
@@ -208,11 +208,10 @@
 
 /// Something that can be located for warning purposes.
 abstract class Locatable {
+  List<Locatable> get documentationFrom;
   String get fullyQualifiedName;
   String get href;
 
-  List<Locatable> get documentationFrom;
-
   /// A string indicating the URI of this Locatable, usually derived from
   /// [Element.location].
   String get location;
diff --git a/lib/templates/_head.html b/lib/templates/_head.html
index e369aba..3628bd9 100644
--- a/lib/templates/_head.html
+++ b/lib/templates/_head.html
@@ -18,6 +18,7 @@
   {{/htmlBase}}
 
   <link href="https://fonts.googleapis.com/css?family=Source+Code+Pro:500,400i,400,300|Source+Sans+Pro:400,300,700" rel="stylesheet">
+  <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
   <link rel="stylesheet" href="static-assets/github.css">
   <link rel="stylesheet" href="static-assets/styles.css">
   <link rel="icon" href="static-assets/favicon.png">
diff --git a/lib/templates/_source_link.html b/lib/templates/_source_link.html
new file mode 100644
index 0000000..7db4cf9
--- /dev/null
+++ b/lib/templates/_source_link.html
@@ -0,0 +1,3 @@
+{{#hasSourceHref}}
+  <div id="external-links" class="btn-group"><a title="View source code" class="source-link" href="{{{sourceHref}}}"><i class="material-icons">description</i></a></div>
+{{/hasSourceHref}}
\ No newline at end of file
diff --git a/lib/templates/class.html b/lib/templates/class.html
index 9a6505e..d511c4a 100644
--- a/lib/templates/class.html
+++ b/lib/templates/class.html
@@ -8,7 +8,7 @@
 
   <div class="col-xs-12 col-sm-9 col-md-8 main-content">
     {{#self}}
-      <h1>{{{nameWithGenerics}}} {{kind}} {{>categorization}}</h1>
+      <div>{{>source_link}}<h1>{{{nameWithGenerics}}} {{kind}} {{>categorization}}</h1></div>
     {{/self}}
 
     {{#clazz}}
diff --git a/lib/templates/constant.html b/lib/templates/constant.html
index 0d7c09d..f8ae237 100644
--- a/lib/templates/constant.html
+++ b/lib/templates/constant.html
@@ -7,7 +7,9 @@
   </div><!--/.sidebar-offcanvas-left-->
 
   <div class="col-xs-12 col-sm-9 col-md-8 main-content">
-    <h1>{{self.name}} {{self.kind}}</h1>
+    {{#self}}
+      <div>{{>source_link}}<h1>{{{name}}} {{kind}}</h1></div>
+    {{/self}}
 
     <section class="multi-line-signature">
       {{#property}}
diff --git a/lib/templates/constructor.html b/lib/templates/constructor.html
index 9422f14..a9f488f 100644
--- a/lib/templates/constructor.html
+++ b/lib/templates/constructor.html
@@ -7,7 +7,9 @@
   </div><!--/.sidebar-offcanvas-left-->
 
   <div class="col-xs-12 col-sm-9 col-md-8 main-content">
-    <h1>{{{self.nameWithGenerics}}} {{self.kind}}</h1>
+    {{#self}}
+      <div>{{>source_link}}<h1>{{{nameWithGenerics}}} {{kind}}</h1></div>
+    {{/self}}
 
     {{#constructor}}
     <section class="multi-line-signature">
diff --git a/lib/templates/enum.html b/lib/templates/enum.html
index 6a72ee7..25a0ac1 100644
--- a/lib/templates/enum.html
+++ b/lib/templates/enum.html
@@ -8,7 +8,7 @@
 
   <div class="col-xs-12 col-sm-9 col-md-8 main-content">
     {{#self}}
-    <h1>{{{name}}} {{kind}} {{>categorization}}</h1>
+      <div>{{>source_link}}<h1>{{{name}}} {{kind}} {{>categorization}}</h1></div>
     {{/self}}
 
     {{#eNum}}
diff --git a/lib/templates/function.html b/lib/templates/function.html
index 73a79ad..c13f320 100644
--- a/lib/templates/function.html
+++ b/lib/templates/function.html
@@ -8,7 +8,7 @@
 
   <div class="col-xs-12 col-sm-9 col-md-8 main-content">
     {{#self}}
-    <h1>{{{nameWithGenerics}}} {{kind}} {{>categorization}}</h1>
+      <div>{{>source_link}}<h1>{{{nameWithGenerics}}} {{kind}} {{>categorization}}</h1></div>
     {{/self}}
 
     {{#function}}
diff --git a/lib/templates/library.html b/lib/templates/library.html
index 1c3b56c..ab1c240 100644
--- a/lib/templates/library.html
+++ b/lib/templates/library.html
@@ -8,7 +8,7 @@
 
   <div class="col-xs-12 col-sm-9 col-md-8 main-content">
     {{#self}}
-    <h1>{{{name}}} {{kind}} {{>categorization}}</h1>
+      <div>{{>source_link}}<h1>{{{name}}} {{kind}} {{>categorization}}</h1></div>
     {{/self}}
 
     {{#library}}
diff --git a/lib/templates/method.html b/lib/templates/method.html
index 4c4f021..ff612ad 100644
--- a/lib/templates/method.html
+++ b/lib/templates/method.html
@@ -7,7 +7,9 @@
   </div><!--/.sidebar-offcanvas-->
 
   <div class="col-xs-12 col-sm-9 col-md-8 main-content">
-    <h1>{{{self.nameWithGenerics}}} {{self.kind}}</h1>
+    {{#self}}
+      <div>{{>source_link}}<h1>{{{nameWithGenerics}}} {{kind}}</h1></div>
+    {{/self}}
 
     {{#method}}
     <section class="multi-line-signature">
diff --git a/lib/templates/mixin.html b/lib/templates/mixin.html
index 1902b4e..20ae6a3 100644
--- a/lib/templates/mixin.html
+++ b/lib/templates/mixin.html
@@ -8,7 +8,7 @@
 
   <div class="col-xs-12 col-sm-9 col-md-8 main-content">
     {{#self}}
-      <h1>{{{nameWithGenerics}}} {{kind}} {{>categorization}}</h1>
+      <div>{{>source_link}}<h1>{{{nameWithGenerics}}} {{kind}} {{>categorization}}</h1></div>
     {{/self}}
 
     {{#mixin}}
diff --git a/lib/templates/property.html b/lib/templates/property.html
index e6b3ded..915c420 100644
--- a/lib/templates/property.html
+++ b/lib/templates/property.html
@@ -7,7 +7,9 @@
   </div><!--/.sidebar-offcanvas-->
 
   <div class="col-xs-12 col-sm-9 col-md-8 main-content">
-    <h1>{{self.name}} {{self.kind}}</h1>
+    {{#self}}
+      <div>{{>source_link}}<h1>{{name}} {{kind}}</h1></div>
+    {{/self}}
 
     {{#self}}
       {{#hasNoGetterSetter}}
diff --git a/lib/templates/top_level_constant.html b/lib/templates/top_level_constant.html
index 47c8340..60499ef 100644
--- a/lib/templates/top_level_constant.html
+++ b/lib/templates/top_level_constant.html
@@ -8,7 +8,7 @@
 
   <div class="col-xs-12 col-sm-9 col-md-8 main-content">
     {{#self}}
-    <h1>{{{name}}} {{kind}} {{>categorization}}</h1>
+    <div>{{>source_link}}<h1>{{{name}}} {{kind}} {{>categorization}}</h1></div>
 
     <section class="multi-line-signature">
       {{>name_summary}}
diff --git a/lib/templates/top_level_property.html b/lib/templates/top_level_property.html
index 4e6a22e..f3682c2 100644
--- a/lib/templates/top_level_property.html
+++ b/lib/templates/top_level_property.html
@@ -8,7 +8,7 @@
 
   <div class="col-xs-12 col-sm-9 col-md-8 main-content">
     {{#self}}
-    <h1>{{{name}}} {{kind}} {{>categorization}}</h1>
+    <div>{{>source_link}}<h1>{{{name}}} {{kind}} {{>categorization}}</h1></div>
 
     {{#hasNoGetterSetter}}
     <section class="multi-line-signature">
diff --git a/lib/templates/typedef.html b/lib/templates/typedef.html
index c0b072a..eb064ca 100644
--- a/lib/templates/typedef.html
+++ b/lib/templates/typedef.html
@@ -8,7 +8,7 @@
 
   <div class="col-xs-12 col-sm-9 col-md-8 main-content">
     {{#self}}
-    <h1>{{{nameWithGenerics}}} {{kind}} {{>categorization}}</h1>
+    <div>{{>source_link}}<h1>{{{nameWithGenerics}}} {{kind}} {{>categorization}}</h1></div>
     {{/self}}
 
     <section class="multi-line-signature">
diff --git a/test/dartdoc_test.dart b/test/dartdoc_test.dart
index 82c27b9..664d7df 100644
--- a/test/dartdoc_test.dart
+++ b/test/dartdoc_test.dart
@@ -26,11 +26,9 @@
 void main() {
   group('dartdoc with generators', () {
     Directory tempDir;
-    List<String> outputParam;
 
     setUpAll(() async {
       tempDir = Directory.systemTemp.createTempSync('dartdoc.test.');
-      outputParam = ['--output', tempDir.path];
       DartdocOptionSet optionSet = await DartdocOptionSet.fromOptionGenerators(
           'dartdoc', [createLoggingOptions]);
       optionSet.parseArguments([]);
@@ -38,29 +36,38 @@
           new DartdocLoggingOptionContext(optionSet, Directory.current));
     });
 
+    setUp(() async {
+      tempDir = Directory.systemTemp.createTempSync('dartdoc.test.');
+    });
+
     tearDown(() async {
-      tempDir.listSync().forEach((FileSystemEntity f) {
-        f.deleteSync(recursive: true);
-      });
+      tempDir.deleteSync(recursive: true);
     });
 
     Future<Dartdoc> buildDartdoc(
-        List<String> argv, Directory packageRoot) async {
+        List<String> argv, Directory packageRoot, Directory tempDir) async {
       return await Dartdoc.withDefaultGenerators(await generatorContextFromArgv(
-          argv..addAll(['--input', packageRoot.path])..addAll(outputParam)));
+          argv
+            ..addAll(['--input', packageRoot.path, '--output', tempDir.path])));
     }
 
     group('Option handling', () {
       Dartdoc dartdoc;
       DartdocResults results;
       PackageGraph p;
+      Directory tempDir;
 
       setUpAll(() async {
-        dartdoc = await buildDartdoc([], testPackageOptions);
+        tempDir = Directory.systemTemp.createTempSync('dartdoc.test.');
+        dartdoc = await buildDartdoc([], testPackageOptions, tempDir);
         results = await dartdoc.generateDocsBase();
         p = results.packageGraph;
       });
 
+      tearDownAll(() async {
+        tempDir.deleteSync(recursive: true);
+      });
+
       test('generator parameters', () async {
         File favicon = new File(
             pathLib.joinAll([tempDir.path, 'static-assets', 'favicon.png']));
@@ -93,7 +100,7 @@
     });
 
     test('errors generate errors even when warnings are off', () async {
-      Dartdoc dartdoc = await buildDartdoc([], testPackageToolError);
+      Dartdoc dartdoc = await buildDartdoc([], testPackageToolError, tempDir);
       DartdocResults results = await dartdoc.generateDocsBase();
       PackageGraph p = results.packageGraph;
       Iterable<String> unresolvedToolErrors = p
@@ -112,15 +119,21 @@
     group('Option handling with cross-linking', () {
       DartdocResults results;
       Package testPackageOptions;
+      Directory tempDir;
 
       setUpAll(() async {
+        tempDir = Directory.systemTemp.createTempSync('dartdoc.test.');
         results = await (await buildDartdoc(
-                ['--link-to-remote'], testPackageOptionsImporter))
+                ['--link-to-remote'], testPackageOptionsImporter, tempDir))
             .generateDocsBase();
         testPackageOptions = results.packageGraph.packages
             .firstWhere((Package p) => p.name == 'test_package_options');
       });
 
+      tearDownAll(() async {
+        tempDir.deleteSync(recursive: true);
+      });
+
       test('linkToUrl', () async {
         Library main = testPackageOptions.allLibraries
             .firstWhere((Library l) => l.name == 'main');
@@ -145,7 +158,8 @@
     });
 
     test('with broken reexport chain', () async {
-      Dartdoc dartdoc = await buildDartdoc([], testPackageImportExportError);
+      Dartdoc dartdoc =
+          await buildDartdoc([], testPackageImportExportError, tempDir);
       DartdocResults results = await dartdoc.generateDocsBase();
       PackageGraph p = results.packageGraph;
       Iterable<String> unresolvedExportWarnings = p
@@ -162,7 +176,8 @@
 
     group('include/exclude parameters', () {
       test('with config file', () async {
-        Dartdoc dartdoc = await buildDartdoc([], testPackageIncludeExclude);
+        Dartdoc dartdoc =
+            await buildDartdoc([], testPackageIncludeExclude, tempDir);
         DartdocResults results = await dartdoc.generateDocs();
         PackageGraph p = results.packageGraph;
         expect(p.localPublicLibraries.map((l) => l.name),
@@ -170,8 +185,8 @@
       });
 
       test('with include command line argument', () async {
-        Dartdoc dartdoc = await buildDartdoc(
-            ['--include', 'another_included'], testPackageIncludeExclude);
+        Dartdoc dartdoc = await buildDartdoc(['--include', 'another_included'],
+            testPackageIncludeExclude, tempDir);
         DartdocResults results = await dartdoc.generateDocs();
         PackageGraph p = results.packageGraph;
         expect(p.localPublicLibraries.length, equals(1));
@@ -180,7 +195,7 @@
 
       test('with exclude command line argument', () async {
         Dartdoc dartdoc = await buildDartdoc(
-            ['--exclude', 'more_included'], testPackageIncludeExclude);
+            ['--exclude', 'more_included'], testPackageIncludeExclude, tempDir);
         DartdocResults results = await dartdoc.generateDocs();
         PackageGraph p = results.packageGraph;
         expect(p.localPublicLibraries.length, equals(1));
@@ -190,7 +205,7 @@
     });
 
     test('package without version produces valid semver in docs', () async {
-      Dartdoc dartdoc = await buildDartdoc([], testPackageMinimumDir);
+      Dartdoc dartdoc = await buildDartdoc([], testPackageMinimumDir, tempDir);
       DartdocResults results = await dartdoc.generateDocs();
       PackageGraph p = results.packageGraph;
       expect(p.defaultPackage.version, equals('0.0.0-unknown'));
@@ -198,7 +213,7 @@
 
     test('basic interlinking test', () async {
       Dartdoc dartdoc =
-          await buildDartdoc(['--link-to-remote'], testPackageDir);
+          await buildDartdoc(['--link-to-remote'], testPackageDir, tempDir);
       DartdocResults results = await dartdoc.generateDocs();
       PackageGraph p = results.packageGraph;
       Package meta = p.publicPackages.firstWhere((p) => p.name == 'meta');
@@ -220,25 +235,49 @@
       expect(useSomethingInTheSdk.modelType.linkedName, contains(stringLink));
     });
 
-    test('generate docs for ${pathLib.basename(testPackageDir.path)} works',
-        () async {
-      Dartdoc dartdoc = await buildDartdoc([], testPackageDir);
+    group('validate basic doc generation', () {
+      Dartdoc dartdoc;
+      DartdocResults results;
+      Directory tempDir;
 
-      DartdocResults results = await dartdoc.generateDocs();
-      expect(results.packageGraph, isNotNull);
+      setUpAll(() async {
+        tempDir = Directory.systemTemp.createTempSync('dartdoc.test.');
+        dartdoc = await buildDartdoc([], testPackageDir, tempDir);
+        results = await dartdoc.generateDocs();
+      });
 
-      PackageGraph packageGraph = results.packageGraph;
-      Package p = packageGraph.defaultPackage;
-      expect(p.name, 'test_package');
-      expect(p.hasDocumentationFile, isTrue);
-      // Total number of public libraries in test_package.
-      expect(packageGraph.defaultPackage.publicLibraries, hasLength(12));
-      expect(packageGraph.localPackages.length, equals(1));
+      tearDownAll(() async {
+        tempDir.deleteSync(recursive: true);
+      });
+
+      test('generate docs for ${pathLib.basename(testPackageDir.path)} works',
+          () async {
+        expect(results.packageGraph, isNotNull);
+        PackageGraph packageGraph = results.packageGraph;
+        Package p = packageGraph.defaultPackage;
+        expect(p.name, 'test_package');
+        expect(p.hasDocumentationFile, isTrue);
+        // Total number of public libraries in test_package.
+        expect(packageGraph.defaultPackage.publicLibraries, hasLength(12));
+        expect(packageGraph.localPackages.length, equals(1));
+      });
+
+      test('source code links are visible', () async {
+        // Picked this object as this library explicitly should never contain
+        // a library directive, so we can predict what line number it will be.
+        File anonymousOutput = new File(pathLib.join(tempDir.path,
+            'anonymous_library', 'anonymous_library-library.html'));
+        expect(anonymousOutput.existsSync(), isTrue);
+        expect(
+            anonymousOutput.readAsStringSync(),
+            contains(
+                r'<a title="View source code" class="source-link" href="https://github.com/dart-lang/dartdoc/blob/master/testing/test_package/lib/anonymous_library.dart#L1"><i class="material-icons">description</i></a>'));
+      });
     });
 
     test('generate docs for ${pathLib.basename(testPackageBadDir.path)} fails',
         () async {
-      Dartdoc dartdoc = await buildDartdoc([], testPackageBadDir);
+      Dartdoc dartdoc = await buildDartdoc([], testPackageBadDir, tempDir);
 
       try {
         await dartdoc.generateDocs();
@@ -251,7 +290,8 @@
             'from analysis_options');
 
     test('generate docs for a package that does not have a readme', () async {
-      Dartdoc dartdoc = await buildDartdoc([], testPackageWithNoReadme);
+      Dartdoc dartdoc =
+          await buildDartdoc([], testPackageWithNoReadme, tempDir);
 
       DartdocResults results = await dartdoc.generateDocs();
       expect(results.packageGraph, isNotNull);
@@ -265,7 +305,7 @@
 
     test('generate docs including a single library', () async {
       Dartdoc dartdoc =
-          await buildDartdoc(['--include', 'fake'], testPackageDir);
+          await buildDartdoc(['--include', 'fake'], testPackageDir, tempDir);
 
       DartdocResults results = await dartdoc.generateDocs();
       expect(results.packageGraph, isNotNull);
@@ -279,7 +319,7 @@
 
     test('generate docs excluding a single library', () async {
       Dartdoc dartdoc =
-          await buildDartdoc(['--exclude', 'fake'], testPackageDir);
+          await buildDartdoc(['--exclude', 'fake'], testPackageDir, tempDir);
 
       DartdocResults results = await dartdoc.generateDocs();
       expect(results.packageGraph, isNotNull);
@@ -293,7 +333,8 @@
     });
 
     test('generate docs for package with embedder yaml', () async {
-      Dartdoc dartdoc = await buildDartdoc([], testPackageWithEmbedderYaml);
+      Dartdoc dartdoc =
+          await buildDartdoc([], testPackageWithEmbedderYaml, tempDir);
 
       DartdocResults results = await dartdoc.generateDocs();
       expect(results.packageGraph, isNotNull);
diff --git a/test/source_linker_test.dart b/test/source_linker_test.dart
new file mode 100644
index 0000000..e31014a
--- /dev/null
+++ b/test/source_linker_test.dart
@@ -0,0 +1,85 @@
+// Copyright (c) 2019, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+library dartdoc.source_linker_test;
+
+import 'package:dartdoc/src/dartdoc_options.dart';
+import 'package:dartdoc/src/source_linker.dart';
+import 'package:test/test.dart';
+
+void main() {
+  group('Source link computations', () {
+    test('Basic usage', () {
+      var sourceLinkerHref = () => new SourceLinker(
+              excludes: [],
+              lineNumber: 14,
+              root: 'path',
+              sourceFileName: 'path/to/file.dart',
+              revision: '01234abcd',
+              uriTemplate: 'http://github.com/base/%r%/%f%/L%l%')
+          .href();
+      expect(sourceLinkerHref(),
+          equals('http://github.com/base/01234abcd/to/file.dart/L14'));
+    });
+
+    test('Throw when missing a revision if one is in the template', () {
+      var sourceLinkerHref = () => new SourceLinker(
+              excludes: [],
+              lineNumber: 20,
+              root: 'path',
+              sourceFileName: 'path/to/file.dart',
+              uriTemplate: 'http://github.com/base/%r%/%f%/L%l%')
+          .href();
+      expect(sourceLinkerHref, throwsA(TypeMatcher<DartdocOptionError>()));
+    });
+
+    test('Allow a missing revision as long as it is not in the template', () {
+      var sourceLinkerHref = () => new SourceLinker(
+              excludes: [],
+              lineNumber: 71,
+              root: 'path',
+              sourceFileName: 'path/to/file.dart',
+              uriTemplate: 'http://github.com/base/master/%f%/L%l%')
+          .href();
+      expect(sourceLinkerHref(),
+          equals('http://github.com/base/master/to/file.dart/L71'));
+    });
+
+    test('Throw if only revision specified', () {
+      var sourceLinkerHref = () => new SourceLinker(
+            excludes: [],
+            lineNumber: 20,
+            revision: '12345',
+          ).href();
+      expect(sourceLinkerHref, throwsA(TypeMatcher<DartdocOptionError>()));
+    });
+
+    test('Hide a path inside an exclude', () {
+      var sourceLinkerHref = () => new SourceLinker(
+              excludes: ['path/under/exclusion'],
+              lineNumber: 14,
+              root: 'path',
+              sourceFileName: 'path/under/exclusion/file.dart',
+              revision: '01234abcd',
+              uriTemplate: 'http://github.com/base/%r%/%f%/L%l%')
+          .href();
+      expect(sourceLinkerHref(), equals(''));
+    });
+
+    test('Check that paths outside exclusions work', () {
+      var sourceLinkerHref = () => new SourceLinker(
+              excludes: ['path/under/exclusion'],
+              lineNumber: 14,
+              root: 'path',
+              sourceFileName: 'path/not/under/exclusion/file.dart',
+              revision: '01234abcd',
+              uriTemplate: 'http://github.com/base/%r%/%f%/L%l%')
+          .href();
+      expect(
+          sourceLinkerHref(),
+          equals(
+              'http://github.com/base/01234abcd/not/under/exclusion/file.dart/L14'));
+    });
+  });
+}
diff --git a/testing/test_package/dartdoc_options.yaml b/testing/test_package/dartdoc_options.yaml
index fb0a3df..305e927 100644
--- a/testing/test_package/dartdoc_options.yaml
+++ b/testing/test_package/dartdoc_options.yaml
@@ -16,3 +16,6 @@
       linux: ['/bin/sh', '-c', 'echo']
       windows: ['C:\\Windows\\System32\\cmd.exe', '/c', 'echo']
       description: 'Works on everything'
+  linkToSource:
+    root: '.'
+    uriTemplate: 'https://github.com/dart-lang/dartdoc/blob/master/testing/test_package/%f%#L%l%'
diff --git a/tool/grind.dart b/tool/grind.dart
index d707afc..a5cfdd4 100644
--- a/tool/grind.dart
+++ b/tool/grind.dart
@@ -779,10 +779,20 @@
   var launcher = new SubprocessLauncher('build');
   await launcher.runStreamed(sdkBin('pub'),
       ['run', 'build_runner', 'build', '--delete-conflicting-outputs']);
+
+  // TODO(jcollins-g): port to build system?
+  String version = _getPackageVersion();
+  File dartdoc_options = new File('dartdoc_options.yaml');
+  await dartdoc_options.writeAsString('''dartdoc:
+  linkToSource:
+    root: '.'
+    uriTemplate: 'https://github.com/dart-lang/dartdoc/blob/v${version}/%f%#L%l%'
+''');
 }
 
 /// Paths in this list are relative to lib/.
 final _generated_files_list = <String>[
+  '../dartdoc_options.yaml',
   'src/html/resources.g.dart',
   'src/version.dart',
 ].map((s) => pathLib.joinAll(pathLib.posix.split(s)));
@@ -803,8 +813,7 @@
     }
   }
 
-  await launcher.runStreamed(sdkBin('pub'),
-      ['run', 'build_runner', 'build', '--delete-conflicting-outputs']);
+  await build();
   for (String relPath in _generated_files_list) {
     File newVersion = new File(pathLib.join('lib', relPath));
     if (!await newVersion.exists()) {