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( name = \"honey_pot\", dreams = [ "
+ + " \"butterfly\", \"rainbow\", ], ) wake_up( name ="
+ + " \"morning_sun\", friend = \":little_bunny\", hugs = [ "
+ + " \":warm_hug\", \":gentle_smile\", ], )</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 = "honey_pot",");
+ assertThat(html).contains("wake_up(\n name = "morning_sun",");
+ 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("<script>alert(1)</script>");
+ }
}