Support code blocks within markdown tables

Markdown tables (GFM tables) require rows to be specified on single
lines, preventing standard fenced or indented code blocks within cells.
Documentation generators (such as Bazel Stardoc) generate code blocks
inside table cells using <pre><code>...</code></pre> (or <pre>...)
with HTML line breaks or entity-encoded newlines (&#10;).

Previously, GitilesHtmlExtension ignored <pre> and <code> inline
tags, causing MarkdownToHtml to strip them and rendering code blocks as
plain inline text with collapsed spaces and no code styling.
Additionally, when GitilesHtmlExtension unlinked replaced sibling nodes
(such as closing anchor tags), the visitor traversal prematurely ended,
skipping subsequent siblings in the same container.

This change adds:
1. TableCodeBlock, a CustomNode representing code blocks in inline
   contexts like table cells.
2. Handling in GitilesHtmlExtension to scan <pre> / <code> tags,
   extract language attributes, preserve newlines and code formatting,
   and convert them to TableCodeBlock (and FencedCodeBlock for block
   HTML).
3. Traversal in GitilesHtmlExtension.visitChildren that safely advances
   past replaced sibling ranges without skipping siblings.
4. Support for <a id="..."> anchors in addition to <a name="...">.
5. Visitor handling in MarkdownToHtml to render TableCodeBlock via
   codeInPre(...).

Change-Id: I75c6420f19537d6b9836294f58bcdc6405410192
diff --git a/java/com/google/gitiles/doc/GitilesHtmlExtension.java b/java/com/google/gitiles/doc/GitilesHtmlExtension.java
index a8fc04f..fbea8d2 100644
--- a/java/com/google/gitiles/doc/GitilesHtmlExtension.java
+++ b/java/com/google/gitiles/doc/GitilesHtmlExtension.java
@@ -20,10 +20,14 @@
 import javax.annotation.Nullable;
 import org.commonmark.Extension;
 import org.commonmark.node.AbstractVisitor;
+import org.commonmark.node.Code;
+import org.commonmark.node.FencedCodeBlock;
 import org.commonmark.node.HardLineBreak;
 import org.commonmark.node.HtmlBlock;
 import org.commonmark.node.HtmlInline;
 import org.commonmark.node.Node;
+import org.commonmark.node.SoftLineBreak;
+import org.commonmark.node.Text;
 import org.commonmark.node.ThematicBreak;
 import org.commonmark.parser.Parser;
 import org.commonmark.parser.Parser.ParserExtension;
@@ -41,15 +45,36 @@
  *   <li>{@link ThematicBreak}
  *   <li>{@link NamedAnchor}
  *   <li>{@link IframeBlock}
+ *   <li>{@link TableCodeBlock}
  * </ul>
  */
 public class GitilesHtmlExtension implements ParserExtension {
   private static final Pattern BREAK = Pattern.compile("<(hr|br)\\s*/?>", Pattern.CASE_INSENSITIVE);
 
   private static final Pattern ANCHOR_OPEN =
-      Pattern.compile("<a\\s+name=([\"'])([^\"'\\s]+)\\1>", Pattern.CASE_INSENSITIVE);
+      Pattern.compile(
+          "<a\\s+[^>]*(?:name|id)=([\"'])([^\"'\\s]+)\\1[^>]*>", Pattern.CASE_INSENSITIVE);
   private static final Pattern ANCHOR_CLOSE = Pattern.compile("</[aA]>");
 
+  private static final Pattern PRE_OPEN =
+      Pattern.compile("<pre(?:\\s+[^>]*)?>", Pattern.CASE_INSENSITIVE);
+  private static final Pattern PRE_CLOSE = Pattern.compile("</pre\\s*>", Pattern.CASE_INSENSITIVE);
+  private static final Pattern CODE_OPEN =
+      Pattern.compile("<code(?:\\s+[^>]*)?>", Pattern.CASE_INSENSITIVE);
+  private static final Pattern CODE_CLOSE =
+      Pattern.compile("</code\\s*>", Pattern.CASE_INSENSITIVE);
+
+  private static final Pattern LANG_ATTR =
+      Pattern.compile(
+          "(?:class=[\"'](?:language-|lang-)?([a-zA-Z0-9_-]+)[\"']|lang=[\"']([a-zA-Z0-9_-]+)[\"'])",
+          Pattern.CASE_INSENSITIVE);
+
+  private static final Pattern PRE_BLOCK =
+      Pattern.compile("^\\s*<pre(?:\\s+[^>]*)?>([\\s\\S]*?)</pre>\\s*$", Pattern.CASE_INSENSITIVE);
+  private static final Pattern CODE_BLOCK_INNER =
+      Pattern.compile(
+          "^\\s*<code(?:\\s+[^>]*)?>([\\s\\S]*?)</code>\\s*$", Pattern.CASE_INSENSITIVE);
+
   private static final Pattern IFRAME_OPEN =
       Pattern.compile("<iframe\\s+", Pattern.CASE_INSENSITIVE);
   private static final Pattern IFRAME_CLOSE =
@@ -80,31 +105,40 @@
 
   private static class HtmlVisitor extends AbstractVisitor {
     @Override
+    protected void visitChildren(Node parent) {
+      Node node = parent.getFirstChild();
+      while (node != null) {
+        if (node instanceof HtmlInline) {
+          node = inline((HtmlInline) node);
+        } else if (node instanceof HtmlBlock) {
+          node = block((HtmlBlock) node);
+        } else {
+          node.accept(this);
+          node = node.getNext();
+        }
+      }
+    }
+
+    @Override
     public void visit(HtmlInline node) {
-      inline(node);
+      // Handled in visitChildren to safely support node replacements.
     }
 
     @Override
     public void visit(HtmlBlock node) {
-      block(node);
+      // Handled in visitChildren to safely support node replacements.
     }
   }
 
-  private static void inline(HtmlInline curr) {
+  private static @Nullable Node inline(HtmlInline curr) {
     String html = curr.getLiteral();
     Matcher m = BREAK.matcher(html);
     if (m.matches()) {
-      switch (m.group(1).toLowerCase()) {
-        case "br":
-          curr.insertAfter(new HardLineBreak());
-          curr.unlink();
-          return;
-
-        case "hr":
-          curr.insertAfter(new ThematicBreak());
-          curr.unlink();
-          return;
-      }
+      Node br = "br".equalsIgnoreCase(m.group(1)) ? new HardLineBreak() : new ThematicBreak();
+      curr.insertBefore(br);
+      Node next = curr.getNext();
+      curr.unlink();
+      return next;
     }
 
     m = ANCHOR_OPEN.matcher(html);
@@ -112,24 +146,128 @@
       String name = m.group(2);
       Node next = curr.getNext();
 
-      // HtmlInline{<a name="id">}HtmlInline{</a>}
       if (isAnchorClose(next)) {
-        next.unlink();
-
         NamedAnchor anchor = new NamedAnchor();
         anchor.setName(name);
-        curr.insertAfter(anchor);
-        curr.unlink();
+        curr.insertBefore(anchor);
         MarkdownUtil.trimPreviousWhitespace(anchor);
+        Node afterClose = next.getNext();
+        curr.unlink();
+        next.unlink();
+        return afterClose;
+      }
+    }
+
+    m = PRE_OPEN.matcher(html);
+    if (m.matches()) {
+      Node afterPre = processPre(curr);
+      if (afterPre != null) {
+        return afterPre;
+      }
+    }
+
+    return curr.getNext();
+  }
+
+  @SuppressWarnings("ReferenceEquality") // commonmark AST nodes compared by identity.
+  private static @Nullable Node processPre(HtmlInline preOpen) {
+    Node preClose = null;
+    for (Node s = preOpen.getNext(); s != null; s = s.getNext()) {
+      if (s instanceof HtmlInline && PRE_CLOSE.matcher(((HtmlInline) s).getLiteral()).matches()) {
+        preClose = s;
+        break;
+      }
+    }
+    if (preClose == null) {
+      return null;
+    }
+
+    String lang = extractLang(preOpen.getLiteral());
+
+    Node codeOpen = null;
+    Node first = preOpen.getNext();
+    if (first != preClose && first instanceof HtmlInline) {
+      String firstHtml = ((HtmlInline) first).getLiteral();
+      if (CODE_OPEN.matcher(firstHtml).matches()) {
+        codeOpen = first;
+        String codeLang = extractLang(firstHtml);
+        if (codeLang != null) {
+          lang = codeLang;
+        }
+      }
+    }
+
+    Node codeClose = null;
+    Node last = preClose.getPrevious();
+    if (last != preOpen && last != codeOpen && last instanceof HtmlInline) {
+      if (CODE_CLOSE.matcher(((HtmlInline) last).getLiteral()).matches()) {
+        codeClose = last;
+      }
+    }
+
+    StringBuilder text = new StringBuilder();
+    Node start = (codeOpen != null) ? codeOpen.getNext() : preOpen.getNext();
+    Node end = (codeClose != null) ? codeClose : preClose;
+    for (Node c = start; c != null && c != end; c = c.getNext()) {
+      appendCodeContent(text, c);
+    }
+
+    TableCodeBlock block = new TableCodeBlock();
+    block.setInfo(lang);
+    block.setLiteral(text.toString());
+
+    preOpen.insertBefore(block);
+
+    Node afterPre = preClose.getNext();
+    Node c = preOpen;
+    while (c != null) {
+      Node toUnlink = c;
+      c = (c == preClose) ? null : c.getNext();
+      toUnlink.unlink();
+    }
+
+    return afterPre;
+  }
+
+  private static void appendCodeContent(StringBuilder text, Node n) {
+    if (n instanceof Text) {
+      text.append(((Text) n).getLiteral());
+    } else if (n instanceof SoftLineBreak || n instanceof HardLineBreak) {
+      text.append('\n');
+    } else if (n instanceof Code) {
+      text.append(((Code) n).getLiteral());
+    } else if (n instanceof HtmlInline) {
+      String literal = ((HtmlInline) n).getLiteral();
+      if (BREAK.matcher(literal).matches()) {
+        text.append('\n');
+      } else if (CODE_OPEN.matcher(literal).matches() || CODE_CLOSE.matcher(literal).matches()) {
+        // Redundant code tags are ignored.
+      } else {
+        text.append(literal);
+      }
+    } else {
+      for (Node child = n.getFirstChild(); child != null; child = child.getNext()) {
+        appendCodeContent(text, child);
       }
     }
   }
 
-  private static boolean isAnchorClose(Node n) {
+  private static @Nullable String extractLang(String html) {
+    Matcher m = LANG_ATTR.matcher(html);
+    if (m.find()) {
+      String l = m.group(1) != null ? m.group(1) : m.group(2);
+      if (!"code".equalsIgnoreCase(l) && !"prettyprint".equalsIgnoreCase(l)) {
+        return l;
+      }
+    }
+    return null;
+  }
+
+  private static boolean isAnchorClose(@Nullable Node n) {
     return n instanceof HtmlInline && ANCHOR_CLOSE.matcher(((HtmlInline) n).getLiteral()).matches();
   }
 
-  private static void block(HtmlBlock curr) {
+  private static @Nullable Node block(HtmlBlock curr) {
     String html = curr.getLiteral();
     Matcher m = IFRAME_OPEN.matcher(html);
     if (m.find()) {
@@ -139,11 +277,37 @@
         int end = start + m.start();
         IframeBlock f = iframe(html.substring(start, end));
         if (f != null) {
-          curr.insertAfter(f);
+          curr.insertBefore(f);
+          Node next = curr.getNext();
           curr.unlink();
+          return next;
         }
       }
     }
+
+    m = PRE_BLOCK.matcher(html.trim());
+    if (m.matches()) {
+      String inner = m.group(1);
+      String lang = extractLang(html);
+      Matcher cm = CODE_BLOCK_INNER.matcher(inner);
+      if (cm.matches()) {
+        String codeLang = extractLang(inner);
+        if (codeLang != null) {
+          lang = codeLang;
+        }
+        inner = cm.group(1);
+      }
+      inner = org.apache.commons.text.StringEscapeUtils.unescapeHtml4(inner);
+      FencedCodeBlock fcb = new FencedCodeBlock();
+      fcb.setInfo(lang);
+      fcb.setLiteral(inner);
+      curr.insertBefore(fcb);
+      Node next = curr.getNext();
+      curr.unlink();
+      return next;
+    }
+
+    return curr.getNext();
   }
 
   private static @Nullable IframeBlock iframe(String html) {
diff --git a/java/com/google/gitiles/doc/MarkdownToHtml.java b/java/com/google/gitiles/doc/MarkdownToHtml.java
index 4afe8b3..c040363 100644
--- a/java/com/google/gitiles/doc/MarkdownToHtml.java
+++ b/java/com/google/gitiles/doc/MarkdownToHtml.java
@@ -390,6 +390,10 @@
     html.close(tag);
   }
 
+  private void visit(TableCodeBlock node) {
+    codeInPre(node.getInfo(), Strings.nullToEmpty(node.getLiteral()));
+  }
+
   private void visit(SmartQuoted node) {
     switch (node.getType()) {
       case DOUBLE -> {
@@ -443,6 +447,7 @@
       case Strikethrough st -> wrapChildren("del", st);
       case TableBody tb -> wrapChildren("tbody", tb);
       case TableCell tc -> visit(tc);
+      case TableCodeBlock tcb -> visit(tcb);
       case TableHead th -> wrapChildren("thead", th);
       case TableRow tr -> visit(tr);
       default -> throw new IllegalArgumentException("cannot render " + node.getClass());
diff --git a/java/com/google/gitiles/doc/TableCodeBlock.java b/java/com/google/gitiles/doc/TableCodeBlock.java
new file mode 100644
index 0000000..6af9fb1
--- /dev/null
+++ b/java/com/google/gitiles/doc/TableCodeBlock.java
@@ -0,0 +1,39 @@
+// Copyright 2026 Google Inc. 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.gitiles.doc;
+
+import org.commonmark.node.CustomNode;
+
+/** A code block inside a table cell (or other inline context). */
+public class TableCodeBlock extends CustomNode {
+  private String info;
+  private String literal;
+
+  public String getInfo() {
+    return info;
+  }
+
+  public void setInfo(String info) {
+    this.info = info;
+  }
+
+  public String getLiteral() {
+    return literal;
+  }
+
+  public void setLiteral(String literal) {
+    this.literal = literal;
+  }
+}
diff --git a/javatests/com/google/gitiles/doc/GitilesMarkdownTest.java b/javatests/com/google/gitiles/doc/GitilesMarkdownTest.java
index 73430ce..72e409b 100644
--- a/javatests/com/google/gitiles/doc/GitilesMarkdownTest.java
+++ b/javatests/com/google/gitiles/doc/GitilesMarkdownTest.java
@@ -310,4 +310,90 @@
             .toSoyHtml(node);
     return html == null ? "" : html.getSafeHtmlString();
   }
+
+  @Test
+  public void testTableCodeBlock() {
+    String md =
+        "| Character | Description | Kind | Friend | Favorite |\n"
+            + "| :------------- | :------------- | :------------- | :------------- | :-------------"
+            + " |\n"
+            + "| <a id=\"little_bear-name\"></a>Little Bear | The softest bear in the forest. | <a"
+            + " href=\"https://example.com/bear\">Bear</a> | True | |\n"
+            + "| <a id=\"little_bear-adventures\"></a>Adventures | List of `*.stars` collected"
+            + " while wandering through the enchanted forest; see bedtime song below.<br><br>Song:"
+            + " <pre><code>sleepy_bear(&#10; name = \"honey_pot\",&#10; dreams = [&#10;"
+            + " \"butterfly\",&#10; \"rainbow\",&#10; ],&#10;)&#10;wake_up(&#10; name ="
+            + " \"morning_sun\",&#10; friend = \":little_bunny\",&#10; hugs = [&#10;"
+            + " \":warm_hug\",&#10; \":gentle_smile\",&#10; ],&#10;)</code></pre> | <a"
+            + " href=\"https://example.com/forest\">Enchanted forest</a> | Sunny | `[]` |\n"
+            + "| <a id=\"little_bear-basket\"></a>Picnic Basket | Name of the `picnic`"
+            + " basket.<br><br>Default to `<name>/berries.basket` if not set. | String | Sweet |"
+            + " `\"\"` |\n"
+            + "| <a id=\"little_bear-storybook\"></a>Storybook | A magic book of bedtime"
+            + " tales.<br><br>Read before bedtime with warm milk. | <a"
+            + " href=\"https://example.com/books\">Book</a> | Cozy | `None` |\n";
+    String html = render(md, false);
+    assertThat(html).contains("<a name=\"little_bear-name\"></a>Little Bear");
+    assertThat(html).contains("<a name=\"little_bear-adventures\"></a>Adventures");
+    assertThat(html).contains("<pre class=\"code\">sleepy_bear(\n name = &quot;honey_pot&quot;,");
+    assertThat(html).contains("wake_up(\n name = &quot;morning_sun&quot;,");
+    assertThat(html).contains("</pre>");
+    assertThat(html).contains("<code class=\"code\">*.stars</code>");
+    assertThat(html).contains("<code class=\"code\">[]</code>");
+  }
+
+  @Test
+  public void testTableCodeBlockSimple() {
+    String md = "| col |\n| --- |\n| <pre><code>hello world</code></pre> |\n";
+    String html = render(md, false);
+    assertThat(html).contains("<pre class=\"code\">hello world</pre>");
+  }
+
+  @Test
+  public void testTableCodeBlockWithoutCodeTag() {
+    String md = "| col |\n| --- |\n| <pre>hello world</pre> |\n";
+    String html = render(md, false);
+    assertThat(html).contains("<pre class=\"code\">hello world</pre>");
+  }
+
+  @Test
+  public void testTableCodeBlockWithBr() {
+    String md = "| col |\n| --- |\n| <pre><code>line 1<br>line 2</code></pre> |\n";
+    String html = render(md, false);
+    assertThat(html).contains("<pre class=\"code\">line 1\nline 2</pre>");
+  }
+
+  @Test
+  public void testTableCodeBlockWithLanguage() {
+    String md = "| col |\n| --- |\n| <pre><code class=\"lang-c\">int x = 0;</code></pre> |\n";
+    String html = render(md, false);
+    assertThat(html).contains("<pre class=\"code\">");
+    assertThat(html).contains("int");
+    assertThat(html).contains("x");
+  }
+
+  @Test
+  public void testTableMultipleCodeBlocksInCell() {
+    String md =
+        "| col |\n| --- |\n| <pre><code>code1</code></pre> and <pre><code>code2</code></pre> |\n";
+    String html = render(md, false);
+    assertThat(html).contains("<pre class=\"code\">code1</pre>");
+    assertThat(html).contains(" and ");
+    assertThat(html).contains("<pre class=\"code\">code2</pre>");
+  }
+
+  @Test
+  public void testAnchorWithId() {
+    String md = "<a id=\"foo\"></a>line1<br>line2\n";
+    String html = render(md, false);
+    assertThat(html).contains("<a name=\"foo\"></a>line1<br />line2");
+  }
+
+  @Test
+  public void testTableCodeBlockXssPrevention() {
+    String md = "| col |\n| --- |\n| <pre><code><script>alert(1)</script></code></pre> |\n";
+    String html = render(md, false);
+    assertThat(html).doesNotContain("<script>");
+    assertThat(html).contains("&lt;script&gt;alert(1)&lt;/script&gt;");
+  }
 }