Merge "Support code blocks within markdown tables"
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;");
+  }
 }