Bazel DocGen (1/3): Make link expansion mechanism more powerful.

# Motivation

Linking from one part of the Blaze/Bazel (“Blazel”) documentation to another one is surprisingly difficult due to various distinctions (native code vs Starlark, Blaze vs Bazel, generated vs narrative documentation). As a result, we’ve implemented various hacks or simply tolerated broken links.

# Contributions

This CL addresses the problems by doubling down on dynamic link expansion:

- RuleLinkExpander can now be used to link to any file in the build encyclopedia (“BE”): While the `${link foo}` syntax was originally intended for linking to rule families, a previous version already contained a hack to link to some static documentation files. This CL super-charges RuleLinkExpander by allowing it to link to *any* BE file, based on an explicit link mapping that has to be defined in a JSON configuration file. As a result, it’s clearly visible which files diverge between Blaze and Bazel.
- Introduction of StarlarkDocExpander: This new class is a proxy for the improved RuleLinkExpander as well as StarlarkDocUtils.substituteVariables(). As a result, Starlark documentation can now easily link to pages in the BE.

# Why JSON?

JSON is a lightweight configuration format, and luckily both Bazel and Blaze already have a JSON parser (GSON) among their dependencies.

# Downsides

The Starlark DocGen code is a love letter to the `static` modifier, which unfortunately means that we have to pass the StarlarkDocExpander instance through many methods.

# Alternatives

We could have passed the StarlarkDocExpander instance to the Velocity Template engine, which would have allowed us to call the expand() method from within the templates. However, I think it’s easier to figure out what happens if the real logic happens inside Java.

PiperOrigin-RevId: 433039776
diff --git a/src/main/java/com/google/devtools/build/docgen/ApiExporter.java b/src/main/java/com/google/devtools/build/docgen/ApiExporter.java
index 15be4a7..1fe236f 100644
--- a/src/main/java/com/google/devtools/build/docgen/ApiExporter.java
+++ b/src/main/java/com/google/devtools/build/docgen/ApiExporter.java
@@ -22,6 +22,7 @@
 import com.google.devtools.build.docgen.builtin.BuiltinProtos.Value;
 import com.google.devtools.build.docgen.starlark.StarlarkBuiltinDoc;
 import com.google.devtools.build.docgen.starlark.StarlarkConstructorMethodDoc;
+import com.google.devtools.build.docgen.starlark.StarlarkDocExpander;
 import com.google.devtools.build.docgen.starlark.StarlarkMethodDoc;
 import com.google.devtools.build.docgen.starlark.StarlarkParamDoc;
 import com.google.devtools.common.options.OptionsParser;
@@ -278,10 +279,10 @@
 
   private static void printUsage(OptionsParser parser) {
     System.err.println(
-        "Usage: api_exporter_bin -n product_name -p rule_class_provider (-i input_dir)+\n"
+        "Usage: api_exporter_bin -m link_map_path -p rule_class_provider (-i input_dir)+\n"
             + "   -f outputFile [-b denylist] [-h]\n\n"
             + "Exports all Starlark builtins to a file including the embedded native rules.\n"
-            + "The product name (-n), rule class provider (-p), output file (-f) and at least \n"
+            + "The link map path (-m), rule class provider (-p), output file (-f) and at least \n"
             + " one input_dir (-i) must be specified.\n");
     System.err.println(
         parser.describeOptionsWithDeprecatedCategories(
@@ -299,7 +300,7 @@
       Runtime.getRuntime().exit(0);
     }
 
-    if (options.productName.isEmpty()
+    if (options.linkMapPath.isEmpty()
         || options.inputDirs.isEmpty()
         || options.provider.isEmpty()
         || options.outputFile.isEmpty()) {
@@ -308,9 +309,14 @@
     }
 
     try {
+      DocLinkMap linkMap = DocLinkMap.createFromFile(options.linkMapPath);
+      RuleLinkExpander ruleExpander = new RuleLinkExpander(true, linkMap);
       SymbolFamilies symbols =
           new SymbolFamilies(
-              options.productName, options.provider, options.inputDirs, options.denylist);
+              new StarlarkDocExpander(ruleExpander),
+              options.provider,
+              options.inputDirs,
+              options.denylist);
       Builtins.Builder builtins = Builtins.newBuilder();
 
       appendTypes(builtins, symbols.getTypes(), symbols.getNativeRules());
diff --git a/src/main/java/com/google/devtools/build/docgen/BUILD b/src/main/java/com/google/devtools/build/docgen/BUILD
index 009be96..19186f4 100644
--- a/src/main/java/com/google/devtools/build/docgen/BUILD
+++ b/src/main/java/com/google/devtools/build/docgen/BUILD
@@ -23,7 +23,6 @@
     deps = [
         "//src/main/java/com/google/devtools/build/docgen/annot",
         "//src/main/java/com/google/devtools/build/lib/analysis:analysis_cluster",
-        "//src/main/java/com/google/devtools/build/lib/bazel:main",
         "//src/main/java/com/google/devtools/build/lib/cmdline",
         "//src/main/java/com/google/devtools/build/lib/collect/nestedset",
         "//src/main/java/com/google/devtools/build/lib/concurrent",
@@ -36,6 +35,7 @@
         "//src/main/java/net/starlark/java/eval",
         "//src/main/protobuf:builtin_java_proto",
         "//third_party:apache_velocity",
+        "//third_party:gson",
         "//third_party:guava",
         "//third_party:jsr305",
     ],
@@ -73,7 +73,6 @@
         "//src/main/java/com/google/devtools/common/options",
         "//src/main/java/net/starlark/java/annot",
         "//src/main/java/net/starlark/java/eval",
-        "//src/main/java/net/starlark/java/syntax",
         "//src/main/protobuf:builtin_java_proto",
         "//third_party:guava",
     ],
@@ -86,3 +85,8 @@
         "templates/**/*.vm",
     ]),
 )
+
+filegroup(
+    name = "bazel_link_map",
+    srcs = ["bazel_link_map.json"],
+)
diff --git a/src/main/java/com/google/devtools/build/docgen/BuildDocCollector.java b/src/main/java/com/google/devtools/build/docgen/BuildDocCollector.java
index a7438ad..cb4008b 100644
--- a/src/main/java/com/google/devtools/build/docgen/BuildDocCollector.java
+++ b/src/main/java/com/google/devtools/build/docgen/BuildDocCollector.java
@@ -44,13 +44,15 @@
 public class BuildDocCollector {
   private static final Splitter SHARP_SPLITTER = Splitter.on('#').limit(2).trimResults();
 
-  private final String productName;
+  private final RuleLinkExpander linkExpander;
   private final ConfiguredRuleClassProvider ruleClassProvider;
   private final boolean printMessages;
 
   public BuildDocCollector(
-      String productName, ConfiguredRuleClassProvider ruleClassProvider, boolean printMessages) {
-    this.productName = productName;
+      RuleLinkExpander linkExpander,
+      ConfiguredRuleClassProvider ruleClassProvider,
+      boolean printMessages) {
+    this.linkExpander = linkExpander;
     this.ruleClassProvider = ruleClassProvider;
     this.printMessages = printMessages;
   }
@@ -90,18 +92,16 @@
    * <p>In the Map's values, all references pointing to other rules, rule attributes, and general
    * documentation (e.g. common definitions, make variables, etc.) are expanded into hyperlinks. The
    * links generated follow either the multi-page or single-page Build Encyclopedia model depending
-   * on the mode set for the provided {@link RuleLinkExpander}.
+   * on the mode set for the {@link RuleLinkExpander} that was passed to the constructor.
    *
    * @param inputDirs list of directories to scan for documentation
    * @param denyList specify an optional denylist file that list some rules that should not be
    *     listed in the output.
-   * @param expander The RuleLinkExpander, which is used for expanding links in the rule doc.
    * @throws BuildEncyclopediaDocException
    * @throws IOException
    * @return Map of rule class to rule documentation.
    */
-  public Map<String, RuleDocumentation> collect(
-      List<String> inputDirs, String denyList, RuleLinkExpander expander)
+  public Map<String, RuleDocumentation> collect(List<String> inputDirs, String denyList)
       throws BuildEncyclopediaDocException, IOException {
     // Read the denyList file
     Set<String> denylistedRules = readDenyList(denyList);
@@ -141,38 +141,14 @@
     }
 
     processAttributeDocs(ruleDocEntries.values(), attributeDocEntries);
-    expander.addIndex(buildRuleIndex(ruleDocEntries.values()));
+    linkExpander.addIndex(buildRuleIndex(ruleDocEntries.values()));
     for (RuleDocumentation rule : ruleDocEntries.values()) {
-      rule.setRuleLinkExpander(expander);
+      rule.setRuleLinkExpander(linkExpander);
     }
     return ruleDocEntries;
   }
 
   /**
-   * Creates a map of rule names (keys) to rule documentation (values).
-   *
-   * <p>This method crawls the specified input directories for rule class definitions (as Java
-   * source files) which contain the rules' and attributes' definitions as comments in a specific
-   * format. The keys in the returned Map correspond to these rule classes.
-   *
-   * <p>In the Map's values, all references pointing to other rules, rule attributes, and general
-   * documentation (e.g. common definitions, make variables, etc.) are expanded into hyperlinks. The
-   * links generated follow the multi-page Build Encyclopedia model (one page per rule class.).
-   *
-   * @param inputDirs list of directories to scan for documentation
-   * @param denyList specify an optional denylist file that list some rules that should not be
-   *     listed in the output.
-   * @throws BuildEncyclopediaDocException
-   * @throws IOException
-   * @return Map of rule class to rule documentation.
-   */
-  public Map<String, RuleDocumentation> collect(List<String> inputDirs, String denyList)
-      throws BuildEncyclopediaDocException, IOException {
-    RuleLinkExpander expander = new RuleLinkExpander(productName, /* singlePage */ false);
-    return collect(inputDirs, denyList, expander);
-  }
-
-  /**
    * Generates an index mapping rule name to its normalized rule family name.
    */
   private Map<String, String> buildRuleIndex(Iterable<RuleDocumentation> rules) {
diff --git a/src/main/java/com/google/devtools/build/docgen/BuildEncyclopediaGenerator.java b/src/main/java/com/google/devtools/build/docgen/BuildEncyclopediaGenerator.java
index 4e5413c..c4a1dcb 100644
--- a/src/main/java/com/google/devtools/build/docgen/BuildEncyclopediaGenerator.java
+++ b/src/main/java/com/google/devtools/build/docgen/BuildEncyclopediaGenerator.java
@@ -26,10 +26,10 @@
 public class BuildEncyclopediaGenerator {
   private static void printUsage(OptionsParser parser) {
     System.err.println(
-        "Usage: docgen_bin -n product_name -p rule_class_provider (-i input_dir)+\n"
+        "Usage: docgen_bin -m link_map_file -p rule_class_provider (-i input_dir)+\n"
             + "    [-o outputdir] [-b denylist] [-1] [-h]\n\n"
             + "Generates the Build Encyclopedia from embedded native rule documentation.\n"
-            + "The product name (-n), rule class provider (-p) and at least one input_dir\n"
+            + "The link map file (-m), rule class provider (-p) and at least one input_dir\n"
             + "(-i) must be specified.\n");
     System.err.println(
         parser.describeOptionsWithDeprecatedCategories(
@@ -66,7 +66,7 @@
       Runtime.getRuntime().exit(0);
     }
 
-    if (options.productName.isEmpty()
+    if (options.linkMapPath.isEmpty()
         || options.inputDirs.isEmpty()
         || options.provider.isEmpty()) {
       printUsage(parser);
@@ -74,15 +74,18 @@
     }
 
     try {
+      DocLinkMap linkMap = DocLinkMap.createFromFile(options.linkMapPath);
+      RuleLinkExpander linkExpander = new RuleLinkExpander(options.singlePage, linkMap);
+
       BuildEncyclopediaProcessor processor = null;
       if (options.singlePage) {
         processor =
             new SinglePageBuildEncyclopediaProcessor(
-                options.productName, createRuleClassProvider(options.provider));
+                linkExpander, createRuleClassProvider(options.provider));
       } else {
         processor =
             new MultiPageBuildEncyclopediaProcessor(
-                options.productName, createRuleClassProvider(options.provider));
+                linkExpander, createRuleClassProvider(options.provider));
       }
       processor.generateDocumentation(options.inputDirs, options.outputDir, options.denylist);
     } catch (BuildEncyclopediaDocException e) {
diff --git a/src/main/java/com/google/devtools/build/docgen/BuildEncyclopediaOptions.java b/src/main/java/com/google/devtools/build/docgen/BuildEncyclopediaOptions.java
index 0cadb48..eeeea50 100644
--- a/src/main/java/com/google/devtools/build/docgen/BuildEncyclopediaOptions.java
+++ b/src/main/java/com/google/devtools/build/docgen/BuildEncyclopediaOptions.java
@@ -24,14 +24,13 @@
  */
 public class BuildEncyclopediaOptions extends OptionsBase {
   @Option(
-    name = "product_name",
-    abbrev = 'n',
-    defaultValue = "",
-    documentationCategory = OptionDocumentationCategory.UNCATEGORIZED,
-    effectTags = {OptionEffectTag.UNKNOWN},
-    help = "Name of the product to put in the documentation"
-  )
-  public String productName;
+      name = "link_map_path",
+      abbrev = 'm',
+      defaultValue = "",
+      documentationCategory = OptionDocumentationCategory.UNCATEGORIZED,
+      effectTags = {OptionEffectTag.UNKNOWN},
+      help = "Path to a JSON file that specifies link mappings (page name to URL).")
+  public String linkMapPath;
 
   @Option(
       name = "input_dir",
diff --git a/src/main/java/com/google/devtools/build/docgen/BuildEncyclopediaProcessor.java b/src/main/java/com/google/devtools/build/docgen/BuildEncyclopediaProcessor.java
index a5ef7e9..05d3751 100644
--- a/src/main/java/com/google/devtools/build/docgen/BuildEncyclopediaProcessor.java
+++ b/src/main/java/com/google/devtools/build/docgen/BuildEncyclopediaProcessor.java
@@ -45,8 +45,8 @@
     }
   };
 
-  /** Name of the product to insert into the documentation. */
-  protected final String productName;
+  /** Class that expand links to the BE. */
+  protected final RuleLinkExpander linkExpander;
 
   /** Rule class provider from which to extract the rule class hierarchy and attributes. */
   protected final ConfiguredRuleClassProvider ruleClassProvider;
@@ -56,8 +56,8 @@
    * rule class hierarchy and attribute checking.
    */
   public BuildEncyclopediaProcessor(
-      String productName, ConfiguredRuleClassProvider ruleClassProvider) {
-    this.productName = productName;
+      RuleLinkExpander linkExpander, ConfiguredRuleClassProvider ruleClassProvider) {
+    this.linkExpander = linkExpander;
     this.ruleClassProvider = Preconditions.checkNotNull(ruleClassProvider);
   }
 
@@ -211,13 +211,12 @@
    * attributes can be expanded.
    *
    * @param attributes The map containing the RuleDocumentationAttributes, keyed by attribute name.
-   * @param expander The RuleLinkExpander to set in each of the RuleDocumentationAttributes.
    * @return The provided map of attributes.
    */
-  protected static Map<String, RuleDocumentationAttribute> expandCommonAttributes(
-      Map<String, RuleDocumentationAttribute> attributes, RuleLinkExpander expander) {
+  protected Map<String, RuleDocumentationAttribute> expandCommonAttributes(
+      Map<String, RuleDocumentationAttribute> attributes) {
     for (RuleDocumentationAttribute attribute : attributes.values()) {
-      attribute.setRuleLinkExpander(expander);
+      attribute.setRuleLinkExpander(linkExpander);
     }
     return attributes;
   }
diff --git a/src/main/java/com/google/devtools/build/docgen/DocLinkMap.java b/src/main/java/com/google/devtools/build/docgen/DocLinkMap.java
new file mode 100644
index 0000000..3311514
--- /dev/null
+++ b/src/main/java/com/google/devtools/build/docgen/DocLinkMap.java
@@ -0,0 +1,41 @@
+// Copyright 2022 The Bazel Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.google.devtools.build.docgen;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonSyntaxException;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.util.HashMap;
+import java.util.Map;
+
+/** Represents a link mapping that acts as input to {@link RuleLinkExpander}. */
+public class DocLinkMap {
+  final String beRoot;
+  final Map<String, String> values;
+
+  DocLinkMap(String beRoot, Map<String, String> values) {
+    this.beRoot = beRoot;
+    this.values = new HashMap<>(values);
+  }
+
+  public static DocLinkMap createFromFile(String filePath) {
+    try {
+      return new Gson().fromJson(Files.readString(Paths.get(filePath)), DocLinkMap.class);
+    } catch (IOException | JsonSyntaxException ex) {
+      throw new IllegalArgumentException("Failed to read link map from " + filePath, ex);
+    }
+  }
+}
diff --git a/src/main/java/com/google/devtools/build/docgen/DocgenConsts.java b/src/main/java/com/google/devtools/build/docgen/DocgenConsts.java
index d6d32a2..9d967dd 100644
--- a/src/main/java/com/google/devtools/build/docgen/DocgenConsts.java
+++ b/src/main/java/com/google/devtools/build/docgen/DocgenConsts.java
@@ -176,12 +176,10 @@
 
   // The following variables are not constants as they can be overridden from
   // StarlarkDocumentationProcessor#parseOptions
+  // Their purpose is to allow generated Starlark documentation to link into the build encyclopedia.
 
-  // Build Encyclopedia documentation root
-  public static String BeDocsRoot = "/versions/main/be";
-
-  // Documentation files extension
-  public static String documentationExtension = "html";
+  // Root directory of *narrative* Starlark documentation files such as rules.md
+  public static String starlarkDocsRoot = "/rules";
 
   static String toCommandLineFormat(String cmdDoc) {
     // Replace html <br> tags with line breaks
diff --git a/src/main/java/com/google/devtools/build/docgen/MultiPageBuildEncyclopediaProcessor.java b/src/main/java/com/google/devtools/build/docgen/MultiPageBuildEncyclopediaProcessor.java
index d930041..2943be5 100644
--- a/src/main/java/com/google/devtools/build/docgen/MultiPageBuildEncyclopediaProcessor.java
+++ b/src/main/java/com/google/devtools/build/docgen/MultiPageBuildEncyclopediaProcessor.java
@@ -25,8 +25,8 @@
  */
 public class MultiPageBuildEncyclopediaProcessor extends BuildEncyclopediaProcessor {
   public MultiPageBuildEncyclopediaProcessor(
-      String productName, ConfiguredRuleClassProvider ruleClassProvider) {
-    super(productName, ruleClassProvider);
+      RuleLinkExpander linkExpander, ConfiguredRuleClassProvider ruleClassProvider) {
+    super(linkExpander, ruleClassProvider);
   }
 
   /**
@@ -40,52 +40,43 @@
   @Override
   public void generateDocumentation(List<String> inputDirs, String outputDir, String denyList)
       throws BuildEncyclopediaDocException, IOException {
-    BuildDocCollector collector = new BuildDocCollector(productName, ruleClassProvider, false);
-    RuleLinkExpander expander = new RuleLinkExpander(productName, false);
-    Map<String, RuleDocumentation> ruleDocEntries =
-        collector.collect(inputDirs, denyList, expander);
+    BuildDocCollector collector = new BuildDocCollector(linkExpander, ruleClassProvider, false);
+    Map<String, RuleDocumentation> ruleDocEntries = collector.collect(inputDirs, denyList);
     warnAboutUndocumentedRules(
         Sets.difference(ruleClassProvider.getRuleClassMap().keySet(), ruleDocEntries.keySet()));
 
-    writeStaticDoc(outputDir, expander, "make-variables");
-    writeStaticDoc(outputDir, expander, "functions");
-    writeCommonDefinitionsPage(outputDir, expander);
+    writeStaticDoc(outputDir, "make-variables");
+    writeStaticDoc(outputDir, "functions");
+    writeCommonDefinitionsPage(outputDir);
 
-    writeRuleDocs(outputDir, expander, ruleDocEntries.values());
+    writeRuleDocs(outputDir, ruleDocEntries.values());
   }
 
-  private void writeStaticDoc(String outputDir, RuleLinkExpander expander, String name)
-      throws IOException {
+  private void writeStaticDoc(String outputDir, String name) throws IOException {
     // TODO(dzc): Consider splitting out the call to writePage so that this method only creates the
     // Page object and adding docgen tests that test the state of Page objects constructed by
     // this method, and similar methods in this class.
     Page page = TemplateEngine.newPage(DocgenConsts.BE_TEMPLATE_DIR + "/" + name + ".vm");
-    page.add("expander", expander);
+    page.add("expander", linkExpander);
     writePage(page, outputDir, name + ".html");
   }
 
-  private void writeCommonDefinitionsPage(String outputDir, RuleLinkExpander expander)
-      throws IOException {
+  private void writeCommonDefinitionsPage(String outputDir) throws IOException {
     Page page = TemplateEngine.newPage(DocgenConsts.COMMON_DEFINITIONS_TEMPLATE);
-    page.add("expander", expander);
-    page.add(
-        "typicalAttributes",
-        expandCommonAttributes(PredefinedAttributes.TYPICAL_ATTRIBUTES, expander));
-    page.add("commonAttributes",
-        expandCommonAttributes(PredefinedAttributes.COMMON_ATTRIBUTES, expander));
-    page.add("testAttributes",
-        expandCommonAttributes(PredefinedAttributes.TEST_ATTRIBUTES, expander));
-    page.add("binaryAttributes",
-        expandCommonAttributes(PredefinedAttributes.BINARY_ATTRIBUTES, expander));
+    page.add("expander", linkExpander);
+    page.add("typicalAttributes", expandCommonAttributes(PredefinedAttributes.TYPICAL_ATTRIBUTES));
+    page.add("commonAttributes", expandCommonAttributes(PredefinedAttributes.COMMON_ATTRIBUTES));
+    page.add("testAttributes", expandCommonAttributes(PredefinedAttributes.TEST_ATTRIBUTES));
+    page.add("binaryAttributes", expandCommonAttributes(PredefinedAttributes.BINARY_ATTRIBUTES));
     writePage(page, outputDir, "common-definitions.html");
   }
 
-  private void writeRuleDocs(String outputDir, RuleLinkExpander expander,
-      Iterable<RuleDocumentation> docEntries) throws BuildEncyclopediaDocException, IOException {
+  private void writeRuleDocs(String outputDir, Iterable<RuleDocumentation> docEntries)
+      throws BuildEncyclopediaDocException, IOException {
     RuleFamilies ruleFamilies = assembleRuleFamilies(docEntries);
 
     // Generate documentation.
-    writeOverviewPage(outputDir, expander, ruleFamilies.langSpecific, ruleFamilies.generic);
+    writeOverviewPage(outputDir, ruleFamilies.langSpecific, ruleFamilies.generic);
     writeBeNav(outputDir, ruleFamilies.all);
     for (RuleFamily ruleFamily : ruleFamilies.all) {
       if (ruleFamily.size() > 0) {
@@ -95,12 +86,11 @@
   }
 
   private void writeOverviewPage(String outputDir,
-      RuleLinkExpander expander,
       List<RuleFamily> langSpecificRuleFamilies,
       List<RuleFamily> genericRuleFamilies)
       throws BuildEncyclopediaDocException, IOException {
     Page page = TemplateEngine.newPage(DocgenConsts.OVERVIEW_TEMPLATE);
-    page.add("expander", expander);
+    page.add("expander", linkExpander);
     page.add("langSpecificRuleFamilies", langSpecificRuleFamilies);
     page.add("genericRuleFamilies", genericRuleFamilies);
     writePage(page, outputDir, "overview.html");
@@ -110,6 +100,7 @@
       throws BuildEncyclopediaDocException, IOException {
     Page page = TemplateEngine.newPage(DocgenConsts.RULES_TEMPLATE);
     page.add("ruleFamily", ruleFamily);
+    page.add("expander", linkExpander);
     writePage(page, outputDir, ruleFamily.getId() + ".html");
   }
 
diff --git a/src/main/java/com/google/devtools/build/docgen/ProtoFileBuildEncyclopediaProcessor.java b/src/main/java/com/google/devtools/build/docgen/ProtoFileBuildEncyclopediaProcessor.java
index 03b776f..be66b95 100644
--- a/src/main/java/com/google/devtools/build/docgen/ProtoFileBuildEncyclopediaProcessor.java
+++ b/src/main/java/com/google/devtools/build/docgen/ProtoFileBuildEncyclopediaProcessor.java
@@ -25,8 +25,8 @@
   private ImmutableList<RuleDocumentation> nativeRules = null;
 
   public ProtoFileBuildEncyclopediaProcessor(
-      String productName, ConfiguredRuleClassProvider ruleClassProvider) {
-    super(productName, ruleClassProvider);
+      RuleLinkExpander linkExpander, ConfiguredRuleClassProvider ruleClassProvider) {
+    super(linkExpander, ruleClassProvider);
   }
 
   /*
@@ -36,10 +36,8 @@
   @Override
   public void generateDocumentation(List<String> inputDirs, String outputFile, String denyList)
       throws BuildEncyclopediaDocException, IOException {
-    BuildDocCollector collector = new BuildDocCollector(productName, ruleClassProvider, false);
-    RuleLinkExpander expander = new RuleLinkExpander(productName, true);
-    Map<String, RuleDocumentation> ruleDocEntries =
-        collector.collect(inputDirs, denyList, expander);
+    BuildDocCollector collector = new BuildDocCollector(linkExpander, ruleClassProvider, false);
+    Map<String, RuleDocumentation> ruleDocEntries = collector.collect(inputDirs, denyList);
     RuleFamilies ruleFamilies = assembleRuleFamilies(ruleDocEntries.values());
     ImmutableList.Builder<RuleDocumentation> ruleDocsBuilder = new ImmutableList.Builder<>();
 
diff --git a/src/main/java/com/google/devtools/build/docgen/RuleDocumentationAttribute.java b/src/main/java/com/google/devtools/build/docgen/RuleDocumentationAttribute.java
index 17194dd..7828508 100644
--- a/src/main/java/com/google/devtools/build/docgen/RuleDocumentationAttribute.java
+++ b/src/main/java/com/google/devtools/build/docgen/RuleDocumentationAttribute.java
@@ -48,17 +48,17 @@
           .put(Type.STRING_DICT, "Dictionary: String -> String")
           .put(Type.STRING_LIST, "List of strings")
           .put(BuildType.TRISTATE, "Integer")
-          .put(BuildType.LABEL, "<a href=\"../build-ref.html#labels\">Label</a>")
-          .put(BuildType.LABEL_LIST, "List of <a href=\"../build-ref.html#labels\">labels</a>")
+          .put(BuildType.LABEL, "<a href=\"${link build-ref#labels}\">Label</a>")
+          .put(BuildType.LABEL_LIST, "List of <a href=\"${link build-ref#labels}\">labels</a>")
           .put(
               BuildType.LABEL_DICT_UNARY,
-              "Dictionary mapping strings to <a href=\"../build-ref.html#labels\">labels</a>")
+              "Dictionary mapping strings to <a href=\"${link build-ref#labels}\">labels</a>")
           .put(BuildType.LICENSE, "Licence type")
-          .put(BuildType.NODEP_LABEL, "<a href=\"../build-ref.html#name\">Name</a>")
-          .put(BuildType.NODEP_LABEL_LIST, "List of <a href=\"../build-ref.html#name\">names</a>")
-          .put(BuildType.OUTPUT, "<a href=\"../build-ref.html#filename\">Filename</a>")
+          .put(BuildType.NODEP_LABEL, "<a href=\"${link build-ref#name}\">Name</a>")
+          .put(BuildType.NODEP_LABEL_LIST, "List of <a href=\"${link build-ref#name}\">names</a>")
+          .put(BuildType.OUTPUT, "<a href=\"${link build-ref#filename}\">Filename</a>")
           .put(
-              BuildType.OUTPUT_LIST, "List of <a href=\"../build-ref.html#filename\">filenames</a>")
+              BuildType.OUTPUT_LIST, "List of <a href=\"${link build-ref#filename}\">filenames</a>")
           .buildOrThrow();
 
   private final Class<? extends RuleDefinition> definitionClass;
@@ -150,15 +150,18 @@
    * Returns the html documentation of the rule attribute.
    */
   public String getHtmlDocumentation() throws BuildEncyclopediaDocException {
-    String expandedHtmlDoc = htmlDocumentation;
-    if (linkExpander != null) {
-      try {
-        expandedHtmlDoc = linkExpander.expand(expandedHtmlDoc);
-      } catch (IllegalArgumentException e) {
-        throw new BuildEncyclopediaDocException(fileName, startLineCnt, e.getMessage());
-      }
+    return tryExpand(htmlDocumentation);
+  }
+
+  public String tryExpand(String html) throws BuildEncyclopediaDocException {
+    if (linkExpander == null) {
+      return html;
     }
-    return expandedHtmlDoc;
+    try {
+      return linkExpander.expand(html);
+    } catch (IllegalArgumentException e) {
+      throw new BuildEncyclopediaDocException(fileName, startLineCnt, e.getMessage());
+    }
   }
 
   /** Returns whether the param is required or optional. */
@@ -182,7 +185,7 @@
     } else if (value instanceof String && !((String) value).isEmpty()) {
       return prefix + "\"" + value + "\"";
     } else if (value instanceof TriState) {
-      switch((TriState) value) {
+      switch ((TriState) value) {
         case AUTO:
           return prefix + "-1";
         case NO:
@@ -196,16 +199,15 @@
     return "";
   }
 
-  /**
-   * Returns a string containing the synopsis for this attribute.
-   */
-  public String getSynopsis() {
+  /** Returns a string containing the synopsis for this attribute. */
+  public String getSynopsis() throws BuildEncyclopediaDocException {
     if (attribute == null) {
       return "";
     }
+    String rawType = TYPE_DESC.get(attribute.getType());
     StringBuilder sb =
         new StringBuilder()
-            .append(TYPE_DESC.get(attribute.getType()))
+            .append(rawType == null ? null : tryExpand(rawType))
             .append("; ")
             .append(attribute.isMandatory() ? "required" : "optional")
             .append(
diff --git a/src/main/java/com/google/devtools/build/docgen/RuleLinkExpander.java b/src/main/java/com/google/devtools/build/docgen/RuleLinkExpander.java
index aacfe55..bce5046 100644
--- a/src/main/java/com/google/devtools/build/docgen/RuleLinkExpander.java
+++ b/src/main/java/com/google/devtools/build/docgen/RuleLinkExpander.java
@@ -14,9 +14,8 @@
 package com.google.devtools.build.docgen;
 
 import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.ImmutableSet;
+import java.nio.file.Paths;
 import java.util.HashMap;
-import java.util.Locale;
 import java.util.Map;
 import java.util.regex.Matcher;
 
@@ -26,14 +25,14 @@
  * <p>See {@link com.google.devtools.build.docgen.DocgenConsts.BLAZE_RULE_LINK} for the regex used
  * to match link references.
  */
+// TODO(fwe): rename to LinkExpander
+// TODO(fwe): prefix rule links with BE root?
 public class RuleLinkExpander {
   private static final String EXAMPLES_SUFFIX = "_examples";
   private static final String ARGS_SUFFIX = "_args";
   private static final String IMPLICIT_OUTPUTS_SUFFIX = "_implicit_outputs";
   private static final String FUNCTIONS_PAGE = "functions";
 
-  private static final ImmutableSet<String> STATIC_PAGES =
-      ImmutableSet.<String>of("common-definitions", "make-variables");
   private static final ImmutableMap<String, String> FUNCTIONS =
       ImmutableMap.<String, String>builder()
           .put("load", FUNCTIONS_PAGE)
@@ -47,21 +46,25 @@
           .put("select", FUNCTIONS_PAGE)
           .buildOrThrow();
 
-  private final String productName;
+  private final DocLinkMap linkMap;
   private final Map<String, String> ruleIndex = new HashMap<>();
   private final boolean singlePage;
 
-  RuleLinkExpander(String productName, Map<String, String> ruleIndex, boolean singlePage) {
-    this.productName = productName;
+  RuleLinkExpander(Map<String, String> ruleIndex, boolean singlePage, DocLinkMap linkMap) {
     this.ruleIndex.putAll(ruleIndex);
     this.ruleIndex.putAll(FUNCTIONS);
     this.singlePage = singlePage;
+    this.linkMap = linkMap;
   }
 
-  RuleLinkExpander(String productName, boolean singlePage) {
-    this.productName = productName;
+  RuleLinkExpander(boolean singlePage, DocLinkMap linkMap) {
     this.ruleIndex.putAll(FUNCTIONS);
     this.singlePage = singlePage;
+    this.linkMap = linkMap;
+  }
+
+  public String beRoot() {
+    return linkMap.beRoot;
   }
 
   public void addIndex(Map<String, String> ruleIndex) {
@@ -70,9 +73,10 @@
 
   private void appendRuleLink(Matcher matcher, StringBuffer sb, String ruleName, String ref) {
     String ruleFamily = ruleIndex.get(ruleName);
-    String link = singlePage
-        ?  "#" + ref
-        : ruleFamily + ".html#" + ref;
+    String link =
+        singlePage
+            ? "#" + ref
+            : Paths.get(linkMap.beRoot, String.format("%s.html#%s", ruleFamily, ref)).toString();
     matcher.appendReplacement(sb, Matcher.quoteReplacement(link));
   }
 
@@ -119,10 +123,9 @@
 
       // The name is not the name of a rule but is the name of a static page, such as
       // common-definitions. Generate a link to that page.
-      if (STATIC_PAGES.contains(name)) {
-        String link = singlePage
-            ? "#" + name
-            : name + ".html";
+      String mapping = linkMap.values.get(name);
+      if (mapping != null) {
+        String link = singlePage ? "#" + name : mapping;
         // For referencing headings on a static page, use the following syntax:
         // ${link static_page_name#heading_name}, example: ${link make-variables#gendir}
         String pageHeading = matcher.group(4);
@@ -151,6 +154,8 @@
     Matcher matcher = DocgenConsts.BLAZE_RULE_HEADING_LINK.matcher(htmlDoc);
     StringBuffer sb = new StringBuffer(htmlDoc.length());
     while (matcher.find()) {
+      // The first capture group matches the entire reference, e.g. "cc_library#some_heading".
+      String ref = matcher.group(1);
       // The second capture group only matches the rule name, e.g. "cc_library" in
       // "cc_library#some_heading"
       String name = matcher.group(2);
@@ -173,17 +178,32 @@
       // The name is of a static page, such as common.definitions. Generate a link to that page, and
       // append the page heading. For example, ${link common-definitions#label-expansion} expands to
       // common-definitions.html#label-expansion.
-      if (STATIC_PAGES.contains(name)) {
-        String link = singlePage
-            ? "#" + heading
-            : name + ".html#" + heading;
-        matcher.appendReplacement(sb, Matcher.quoteReplacement(link));
-        continue;
-      }
 
-      // Links to the user manual are handled specially. Meh.
-      if ("user-manual".equals(name)) {
-        String link = productName.toLowerCase(Locale.US) + "-" + name + ".html#" + heading;
+      // We need to search for the entire match first since some documentation files have a 1:n
+      // relation between Blaze and Bazel. Example: build-ref#labels points to build-ref.html#labels
+      // for Blaze, but to /concepts/labels for Bazel. However, we have to consider whether a single
+      // heading or the entire page has to be redirected.
+
+      // Not-null if page#heading has a mapping (other headings on the page are unaffected):
+      String headingMapping = linkMap.values.get(ref);
+      // Not-null if the entire page has a mapping, i.e. all headings should be redirected:
+      String pageMapping = linkMap.values.get(name);
+
+      if (headingMapping != null || pageMapping != null) {
+        String link;
+        if (singlePage) {
+          // Special case: For the single-page BE we don't use the value of the mapping, we just
+          // need to know that there is one (since that means `name` is a legitimate BE page).
+          link = "#" + heading;
+        } else if (headingMapping != null) {
+          // Multi-page BE where page#heading has to be redirected.
+          link = headingMapping;
+        } else { // pageMapping != null
+          // Multi-page BE where the entire page has to be forwarded (but the new page has
+          // identical headings).
+          link = String.format("%s#%s", pageMapping, heading);
+        }
+
         matcher.appendReplacement(sb, Matcher.quoteReplacement(link));
         continue;
       }
diff --git a/src/main/java/com/google/devtools/build/docgen/SinglePageBuildEncyclopediaProcessor.java b/src/main/java/com/google/devtools/build/docgen/SinglePageBuildEncyclopediaProcessor.java
index 346cc33..0e6bc2d 100644
--- a/src/main/java/com/google/devtools/build/docgen/SinglePageBuildEncyclopediaProcessor.java
+++ b/src/main/java/com/google/devtools/build/docgen/SinglePageBuildEncyclopediaProcessor.java
@@ -25,8 +25,8 @@
  */
 public class SinglePageBuildEncyclopediaProcessor extends BuildEncyclopediaProcessor {
   public SinglePageBuildEncyclopediaProcessor(
-      String productName, ConfiguredRuleClassProvider ruleClassProvider) {
-    super(productName, ruleClassProvider);
+      RuleLinkExpander linkExpander, ConfiguredRuleClassProvider ruleClassProvider) {
+    super(linkExpander, ruleClassProvider);
   }
 
   /**
@@ -40,10 +40,8 @@
   @Override
   public void generateDocumentation(List<String> inputDirs, String outputDir, String denyList)
       throws BuildEncyclopediaDocException, IOException {
-    BuildDocCollector collector = new BuildDocCollector(productName, ruleClassProvider, false);
-    RuleLinkExpander expander = new RuleLinkExpander(productName, true);
-    Map<String, RuleDocumentation> ruleDocEntries =
-        collector.collect(inputDirs, denyList, expander);
+    BuildDocCollector collector = new BuildDocCollector(linkExpander, ruleClassProvider, false);
+    Map<String, RuleDocumentation> ruleDocEntries = collector.collect(inputDirs, denyList);
     warnAboutUndocumentedRules(
         Sets.difference(ruleClassProvider.getRuleClassMap().keySet(), ruleDocEntries.keySet()));
     RuleFamilies ruleFamilies = assembleRuleFamilies(ruleDocEntries.values());
@@ -51,18 +49,13 @@
     Page page = TemplateEngine.newPage(DocgenConsts.SINGLE_BE_TEMPLATE);
 
     // Add the rule link expander.
-    page.add("expander", expander);
+    page.add("expander", linkExpander);
 
     // Populate variables for Common Definitions section.
-    page.add(
-        "typicalAttributes",
-        expandCommonAttributes(PredefinedAttributes.TYPICAL_ATTRIBUTES, expander));
-    page.add("commonAttributes",
-        expandCommonAttributes(PredefinedAttributes.COMMON_ATTRIBUTES, expander));
-    page.add("testAttributes",
-        expandCommonAttributes(PredefinedAttributes.TEST_ATTRIBUTES, expander));
-    page.add("binaryAttributes",
-        expandCommonAttributes(PredefinedAttributes.BINARY_ATTRIBUTES, expander));
+    page.add("typicalAttributes", expandCommonAttributes(PredefinedAttributes.TYPICAL_ATTRIBUTES));
+    page.add("commonAttributes", expandCommonAttributes(PredefinedAttributes.COMMON_ATTRIBUTES));
+    page.add("testAttributes", expandCommonAttributes(PredefinedAttributes.TEST_ATTRIBUTES));
+    page.add("binaryAttributes", expandCommonAttributes(PredefinedAttributes.BINARY_ATTRIBUTES));
 
     // Popualte variables for Overview section.
     page.add("langSpecificRuleFamilies", ruleFamilies.langSpecific);
diff --git a/src/main/java/com/google/devtools/build/docgen/StarlarkDocumentationCollector.java b/src/main/java/com/google/devtools/build/docgen/StarlarkDocumentationCollector.java
index 36aba17..3881f05 100644
--- a/src/main/java/com/google/devtools/build/docgen/StarlarkDocumentationCollector.java
+++ b/src/main/java/com/google/devtools/build/docgen/StarlarkDocumentationCollector.java
@@ -21,6 +21,7 @@
 import com.google.devtools.build.docgen.annot.StarlarkConstructor;
 import com.google.devtools.build.docgen.starlark.StarlarkBuiltinDoc;
 import com.google.devtools.build.docgen.starlark.StarlarkConstructorMethodDoc;
+import com.google.devtools.build.docgen.starlark.StarlarkDocExpander;
 import com.google.devtools.build.docgen.starlark.StarlarkJavaMethodDoc;
 import com.google.devtools.build.lib.util.Classpath;
 import com.google.devtools.build.lib.util.Classpath.ClassPathException;
@@ -53,14 +54,15 @@
   private static ImmutableMap<String, StarlarkBuiltinDoc> all;
 
   /** Applies {@link #collectModules} to all Bazel and Starlark classes. */
-  static synchronized ImmutableMap<String, StarlarkBuiltinDoc> getAllModules()
-      throws ClassPathException {
+  static synchronized ImmutableMap<String, StarlarkBuiltinDoc> getAllModules(
+      StarlarkDocExpander expander) throws ClassPathException {
     if (all == null) {
       all =
           collectModules(
               Iterables.concat(
                   Classpath.findClasses("com/google/devtools/build"), // Bazel
-                  Classpath.findClasses("net/starlark/java"))); // Starlark
+                  Classpath.findClasses("net/starlark/java")),
+              expander); // Starlark
     }
     return all;
   }
@@ -69,20 +71,22 @@
    * Collects the documentation for all Starlark modules comprised of the given classes and returns
    * a map from the name of each Starlark module to its documentation.
    */
-  static ImmutableMap<String, StarlarkBuiltinDoc> collectModules(Iterable<Class<?>> classes) {
+  static ImmutableMap<String, StarlarkBuiltinDoc> collectModules(
+      Iterable<Class<?>> classes, StarlarkDocExpander expander) {
     Map<String, StarlarkBuiltinDoc> modules = new TreeMap<>();
     // The top level module first.
     // (This is a special case of {@link StarlarkBuiltinDoc} as it has no object name).
     StarlarkBuiltin topLevelModule = getTopLevelModule();
     modules.put(
         topLevelModule.name(),
-        new StarlarkBuiltinDoc(topLevelModule, /*title=*/ "Globals", TopLevelModule.class));
+        new StarlarkBuiltinDoc(
+            topLevelModule, /*title=*/ "Globals", TopLevelModule.class, expander));
 
     // Creating module documentation is done in three passes.
     // 1. Add all classes/interfaces annotated with @StarlarkBuiltin with documented = true.
     for (Class<?> candidateClass : classes) {
       if (candidateClass.isAnnotationPresent(StarlarkBuiltin.class)) {
-        collectStarlarkModule(candidateClass, modules);
+        collectStarlarkModule(candidateClass, modules, expander);
       }
     }
 
@@ -97,7 +101,7 @@
     //      if (e.getValue() instanceof BuiltinFunction) {
     //        BuiltinFunction fn = (BuiltinFunction) e.getValue();
     //        topLevelModuleDoc.addMethod(
-    //          new StarlarkJavaMethodDoc("", fn.getJavaMethod(), fn.getAnnotation()));
+    //          new StarlarkJavaMethodDoc("", fn.getJavaMethod(), fn.getAnnotation(), expander));
     //      }
     //    }
     //
@@ -105,11 +109,11 @@
     //
     for (Class<?> candidateClass : classes) {
       if (candidateClass.isAnnotationPresent(StarlarkBuiltin.class)) {
-        collectModuleMethods(candidateClass, modules);
+        collectModuleMethods(candidateClass, modules, expander);
       }
       if (candidateClass.isAnnotationPresent(DocumentMethods.class)
           || candidateClass.getName().equals("net.starlark.java.eval.MethodLibrary")) {
-        collectDocumentedMethods(candidateClass, modules);
+        collectDocumentedMethods(candidateClass, modules, expander);
       }
     }
 
@@ -117,7 +121,7 @@
     for (Class<?> candidateClass : classes) {
       if (candidateClass.isAnnotationPresent(StarlarkBuiltin.class)
           || candidateClass.isAnnotationPresent(DocumentMethods.class)) {
-        collectConstructorMethods(candidateClass, modules);
+        collectConstructorMethods(candidateClass, modules, expander);
       }
     }
 
@@ -137,7 +141,7 @@
    * moduleClass}, if it is a documented module.
    */
   private static void collectStarlarkModule(
-      Class<?> moduleClass, Map<String, StarlarkBuiltinDoc> modules) {
+      Class<?> moduleClass, Map<String, StarlarkBuiltinDoc> modules, StarlarkDocExpander expander) {
     if (moduleClass.equals(TopLevelModule.class)) {
       // The top level module doc is a special case and is handled separately.
       return;
@@ -151,7 +155,8 @@
       if (previousModuleDoc == null) {
         modules.put(
             moduleAnnotation.name(),
-            new StarlarkBuiltinDoc(moduleAnnotation, moduleAnnotation.name(), moduleClass));
+            new StarlarkBuiltinDoc(
+                moduleAnnotation, moduleAnnotation.name(), moduleClass, expander));
       } else {
         // Handle a strange corner-case: If moduleClass has a subclass which is also
         // annotated with {@link StarlarkBuiltin} with the same name, and also has the same
@@ -165,7 +170,7 @@
           modules.put(
               moduleAnnotation.name(),
               new StarlarkBuiltinDoc(
-                  moduleAnnotation, /*title=*/ moduleAnnotation.name(), moduleClass));
+                  moduleAnnotation, /*title=*/ moduleAnnotation.name(), moduleClass, expander));
         }
       }
     }
@@ -194,7 +199,7 @@
   }
 
   private static void collectModuleMethods(
-      Class<?> moduleClass, Map<String, StarlarkBuiltinDoc> modules) {
+      Class<?> moduleClass, Map<String, StarlarkBuiltinDoc> modules, StarlarkDocExpander expander) {
     StarlarkBuiltin moduleAnnotation =
         Preconditions.checkNotNull(moduleClass.getAnnotation(StarlarkBuiltin.class));
 
@@ -209,7 +214,8 @@
           // Methods with @StarlarkConstructor are added later.
           if (!entry.getKey().isAnnotationPresent(StarlarkConstructor.class)) {
             moduleDoc.addMethod(
-                new StarlarkJavaMethodDoc(moduleDoc.getName(), entry.getKey(), entry.getValue()));
+                new StarlarkJavaMethodDoc(
+                    moduleDoc.getName(), entry.getKey(), entry.getValue(), expander));
           }
         }
       }
@@ -230,7 +236,7 @@
    * each @StarlarkMethod method defined in the given @DocumentMethods class {@code moduleClass}.
    */
   private static void collectDocumentedMethods(
-      Class<?> moduleClass, Map<String, StarlarkBuiltinDoc> modules) {
+      Class<?> moduleClass, Map<String, StarlarkBuiltinDoc> modules, StarlarkDocExpander expander) {
     StarlarkBuiltinDoc topLevelModuleDoc = getTopLevelModuleDoc(modules);
 
     for (Map.Entry<Method, StarlarkMethod> entry :
@@ -238,12 +244,13 @@
       // Only add non-constructor global library methods. Constructors are added later.
       if (!entry.getKey().isAnnotationPresent(StarlarkConstructor.class)) {
         topLevelModuleDoc.addMethod(
-            new StarlarkJavaMethodDoc("", entry.getKey(), entry.getValue()));
+            new StarlarkJavaMethodDoc("", entry.getKey(), entry.getValue(), expander));
       }
     }
   }
 
-  private static void collectConstructor(Map<String, StarlarkBuiltinDoc> modules, Method method) {
+  private static void collectConstructor(
+      Map<String, StarlarkBuiltinDoc> modules, Method method, StarlarkDocExpander expander) {
     Preconditions.checkNotNull(method.getAnnotation(StarlarkConstructor.class));
 
     StarlarkBuiltin builtinType = StarlarkAnnotations.getStarlarkBuiltin(method.getReturnType());
@@ -255,7 +262,8 @@
     StarlarkMethod methodAnnot =
         Preconditions.checkNotNull(method.getAnnotation(StarlarkMethod.class));
     StarlarkBuiltinDoc doc = modules.get(builtinType.name());
-    doc.setConstructor(new StarlarkConstructorMethodDoc(builtinType.name(), method, methodAnnot));
+    doc.setConstructor(
+        new StarlarkConstructorMethodDoc(builtinType.name(), method, methodAnnot, expander));
   }
 
   /**
@@ -269,20 +277,20 @@
    * like a constructor method.)
    */
   private static void collectConstructorMethods(
-      Class<?> moduleClass, Map<String, StarlarkBuiltinDoc> modules) {
+      Class<?> moduleClass, Map<String, StarlarkBuiltinDoc> modules, StarlarkDocExpander expander) {
     Method selfCallConstructor = getSelfCallConstructorMethod(moduleClass);
     if (selfCallConstructor != null) {
-      collectConstructor(modules, selfCallConstructor);
+      collectConstructor(modules, selfCallConstructor, expander);
     }
 
     for (Method method : Starlark.getMethodAnnotations(moduleClass).keySet()) {
       if (method.isAnnotationPresent(StarlarkConstructor.class)) {
-        collectConstructor(modules, method);
+        collectConstructor(modules, method, expander);
       }
       Class<?> returnClass = method.getReturnType();
       Method returnClassConstructor = getSelfCallConstructorMethod(returnClass);
       if (returnClassConstructor != null) {
-        collectConstructor(modules, returnClassConstructor);
+        collectConstructor(modules, returnClassConstructor, expander);
       }
     }
   }
diff --git a/src/main/java/com/google/devtools/build/docgen/StarlarkDocumentationProcessor.java b/src/main/java/com/google/devtools/build/docgen/StarlarkDocumentationProcessor.java
index b66369a..5051b34 100644
--- a/src/main/java/com/google/devtools/build/docgen/StarlarkDocumentationProcessor.java
+++ b/src/main/java/com/google/devtools/build/docgen/StarlarkDocumentationProcessor.java
@@ -16,7 +16,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.devtools.build.docgen.annot.DocCategory;
 import com.google.devtools.build.docgen.starlark.StarlarkBuiltinDoc;
-import com.google.devtools.build.docgen.starlark.StarlarkDocUtils;
+import com.google.devtools.build.docgen.starlark.StarlarkDocExpander;
 import com.google.devtools.build.docgen.starlark.StarlarkMethodDoc;
 import com.google.devtools.build.lib.util.Classpath.ClassPathException;
 import java.io.File;
@@ -24,6 +24,7 @@
 import java.text.Collator;
 import java.util.ArrayList;
 import java.util.Collections;
+import java.util.HashMap;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Locale;
@@ -42,10 +43,24 @@
   /** Generates the Starlark documentation to the given output directory. */
   public static void generateDocumentation(String outputDir, String... args)
       throws IOException, ClassPathException {
-    parseOptions(args);
+    Map<String, String> options = parseOptions(args);
+
+    String docsRoot = options.get("--starlark_docs_root");
+    if (docsRoot != null) {
+      DocgenConsts.starlarkDocsRoot = docsRoot;
+    }
+
+    String linkMapPath = options.get("--link_map_path");
+    if (linkMapPath == null) {
+      throw new IllegalArgumentException("Required option '--link_map_path' is missing.");
+    }
+
+    DocLinkMap linkMap = DocLinkMap.createFromFile(linkMapPath);
+    StarlarkDocExpander expander =
+        new StarlarkDocExpander(new RuleLinkExpander(/*singlePage*/ false, linkMap));
 
     Map<String, StarlarkBuiltinDoc> modules =
-        new TreeMap<>(StarlarkDocumentationCollector.getAllModules());
+        new TreeMap<>(StarlarkDocumentationCollector.getAllModules(expander));
 
     // Generate the top level module first in the doc
     StarlarkBuiltinDoc topLevelModule =
@@ -71,10 +86,10 @@
     for (List<StarlarkBuiltinDoc> module : modulesByCategory.values()) {
       Collections.sort(module, (doc1, doc2) -> us.compare(doc1.getTitle(), doc2.getTitle()));
     }
-    writeCategoryPage(Category.CORE, outputDir, modulesByCategory);
-    writeCategoryPage(Category.CONFIGURATION_FRAGMENT, outputDir, modulesByCategory);
-    writeCategoryPage(Category.BUILTIN, outputDir, modulesByCategory);
-    writeCategoryPage(Category.PROVIDER, outputDir, modulesByCategory);
+    writeCategoryPage(Category.CORE, outputDir, modulesByCategory, expander);
+    writeCategoryPage(Category.CONFIGURATION_FRAGMENT, outputDir, modulesByCategory, expander);
+    writeCategoryPage(Category.BUILTIN, outputDir, modulesByCategory, expander);
+    writeCategoryPage(Category.PROVIDER, outputDir, modulesByCategory, expander);
     writeNavPage(outputDir, modulesByCategory.get(Category.TOP_LEVEL_TYPE));
 
     // In the code, there are two StarlarkModuleCategory instances that have no heading:
@@ -138,14 +153,17 @@
   }
 
   private static void writeCategoryPage(
-      Category category, String outputDir, Map<Category, List<StarlarkBuiltinDoc>> modules)
+      Category category,
+      String outputDir,
+      Map<Category, List<StarlarkBuiltinDoc>> modules,
+      StarlarkDocExpander expander)
       throws IOException {
     File starlarkDocPath =
         new File(String.format("%s/starlark-%s.html", outputDir, category.getTemplateIdentifier()));
     Page page = TemplateEngine.newPage(DocgenConsts.STARLARK_MODULE_CATEGORY_TEMPLATE);
     page.add("category", category);
     page.add("modules", modules.get(category));
-    page.add("description", StarlarkDocUtils.substituteVariables(category.description));
+    page.add("description", expander.expand(category.description));
     page.write(starlarkDocPath);
   }
 
@@ -175,15 +193,15 @@
     page.write(starlarkDocPath);
   }
 
-  private static void parseOptions(String... args) {
+  private static Map<String, String> parseOptions(String... args) {
+    Map<String, String> options = new HashMap<>();
     for (String arg : args) {
-      if (arg.startsWith("--be_root=")) {
-        DocgenConsts.BeDocsRoot = arg.split("--be_root=", 2)[1];
-      }
-      if (arg.startsWith("--doc_extension=")) {
-        DocgenConsts.documentationExtension = arg.split("--doc_extension=", 2)[1];
+      if (arg.startsWith("--")) {
+        String[] parts = arg.split("=", 2);
+        options.put(parts[0], parts.length > 1 ? parts[1] : null);
       }
     }
+    return options;
   }
 
   /**
@@ -202,8 +220,9 @@
 
     PROVIDER(
         "Providers",
-        "This section lists providers available on built-in rules. See the "
-            + "<a href='../rules.$DOC_EXT#providers'>Rules page</a> for more on providers."),
+        "This section lists providers available on built-in rules. See the <a"
+            + " href='$STARLARK_DOCS_ROOT/rules.html#providers'>Rules page</a> for more on"
+            + " providers."),
 
     BUILTIN("Built-in Types", "This section lists types of Starlark objects."),
 
diff --git a/src/main/java/com/google/devtools/build/docgen/SymbolFamilies.java b/src/main/java/com/google/devtools/build/docgen/SymbolFamilies.java
index 2dd8863..38e9bec 100644
--- a/src/main/java/com/google/devtools/build/docgen/SymbolFamilies.java
+++ b/src/main/java/com/google/devtools/build/docgen/SymbolFamilies.java
@@ -16,6 +16,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.devtools.build.docgen.starlark.StarlarkBuiltinDoc;
+import com.google.devtools.build.docgen.starlark.StarlarkDocExpander;
 import com.google.devtools.build.lib.analysis.ConfiguredRuleClassProvider;
 import com.google.devtools.build.lib.util.Classpath.ClassPathException;
 import com.google.devtools.build.skydoc.fakebuildapi.FakeStarlarkNativeModuleApi;
@@ -39,17 +40,18 @@
   private final ImmutableMap<String, Object> bzlGlobals;
 
   public SymbolFamilies(
-      String productName, String provider, List<String> inputDirs, String denyList)
+      StarlarkDocExpander expander, String provider, List<String> inputDirs, String denyList)
       throws NoSuchMethodException, ClassPathException, InvocationTargetException,
           IllegalAccessException, BuildEncyclopediaDocException, ClassNotFoundException,
           IOException {
     ConfiguredRuleClassProvider configuredRuleClassProvider = createRuleClassProvider(provider);
     this.nativeRules =
         ImmutableList.copyOf(
-            collectNativeRules(productName, configuredRuleClassProvider, inputDirs, denyList));
+            collectNativeRules(
+                expander.ruleExpander, configuredRuleClassProvider, inputDirs, denyList));
     this.globals = Starlark.UNIVERSE;
     this.bzlGlobals = collectBzlGlobals(configuredRuleClassProvider);
-    this.types = StarlarkDocumentationCollector.getAllModules();
+    this.types = StarlarkDocumentationCollector.getAllModules(expander);
   }
 
   /*
@@ -85,13 +87,13 @@
    * and in BZL files as methods of the native package.
    */
   private List<RuleDocumentation> collectNativeRules(
-      String productName,
+      RuleLinkExpander linkExpander,
       ConfiguredRuleClassProvider provider,
       List<String> inputDirs,
       String denyList)
       throws BuildEncyclopediaDocException, IOException {
     ProtoFileBuildEncyclopediaProcessor processor =
-        new ProtoFileBuildEncyclopediaProcessor(productName, provider);
+        new ProtoFileBuildEncyclopediaProcessor(linkExpander, provider);
     processor.generateDocumentation(inputDirs, "", denyList);
     return processor.getNativeRules();
   }
diff --git a/src/main/java/com/google/devtools/build/docgen/bazel_link_map.json b/src/main/java/com/google/devtools/build/docgen/bazel_link_map.json
new file mode 100644
index 0000000..e55dab9
--- /dev/null
+++ b/src/main/java/com/google/devtools/build/docgen/bazel_link_map.json
@@ -0,0 +1,25 @@
+{
+  "beRoot": "/reference/be",
+  "values":
+  {
+      "build-ref": "/concepts/build-ref",
+      "build-ref#labels": "/concepts/labels",
+      "build-ref#name": "/concepts/labels#target-names",
+      "build-ref#BUILD_files": "/concepts/build-files",
+      "build-ref#dependencies": "/concepts/dependencies",
+      "common-definitions": "/reference/be/common-definitions",
+      "config": "/rules/config",
+      "configurable-attributes": "/docs/configurable-attributes",
+      "exec-groups": "/reference/exec-groups",
+      "functions": "/reference/be/functions",
+      "guide": "/contribute/guide",
+      "make-variables": "/reference/be/make-variables",
+      "platforms": "/docs/platforms",
+      "protocol-buffer": "/reference/be/protocol-buffer",
+      "query": "/reference/query",
+      "test-encyclopedia": "/reference/test-encyclopedia",
+      "toolchains": "/docs/toolchains",
+      "user-manual": "/docs/user-manual",
+      "visibility": "/concepts/visibility"
+  }
+}
diff --git a/src/main/java/com/google/devtools/build/docgen/starlark/StarlarkBuiltinDoc.java b/src/main/java/com/google/devtools/build/docgen/starlark/StarlarkBuiltinDoc.java
index e6c3f3c..0045315 100644
--- a/src/main/java/com/google/devtools/build/docgen/starlark/StarlarkBuiltinDoc.java
+++ b/src/main/java/com/google/devtools/build/docgen/starlark/StarlarkBuiltinDoc.java
@@ -39,7 +39,9 @@
   private TreeMap<String, StarlarkMethodDoc> methodMap;
   @Nullable private StarlarkConstructorMethodDoc javaConstructor;
 
-  public StarlarkBuiltinDoc(StarlarkBuiltin module, String title, Class<?> classObject) {
+  public StarlarkBuiltinDoc(
+      StarlarkBuiltin module, String title, Class<?> classObject, StarlarkDocExpander expander) {
+    super(expander);
     this.module =
         Preconditions.checkNotNull(
             module, "Class has to be annotated with StarlarkBuiltin: %s", classObject);
@@ -55,8 +57,8 @@
   }
 
   @Override
-  public String getDocumentation() {
-    return StarlarkDocUtils.substituteVariables(module.doc());
+  public String getRawDocumentation() {
+    return module.doc();
   }
 
   public String getTitle() {
diff --git a/src/main/java/com/google/devtools/build/docgen/starlark/StarlarkConstructorMethodDoc.java b/src/main/java/com/google/devtools/build/docgen/starlark/StarlarkConstructorMethodDoc.java
index 1865c55e..e96e3a0 100644
--- a/src/main/java/com/google/devtools/build/docgen/starlark/StarlarkConstructorMethodDoc.java
+++ b/src/main/java/com/google/devtools/build/docgen/starlark/StarlarkConstructorMethodDoc.java
@@ -30,7 +30,11 @@
   private final ImmutableList<StarlarkParamDoc> params;
 
   public StarlarkConstructorMethodDoc(
-      String fullyQualifiedName, Method method, StarlarkMethod callable) {
+      String fullyQualifiedName,
+      Method method,
+      StarlarkMethod callable,
+      StarlarkDocExpander expander) {
+    super(expander);
     this.fullyQualifiedName = fullyQualifiedName;
     this.method = method;
     this.callable = callable;
@@ -39,7 +43,8 @@
             this,
             withoutSelfParam(callable, method),
             callable.extraPositionals(),
-            callable.extraKeywords());
+            callable.extraKeywords(),
+            expander);
   }
 
   @Override
@@ -58,8 +63,8 @@
   }
 
   @Override
-  public String getDocumentation() {
-    return StarlarkDocUtils.substituteVariables(callable.doc());
+  public String getRawDocumentation() {
+    return callable.doc();
   }
 
   @Override
diff --git a/src/main/java/com/google/devtools/build/docgen/starlark/StarlarkDoc.java b/src/main/java/com/google/devtools/build/docgen/starlark/StarlarkDoc.java
index 6da233b..582a0da 100644
--- a/src/main/java/com/google/devtools/build/docgen/starlark/StarlarkDoc.java
+++ b/src/main/java/com/google/devtools/build/docgen/starlark/StarlarkDoc.java
@@ -32,15 +32,30 @@
 abstract class StarlarkDoc {
   protected static final String TOP_LEVEL_ID = "globals";
 
+  private final StarlarkDocExpander expander;
+
+  protected StarlarkDoc(StarlarkDocExpander expander) {
+    this.expander = expander;
+  }
+
   /**
    * Returns a string containing the name of the entity being documented.
    */
   public abstract String getName();
 
   /**
-   * Returns a string containing the formatted HTML documentation of the entity being documented.
+   * Returns a string containing the formatted HTML documentation of the entity being documented
+   * (without any variables).
    */
-  public abstract String getDocumentation();
+  public String getDocumentation() {
+    return expander.expand(getRawDocumentation());
+  }
+
+  /**
+   * Returns the HTML documentation of the entity being documented, which potentially contains
+   * variables and unresolved links.
+   */
+  public abstract String getRawDocumentation();
 
   protected String getTypeAnchor(Class<?> returnType, Class<?> generic1) {
     return getTypeAnchor(returnType) + " of " + getTypeAnchor(generic1) + "s";
diff --git a/src/main/java/com/google/devtools/build/docgen/starlark/StarlarkDocExpander.java b/src/main/java/com/google/devtools/build/docgen/starlark/StarlarkDocExpander.java
new file mode 100644
index 0000000..973f65c
--- /dev/null
+++ b/src/main/java/com/google/devtools/build/docgen/starlark/StarlarkDocExpander.java
@@ -0,0 +1,32 @@
+// Copyright 2022 The Bazel Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.devtools.build.docgen.starlark;
+
+import com.google.devtools.build.docgen.RuleLinkExpander;
+
+/** A utility class for replacing variables in documentation strings with their actual values. */
+public class StarlarkDocExpander {
+
+  public final RuleLinkExpander ruleExpander;
+
+  public StarlarkDocExpander(RuleLinkExpander ruleExpander) {
+    this.ruleExpander = ruleExpander;
+  }
+
+  public String expand(String docString) {
+    return ruleExpander.expand(
+        StarlarkDocUtils.substituteVariables(docString, ruleExpander.beRoot()));
+  }
+}
diff --git a/src/main/java/com/google/devtools/build/docgen/starlark/StarlarkDocUtils.java b/src/main/java/com/google/devtools/build/docgen/starlark/StarlarkDocUtils.java
index e24993c..61a55d6 100644
--- a/src/main/java/com/google/devtools/build/docgen/starlark/StarlarkDocUtils.java
+++ b/src/main/java/com/google/devtools/build/docgen/starlark/StarlarkDocUtils.java
@@ -27,10 +27,11 @@
    *
    * @return a string with substituted variables
    */
-  public static String substituteVariables(String documentation) {
+  public static String substituteVariables(String documentation, String beRoot) {
+    // TODO(b/193923321): Get rid of $STARLARK_DOCS_ROOT and of this entire class, eventually.
     return documentation
-        .replace("$BE_ROOT", DocgenConsts.BeDocsRoot)
-        .replace("$DOC_EXT", DocgenConsts.documentationExtension);
+        .replace("$STARLARK_DOCS_ROOT", DocgenConsts.starlarkDocsRoot)
+        .replace("$BE_ROOT", beRoot);
   }
 
   /**
@@ -41,18 +42,19 @@
       StarlarkMethodDoc methodDoc,
       Param[] userSuppliedParams,
       Param extraPositionals,
-      Param extraKeywords) {
+      Param extraKeywords,
+      StarlarkDocExpander expander) {
     ImmutableList.Builder<StarlarkParamDoc> paramsBuilder = ImmutableList.builder();
     for (Param param : userSuppliedParams) {
       if (param.documented()) {
-        paramsBuilder.add(new StarlarkParamDoc(methodDoc, param));
+        paramsBuilder.add(new StarlarkParamDoc(methodDoc, param, expander));
       }
     }
     if (!extraPositionals.name().isEmpty()) {
-      paramsBuilder.add(new StarlarkParamDoc(methodDoc, extraPositionals));
+      paramsBuilder.add(new StarlarkParamDoc(methodDoc, extraPositionals, expander));
     }
     if (!extraKeywords.name().isEmpty()) {
-      paramsBuilder.add(new StarlarkParamDoc(methodDoc, extraKeywords));
+      paramsBuilder.add(new StarlarkParamDoc(methodDoc, extraKeywords, expander));
     }
     return paramsBuilder.build();
   }
diff --git a/src/main/java/com/google/devtools/build/docgen/starlark/StarlarkJavaMethodDoc.java b/src/main/java/com/google/devtools/build/docgen/starlark/StarlarkJavaMethodDoc.java
index ae9eaf0..15ae698 100644
--- a/src/main/java/com/google/devtools/build/docgen/starlark/StarlarkJavaMethodDoc.java
+++ b/src/main/java/com/google/devtools/build/docgen/starlark/StarlarkJavaMethodDoc.java
@@ -33,7 +33,9 @@
 
   private boolean isOverloaded;
 
-  public StarlarkJavaMethodDoc(String moduleName, Method method, StarlarkMethod callable) {
+  public StarlarkJavaMethodDoc(
+      String moduleName, Method method, StarlarkMethod callable, StarlarkDocExpander expander) {
+    super(expander);
     this.moduleName = moduleName;
     this.name = callable.name();
     this.method = method;
@@ -43,7 +45,8 @@
             this,
             withoutSelfParam(callable, method),
             callable.extraPositionals(),
-            callable.extraKeywords());
+            callable.extraKeywords(),
+            expander);
   }
 
   @Override
@@ -87,7 +90,7 @@
   }
 
   @Override
-  public String getDocumentation() {
+  public String getRawDocumentation() {
     String prefixWarning = "";
     if (!callable.enableOnlyWithFlag().isEmpty()) {
       prefixWarning =
@@ -105,7 +108,7 @@
               + "</code>. Use this flag "
               + "to verify your code is compatible with its imminent removal. <br>";
     }
-    return prefixWarning + StarlarkDocUtils.substituteVariables(callable.doc());
+    return prefixWarning + callable.doc();
   }
 
   @Override
diff --git a/src/main/java/com/google/devtools/build/docgen/starlark/StarlarkMethodDoc.java b/src/main/java/com/google/devtools/build/docgen/starlark/StarlarkMethodDoc.java
index 1851367..768089e 100644
--- a/src/main/java/com/google/devtools/build/docgen/starlark/StarlarkMethodDoc.java
+++ b/src/main/java/com/google/devtools/build/docgen/starlark/StarlarkMethodDoc.java
@@ -24,6 +24,11 @@
 
 /** An abstract class containing documentation for a Starlark method. */
 public abstract class StarlarkMethodDoc extends StarlarkDoc {
+
+  public StarlarkMethodDoc(StarlarkDocExpander expander) {
+    super(expander);
+  }
+
   /** Returns whether the Starlark method is documented. */
   public abstract boolean documented();
 
diff --git a/src/main/java/com/google/devtools/build/docgen/starlark/StarlarkParamDoc.java b/src/main/java/com/google/devtools/build/docgen/starlark/StarlarkParamDoc.java
index 2eafd05..8cdefba 100644
--- a/src/main/java/com/google/devtools/build/docgen/starlark/StarlarkParamDoc.java
+++ b/src/main/java/com/google/devtools/build/docgen/starlark/StarlarkParamDoc.java
@@ -21,7 +21,8 @@
   private StarlarkMethodDoc method;
   private Param param;
 
-  public StarlarkParamDoc(StarlarkMethodDoc method, Param param) {
+  public StarlarkParamDoc(StarlarkMethodDoc method, Param param, StarlarkDocExpander expander) {
+    super(expander);
     this.method = method;
     this.param = param;
   }
@@ -65,7 +66,7 @@
   }
 
   @Override
-  public String getDocumentation() {
+  public String getRawDocumentation() {
     String prefixWarning = "";
     if (!param.enableOnlyWithFlag().isEmpty()) {
       prefixWarning =
@@ -83,6 +84,6 @@
               + "</code>. Use this flag "
               + "to verify your code is compatible with its imminent removal. <br>";
     }
-    return prefixWarning + StarlarkDocUtils.substituteVariables(param.doc());
+    return prefixWarning + param.doc();
   }
 }
diff --git a/src/main/java/com/google/devtools/build/lib/BUILD b/src/main/java/com/google/devtools/build/lib/BUILD
index eafa7dc..11ccadd 100644
--- a/src/main/java/com/google/devtools/build/lib/BUILD
+++ b/src/main/java/com/google/devtools/build/lib/BUILD
@@ -430,12 +430,15 @@
 
 genrule(
     name = "gen_buildencyclopedia",
-    srcs = [":docs_embedded_in_sources"],
+    srcs = [
+        ":docs_embedded_in_sources",
+        "//src/main/java/com/google/devtools/build/docgen:bazel_link_map",
+    ],
     outs = ["build-encyclopedia.zip"],
     cmd = (
         "mkdir -p $(@D)/be && " +
         "$(location //src/main/java/com/google/devtools/build/docgen:docgen_bin)" +
-        " --product_name=bazel" +
+        " --link_map_path=$(location //src/main/java/com/google/devtools/build/docgen:bazel_link_map)" +
         " --provider=com.google.devtools.build.lib.bazel.rules.BazelRuleClassProvider" +
         " --input_dir=$$PWD/src/main/java/com/google/devtools/build/lib" +
         " --output_dir=$(@D)/be &&" +
@@ -476,9 +479,12 @@
 
 genrule(
     name = "gen_skylarklibrary",
+    srcs = ["//src/main/java/com/google/devtools/build/docgen:bazel_link_map"],
     outs = ["skylark-library.zip"],
     cmd = "mkdir -p $(@D)/skylark-lib &&" +
-          "$(location //src/main/java/com/google/devtools/build/docgen:skydoc_bin) $(@D)/skylark-lib" +
+          "$(location //src/main/java/com/google/devtools/build/docgen:skydoc_bin)" +
+          " $(@D)/skylark-lib" +
+          " --link_map_path=$(location //src/main/java/com/google/devtools/build/docgen:bazel_link_map)" +
           " && zip -qj $@ $(@D)/skylark-lib/*",
     tools = ["//src/main/java/com/google/devtools/build/docgen:skydoc_bin"],
     visibility = ["//site:__pkg__"],