Add a version indicator and selector to all docs pages

The header of each docs page now indicates the current version in bold and contains links to the same page for each other release with versioned docs.

The template language usage in this change is based on publicly available information: https://github.com/GoogleCloudPlatform/DataflowTemplates/blob/97f69c065e9a049f43a5676b633d181ed0ac8448/plugins/core-plugin/src/main/resources/site-template.md?plain=1

<img width="1281" alt="Screenshot 2024-06-13 at 12 29 57" src="https://github.com/bazelbuild/bazel/assets/4312191/64deebcc-2d0e-4a16-9490-eacae2e12561">

Closes #22725.

PiperOrigin-RevId: 644354576
Change-Id: I7c4e9208a79430d3c56c94674b70296a2a778a5f
diff --git a/scripts/docs/BUILD b/scripts/docs/BUILD
index 1870ad1..84073ab 100644
--- a/scripts/docs/BUILD
+++ b/scripts/docs/BUILD
@@ -7,6 +7,7 @@
     name = "gen_release_docs",
     srcs = [
         ":new_toc.yaml",
+        ":new_buttons.html",
         "//site/en:docs",
         "//src/main/java/com/google/devtools/build/lib:reference-docs.zip",
     ],
@@ -16,6 +17,7 @@
     cmd = "$(location :create_release_docs)" +
           " --version=" + BUILD_SCM_REV_CMD +
           " --toc_path=$(location :new_toc.yaml)" +
+          " --buttons_path=$(location :new_buttons.html)" +
           " --narrative_docs_path=$(location //site/en:docs)" +
           " --reference_docs_path=$(location //src/main/java/com/google/devtools/build/lib:reference-docs.zip)" +
           " --output_path=$(OUTS)",
@@ -30,12 +32,18 @@
     name = "gen_new_toc",
     srcs = [
         "//site/en:versions/_toc.yaml",
+        "//site/en:_buttons.html",
     ],
-    outs = ["new_toc.yaml"],
+    outs = [
+        "new_toc.yaml",
+        "new_buttons.html",
+    ],
     cmd = "$(location //src/main/java/com/google/devtools/build/docgen/release:toc_updater)" +
           " -i $(location //site/en:versions/_toc.yaml)" +
-          " -o $(OUTS)" +
-          " -v " + BUILD_SCM_REV_CMD,
+          " -o $(location new_toc.yaml)" +
+          " -v " + BUILD_SCM_REV_CMD +
+          " --version_indicator_input=$(location //site/en:_buttons.html)" +
+          " --version_indicator_output=$(location new_buttons.html)",
     stamp = 1,
     tools = [
         "//src/main/java/com/google/devtools/build/docgen/release:toc_updater",
diff --git a/scripts/docs/create_release_docs.py b/scripts/docs/create_release_docs.py
index 3aa1c23..61dc28e 100644
--- a/scripts/docs/create_release_docs.py
+++ b/scripts/docs/create_release_docs.py
@@ -35,6 +35,11 @@
     "Path to the _toc.yaml file that contains the table of contents for the versions menu.",
 )
 flags.DEFINE_string(
+    "buttons_path",
+    None,
+    "Path to the _buttons.html file that contains the version indicator.",
+)
+flags.DEFINE_string(
     "narrative_docs_path",
     None,
     "Path of the archive (zip or tar) that contains the narrative documentation.",
@@ -70,14 +75,17 @@
   exit(1)
 
 
-def create_docs_tree(version, toc_path, narrative_docs_path,
-                     reference_docs_path):
+def create_docs_tree(
+    version, toc_path, buttons_path, narrative_docs_path, reference_docs_path
+):
   """Creates a directory tree containing the docs for the Bazel version.
 
   Args:
     version: Version of this Bazel release.
     toc_path: Absolute path to the _toc.yaml file that lists the most recent
       Bazel versions.
+    buttons_path: Absolute path of the _buttons.html file that contains the
+      version indicator.
     narrative_docs_path: Absolute path of an archive that contains the narrative
       documentation (can be .zip or .tar).
     reference_docs_path: Absolute path of an archive that contains the reference
@@ -101,7 +109,11 @@
   try_extract(narrative_docs_path, release_dir)
   try_extract(reference_docs_path, release_dir)
 
-  return root_dir, toc_dest_path
+  buttons_dest_path = os.path.join(release_dir, "_buttons.html")
+  os.remove(buttons_dest_path)
+  shutil.copyfile(buttons_path, buttons_dest_path)
+
+  return root_dir, toc_dest_path, release_dir
 
 
 def try_extract(archive_path, output_dir):
@@ -128,7 +140,7 @@
     archive.extractall(output_dir)
 
 
-def build_archive(version, root_dir, toc_path, output_path):
+def build_archive(version, root_dir, toc_path, output_path, release_dir):
   """Builds a documentation archive for the given Bazel release.
 
   This function reads all documentation files from the tree rooted in root_dir,
@@ -141,24 +153,27 @@
       tree.
     toc_path: Absolute path of the _toc.yaml file.
     output_path: Absolute path where the archive should be written to.
+    release_dir: Absolute path of the root directory for this version.
   """
   with zipfile.ZipFile(output_path, "w") as archive:
     for root, _, files in os.walk(root_dir):
       for f in files:
         src = os.path.join(root, f)
         dest = src[len(root_dir) + 1:]
+        rel_path = os.path.relpath(src, release_dir)
 
         if src != toc_path and rewriter.can_rewrite(src):
-          archive.writestr(dest, get_versioned_content(src, version))
+          archive.writestr(dest, get_versioned_content(src, rel_path, version))
         else:
           archive.write(src, dest)
 
 
-def get_versioned_content(path, version):
+def get_versioned_content(path, rel_path, version):
   """Rewrites links in the given file to point at versioned docs.
 
   Args:
     path: Absolute path of the file that should be rewritten.
+    rel_path: Relative path of the file that should be rewritten.
     version: Version of the Bazel release whose documentation is being built.
 
   Returns:
@@ -167,15 +182,16 @@
   with open(path, "rt", encoding="utf-8") as f:
     content = f.read()
 
-  return rewriter.rewrite_links(path, content, version)
+  return rewriter.rewrite_links(path, content, rel_path, version)
 
 
 def main(unused_argv):
   version = validate_flag("version")
   output_path = validate_flag("output_path")
-  root_dir, toc_path = create_docs_tree(
+  root_dir, toc_path, release_dir = create_docs_tree(
       version=version,
       toc_path=validate_flag("toc_path"),
+      buttons_path=validate_flag("buttons_path"),
       narrative_docs_path=validate_flag("narrative_docs_path"),
       reference_docs_path=validate_flag("reference_docs_path"),
   )
@@ -185,6 +201,7 @@
       root_dir=root_dir,
       toc_path=toc_path,
       output_path=output_path,
+      release_dir=release_dir,
   )
 
 
diff --git a/scripts/docs/rewriter.py b/scripts/docs/rewriter.py
index a7f2aa7..e457145 100644
--- a/scripts/docs/rewriter.py
+++ b/scripts/docs/rewriter.py
@@ -26,20 +26,32 @@
     r"((href|src)\s*=\s*[\"']({})?)/".format(_BASE_URL))
 
 
-def _fix_html_links(content, version):
+def _fix_html_links(content, rel_path, version):
+  del rel_path  # unused
   return _HTML_LINK_PATTERN.sub(r"\1/versions/{}/".format(version), content)
 
 
-def _fix_html_metadata(content, version):
+def _fix_html_metadata(content, rel_path, version):
+  del rel_path  # unused
   return content.replace("value=\"/_book.yaml\"",
                          "value=\"/versions/{}/_book.yaml\"".format(version))
 
 
+def _set_header_vars(content, rel_path, version):
+  return content.replace(
+      """{% include "_buttons.html" %}""",
+      f"""{{% dynamic setvar version "{version}" %}}
+{{% dynamic setvar original_path "/{os.path.splitext(rel_path)[0]}" %}}
+{{% include "_buttons.html" %}}""",
+  )
+
+
 _MD_LINK_OR_IMAGE_PATTERN = re.compile(
     r"(\!?\[.*?\]\(({})?)(/.*?)\)".format(_BASE_URL))
 
 
-def _fix_md_links_and_images(content, version):
+def _fix_md_links_and_images(content, rel_path, version):
+  del rel_path  # unused
   return _MD_LINK_OR_IMAGE_PATTERN.sub(r"\1/versions/{}\3)".format(version),
                                        content)
 
@@ -47,7 +59,8 @@
 _MD_METADATA_PATTERN = re.compile(r"^(Book: )(/.+)$", re.MULTILINE)
 
 
-def _fix_md_metadata(content, version):
+def _fix_md_metadata(content, rel_path, version):
+  del rel_path  # unused
   return _MD_METADATA_PATTERN.sub(r"\1/versions/{}\2".format(version), content)
 
 
@@ -59,8 +72,8 @@
     ["/", "/_project.yaml", "/versions/", "/versions/_toc.yaml"])
 
 
-def _fix_yaml_paths(content, version):
-
+def _fix_yaml_paths(content, rel_path, version):
+  del rel_path  # unused
   def sub(m):
     prefix, path, suffix = m.group(1, 4, 5)
     if path in _YAML_IGNORE_LIST:
@@ -72,7 +85,7 @@
 
 
 _PURE_HTML_FIXES = [_fix_html_links, _fix_html_metadata]
-_PURE_MD_FIXES = [_fix_md_links_and_images, _fix_md_metadata]
+_PURE_MD_FIXES = [_fix_md_links_and_images, _fix_md_metadata, _set_header_vars]
 _PURE_YAML_FIXES = [_fix_yaml_paths]
 
 _FIXES = {
@@ -99,12 +112,13 @@
   return bool(_get_fixes(path))
 
 
-def rewrite_links(path, content, version):
+def rewrite_links(path, content, rel_path, version):
   """Rewrites links in the given file to point to versioned docs.
 
   Args:
     path: Absolute path of the file to be rewritten.
     content: Content of said file, as text.
+    rel_path: Relative path of the file to be rewritten.
     version: Version of the Bazel release that is being built.
 
   Returns:
@@ -118,6 +132,6 @@
 
   new_content = content
   for f in fixes:
-    new_content = f(new_content, version)
+    new_content = f(new_content, rel_path, version)
 
   return new_content
diff --git a/scripts/docs/rewriter_test.py b/scripts/docs/rewriter_test.py
index b13cece..26a7126 100644
--- a/scripts/docs/rewriter_test.py
+++ b/scripts/docs/rewriter_test.py
@@ -51,7 +51,7 @@
     input_path, content = read_data_file(basename, "input")
     _, version = read_data_file("VERSION", "input")
 
-    actual = rewriter.rewrite_links(input_path, content, version)
+    actual = rewriter.rewrite_links(input_path, content, basename, version)
 
     _, expected = read_data_file(basename, "expected_output")
 
diff --git a/scripts/docs/testdata/expected_output/doc.md b/scripts/docs/testdata/expected_output/doc.md
index fcee50a..dedecaa 100644
--- a/scripts/docs/testdata/expected_output/doc.md
+++ b/scripts/docs/testdata/expected_output/doc.md
@@ -3,6 +3,10 @@
 
 # Configurations
 
+{% dynamic setvar version "6.6.6" %}
+{% dynamic setvar original_path "/doc" %}
+{% include "_buttons.html" %}
+
 A build setting is a single piece of [configuration](/versions/6.6.6/rules/rules#configurations) information.
 
 Like all rules, build setting rules have [implementation functions](https://bazel.build/versions/6.6.6/rules/rules#implementation-function).
diff --git a/scripts/docs/testdata/expected_output/markdown_with_html.md b/scripts/docs/testdata/expected_output/markdown_with_html.md
index 4a21336..4ba1001 100644
--- a/scripts/docs/testdata/expected_output/markdown_with_html.md
+++ b/scripts/docs/testdata/expected_output/markdown_with_html.md
@@ -1,6 +1,10 @@
 Project: /_project.yaml
 Book: /versions/6.6.6/_book.yaml
 
+{% dynamic setvar version "6.6.6" %}
+{% dynamic setvar original_path "/markdown_with_html" %}
+{% include "_buttons.html" %}
+
 Lorem ipsum [short link](/versions/6.6.6/foo/bar). Or rather a [long link](https://bazel.build/versions/6.6.6/foo/bar)?
 
 ![Scalability graph](/versions/6.6.6/rules/scalability-graph.png "Scalability graph")
diff --git a/scripts/docs/testdata/input/doc.md b/scripts/docs/testdata/input/doc.md
index e86b57e..032f1da 100644
--- a/scripts/docs/testdata/input/doc.md
+++ b/scripts/docs/testdata/input/doc.md
@@ -3,6 +3,8 @@
 
 # Configurations
 
+{% include "_buttons.html" %}
+
 A build setting is a single piece of [configuration](/rules/rules#configurations) information.
 
 Like all rules, build setting rules have [implementation functions](https://bazel.build/rules/rules#implementation-function).
diff --git a/scripts/docs/testdata/input/markdown_with_html.md b/scripts/docs/testdata/input/markdown_with_html.md
index 7cf8c0f..538184a 100644
--- a/scripts/docs/testdata/input/markdown_with_html.md
+++ b/scripts/docs/testdata/input/markdown_with_html.md
@@ -1,6 +1,8 @@
 Project: /_project.yaml
 Book: /_book.yaml
 
+{% include "_buttons.html" %}
+
 Lorem ipsum [short link](/foo/bar). Or rather a [long link](https://bazel.build/foo/bar)?
 
 ![Scalability graph](/rules/scalability-graph.png "Scalability graph")
diff --git a/site/en/BUILD.bazel b/site/en/BUILD.bazel
index d58ffe3..9a74a0d 100644
--- a/site/en/BUILD.bazel
+++ b/site/en/BUILD.bazel
@@ -7,6 +7,7 @@
     [
         "docs/user-manual.md",
         "versions/_toc.yaml",
+        "_buttons.html",
     ],
     visibility = [
         "//scripts/docs:__pkg__",
diff --git a/site/en/_buttons.html b/site/en/_buttons.html
index f9d28e6..cf5c87d 100644
--- a/site/en/_buttons.html
+++ b/site/en/_buttons.html
@@ -10,4 +10,47 @@
   View source<span class="material-icons icon-after" aria-hidden="true">open_in_new</span>
 </a>
 {% dynamic endif %}
+{% dynamic if not setvar.original_path %}
+{% dynamic setvar original_path %}{% dynamic print request.path %}{% dynamic endsetvar %}
+{% dynamic endif %}
+<span style="float: right; line-height: 36px">
+{% dynamic if not setvar.version %}
+<strong>Nightly</strong>
+{% dynamic else %}
+<a href="{% dynamic print setvar.original_path %}">Nightly</a>
+{% dynamic endif %}
+<!-- The lines below are updated by //scripts/docs:gen_new_toc -->
+<!-- BEGIN_VERSION_INDICATOR -->

+{% dynamic if setvar.version == "7.2.0" %}
+<strong>7.2</strong>
+{% dynamic else %}
+<a href="/versions/7.2.0{% dynamic print setvar.original_path %}">7.2</a>
+{% dynamic endif %}

+{% dynamic if setvar.version == "7.1.0" %}
+<strong>7.1</strong>
+{% dynamic else %}
+<a href="/versions/7.1.0{% dynamic print setvar.original_path %}">7.1</a>
+{% dynamic endif %}

+{% dynamic if setvar.version == "7.0.0" %}
+<strong>7.0</strong>
+{% dynamic else %}
+<a href="/versions/7.0.0{% dynamic print setvar.original_path %}">7.0</a>
+{% dynamic endif %}

+{% dynamic if setvar.version == "6.5.0" %}
+<strong>6.5</strong>
+{% dynamic else %}
+<a href="/versions/6.5.0{% dynamic print setvar.original_path %}">6.5</a>
+{% dynamic endif %}

+{% dynamic if setvar.version == "6.4.0" %}
+<strong>6.4</strong>
+{% dynamic else %}
+<a href="/versions/6.4.0{% dynamic print setvar.original_path %}">6.4</a>
+{% dynamic endif %}
+<!-- END_VERSION_INDICATOR -->
+</span>
 </p>
diff --git a/src/main/java/com/google/devtools/build/docgen/release/TableOfContentsOptions.java b/src/main/java/com/google/devtools/build/docgen/release/TableOfContentsOptions.java
index af50e55..990679b 100644
--- a/src/main/java/com/google/devtools/build/docgen/release/TableOfContentsOptions.java
+++ b/src/main/java/com/google/devtools/build/docgen/release/TableOfContentsOptions.java
@@ -39,6 +39,22 @@
   public String outputPath;
 
   @Option(
+      name = "version_indicator_input",
+      defaultValue = "",
+      documentationCategory = OptionDocumentationCategory.UNCATEGORIZED,
+      effectTags = {OptionEffectTag.UNKNOWN},
+      help = "Path of the file containing the version indicator.")
+  public String versionIndicatorInputPath;
+
+  @Option(
+      name = "version_indicator_output",
+      defaultValue = "",
+      documentationCategory = OptionDocumentationCategory.UNCATEGORIZED,
+      effectTags = {OptionEffectTag.UNKNOWN},
+      help = "Path of the file where the version indicator should be written.")
+  public String versionIndicatorOutputPath;
+
+  @Option(
       name = "version",
       abbrev = 'v',
       defaultValue = "",
diff --git a/src/main/java/com/google/devtools/build/docgen/release/TableOfContentsUpdater.java b/src/main/java/com/google/devtools/build/docgen/release/TableOfContentsUpdater.java
index 6d65dd6..c2f69c2 100644
--- a/src/main/java/com/google/devtools/build/docgen/release/TableOfContentsUpdater.java
+++ b/src/main/java/com/google/devtools/build/docgen/release/TableOfContentsUpdater.java
@@ -14,15 +14,19 @@
 package com.google.devtools.build.docgen.release;
 
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.joining;
 
 import com.google.common.flogger.GoogleLogger;
 import com.google.devtools.common.options.OptionsParser;
 import java.io.FileInputStream;
 import java.io.FileOutputStream;
 import java.io.OutputStreamWriter;
+import java.nio.file.Files;
+import java.nio.file.Path;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.regex.Pattern;
 import org.yaml.snakeyaml.DumperOptions;
 import org.yaml.snakeyaml.Yaml;
 
@@ -33,6 +37,21 @@
 
   private static final String VERSION_ROOT = "/versions/";
 
+  private static final String VERSION_INDICATOR_START = "<!-- BEGIN_VERSION_INDICATOR -->";
+
+  private static final String VERSION_INDICATOR_END = "<!-- END_VERSION_INDICATOR -->";
+
+  private static final String VERSION_INDICATOR_TEMPLATE =
+      """

+{% dynamic if setvar.version == "{canonical_version}" %}
+<strong>{pretty_version}</strong>
+{% dynamic else %}
+<a href="{version_root}{canonical_version}/{% dynamic print setvar.original_path %}">
+{pretty_version}</a>
+{% dynamic endif %}
+""";
+
   private TableOfContentsUpdater() {}
 
   public static void main(String[] args) {
@@ -52,23 +71,44 @@
     }
 
     Yaml yaml = new Yaml(getYamlOptions());
+    List<String> versions;
     try (FileInputStream fis = new FileInputStream(options.inputPath)) {
       Object data = yaml.load(fis);
-      update(data, options.version, options.maxReleases);
+      versions = updateTocAndGetVersions(data, options.version, options.maxReleases);
       yaml.dump(data, new OutputStreamWriter(new FileOutputStream(options.outputPath), UTF_8));
     } catch (Throwable t) {
       System.err.printf("ERROR: %s\n", t.getMessage());
       logger.atSevere().withCause(t).log(
           "Failed to transform TOC from %s to %s", options.inputPath, options.outputPath);
       Runtime.getRuntime().exit(1);
+      throw new IllegalStateException("Not reached");
+    }
+
+    if (!options.versionIndicatorInputPath.isEmpty()) {
+      try {
+        Files.writeString(
+            Path.of(options.versionIndicatorOutputPath),
+            makeUpdatedVersionIndicator(
+                Files.readString(Path.of(options.versionIndicatorInputPath)), versions));
+      } catch (Throwable t) {
+        System.err.printf("ERROR: %s\n", t.getMessage());
+        logger.atSevere().withCause(t).log(
+            "Failed to update version indicator from %s to %s",
+            options.versionIndicatorInputPath, options.versionIndicatorOutputPath);
+        Runtime.getRuntime().exit(1);
+      }
     }
   }
 
   private static void printUsage() {
     System.err.println(
-        "Usage: toc-updater -i src_toc_path -o dest_toc_path -v version [-m max_releases] [-h]\n\n"
-            + "Reads the input TOC, adds an entry for the specified version and saves the new TOC"
-            + " at the specified location.\n");
+        """
+Usage: toc-updater -i src_toc_path -o dest_toc_path -v version [-m max_releases] [-h] \
+[--version_indicator_input path --version_indicator_output path]
+
+Reads the input TOC, adds an entry for the specified version and saves the new TOC\
+ at the specified location.
+""");
   }
 
   private static DumperOptions getYamlOptions() {
@@ -79,10 +119,11 @@
     return opts;
   }
 
-  private static void update(Object data, String version, int maxReleases) {
+  private static List<String> updateTocAndGetVersions(
+      Object data, String version, int maxReleases) {
     @SuppressWarnings("unchecked") // yaml deserialization
     Map<String, List<Map<String, String>>> m = (Map<String, List<Map<String, String>>>) data;
-    List<Map<String, String>> toc = (List<Map<String, String>>) m.get("toc");
+    List<Map<String, String>> toc = m.get("toc");
     if (toc == null) {
       throw new IllegalStateException("Missing 'toc' element.");
     }
@@ -91,9 +132,53 @@
     newEntry.put("path", String.format("%s%s", VERSION_ROOT, version));
     newEntry.put("label", version);
 
-    toc.add(0, newEntry);
+    toc.addFirst(newEntry);
     if (toc.size() > maxReleases) {
       m.put("toc", toc.subList(0, maxReleases));
     }
+
+    return m.get("toc").stream()
+        // Exclude legacy doc versions.
+        .filter(e -> e.get("path").startsWith(VERSION_ROOT))
+        .map(e -> e.get("label"))
+        .map(TableOfContentsUpdater::canonicalizeVersion)
+        .toList();
+  }
+
+  private static String makeUpdatedVersionIndicator(
+      String oldVersionIndicator, List<String> versions) {
+    int beginPos = oldVersionIndicator.indexOf(VERSION_INDICATOR_START);
+    int endPos = oldVersionIndicator.indexOf(VERSION_INDICATOR_END);
+    if (beginPos == -1 || endPos == -1) {
+      throw new IllegalStateException("Version indicator markers not found.");
+    }
+    // Include the line terminator.
+    String prefix =
+        oldVersionIndicator.substring(0, beginPos + VERSION_INDICATOR_START.length() + 1);
+    String suffix = oldVersionIndicator.substring(endPos);
+    return versions.stream()
+        .map(
+            version ->
+                VERSION_INDICATOR_TEMPLATE
+                    .replace("{canonical_version}", version)
+                    .replace("{pretty_version}", prettifyVersion(version))
+                    .replace("{version_root}", VERSION_ROOT))
+        .collect(joining("", prefix, suffix));
+  }
+
+  private static String canonicalizeVersion(String version) {
+    if (version.split(Pattern.quote(".")).length < 3) {
+      return version + ".0";
+    } else {
+      return version;
+    }
+  }
+
+  private static String prettifyVersion(String version) {
+    if (version.endsWith(".0")) {
+      return version.substring(0, version.length() - 2);
+    } else {
+      return version;
+    }
   }
 }