001/* 002 * Licensed to the Apache Software Foundation (ASF) under one or more 003 * contributor license agreements. See the NOTICE file distributed with 004 * this work for additional information regarding copyright ownership. 005 * The ASF licenses this file to You under the Apache License, Version 2.0 006 * (the "License"); you may not use this file except in compliance with 007 * the License. You may obtain a copy of the License at 008 * 009 * http://www.apache.org/licenses/LICENSE-2.0 010 * 011 * Unless required by applicable law or agreed to in writing, software 012 * distributed under the License is distributed on an "AS IS" BASIS, 013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 014 * See the License for the specific language governing permissions and 015 * limitations under the License. 016 */ 017package org.apache.juneau.commons.template; 018 019import java.util.*; 020import java.util.function.*; 021 022import org.apache.juneau.commons.svl.*; 023import org.apache.juneau.commons.svl.functions.*; 024 025/** 026 * Destination presets for {@link TemplateResolver.Builder#destination(Destination)}. 027 * 028 * <p> 029 * Each preset pairs a value encoder (applied to resolved values, never to template text) with a {@link StructureRenderer} 030 * that encodes every item and cell itself. Only generic formats are offered; define a {@link Destination} for anything 031 * else. 032 * 033 * <ul> 034 * <li>{@link #HTML}: for text nodes and quoted attribute values only; it does not make a URL attribute safe (use 035 * {@link #URL_COMPONENT} and a scheme allowlist for <c>href</c>/<c>src</c>). Escapes <c>& < > " '</c>; lists, blocks and tables become <c>ul</c>, <c>ol</c>, 036 * <c>blockquote</c> and <c>table</c> elements. 037 * <li>{@link #MARKDOWN}: backslash-escapes characters Markdown would interpret and flattens whitespace so a value 038 * cannot start a new block; lists, quotes and pipe tables. Bare URLs are not auto-linked (the <c>:</c> of 039 * <c>://</c> and every <c>.</c> are escaped). 040 * <li>{@link #PLAIN_TEXT}: drops control characters other than newline and tab; <c>- </c>, <c>N. </c> and <c>> </c> 041 * markers and column-aligned tables. 042 * <li>{@link #JSON_STRING}: escapes for the inside of a JSON string literal. 043 * <li>{@link #URL_COMPONENT}: percent-encodes for a URL path segment or query component. 044 * </ul> 045 */ 046@SuppressWarnings({ 047 "java:S127", // The markdown encoder skips ahead past a digit run by assigning the loop counter 048 "java:S135", // Encoder loops use continue to skip dropped characters 049 "java:S3776" // Encoders and renderers dispatch over every structure kind in one place 050}) 051public final class Destinations { 052 053 /** HTML. */ 054 public static final Destination HTML = new Destination("html", Destinations::htmlEncode, Destinations::renderHtml); 055 056 /** Markdown. */ 057 public static final Destination MARKDOWN = new Destination("markdown", Destinations::markdownEncode, Destinations::renderMarkdown); 058 059 /** Plain text. */ 060 public static final Destination PLAIN_TEXT = new Destination("plain-text", Destinations::plainEncode, Destinations::renderPlain); 061 062 /** The inside of a JSON string literal (no surrounding quotes). */ 063 public static final Destination JSON_STRING = new Destination("json-string", Destinations::jsonEncode, (s, t) -> jsonEncode(renderPlain(s, t))); 064 065 /** A URL path segment or query component. */ 066 public static final Destination URL_COMPONENT = new Destination("url-component", Destinations::urlEncode, (s, t) -> urlEncode(renderPlain(s, t))); 067 068 private static final EncodingFunctions.HtmlEscape HTML_ESCAPE = new EncodingFunctions.HtmlEscape(); 069 private static final EncodingFunctions.UrlEncode URL_ENCODE = new EncodingFunctions.UrlEncode(); 070 071 private Destinations() {} 072 073 //----------------------------------------------------------------------------------------------------------------- 074 // Encoders 075 //----------------------------------------------------------------------------------------------------------------- 076 077 private static String htmlEncode(String s) { 078 return HTML_ESCAPE.invoke(s); 079 } 080 081 private static String urlEncode(String s) { 082 // URLEncoder writes a space as '+' (and a literal '+' as %2B); a component wants %20. 083 return URL_ENCODE.invoke(s).replace("+", "%20"); 084 } 085 086 private static String plainEncode(String s) { 087 var sb = new StringBuilder(s.length()); 088 for (var i = 0; i < s.length(); i++) { 089 var c = s.charAt(i); 090 if (c == '\n' || c == '\t' || (c >= 0x20 && c != 0x7f)) 091 sb.append(c); 092 } 093 return sb.toString(); 094 } 095 096 private static String jsonEncode(String s) { 097 var sb = new StringBuilder(s.length() + 8); 098 for (var i = 0; i < s.length(); i++) { 099 var c = s.charAt(i); 100 switch (c) { 101 case '"' -> sb.append("\\\""); 102 case '\\' -> sb.append("\\\\"); 103 case '\n' -> sb.append("\\n"); 104 case '\r' -> sb.append("\\r"); 105 case '\t' -> sb.append("\\t"); 106 case '\b' -> sb.append("\\b"); 107 case '\f' -> sb.append("\\f"); 108 default -> { 109 if (c < 0x20 || c == 0x7f || c == 0x2028 || c == 0x2029) 110 sb.append(String.format("\\u%04x", (int)c)); 111 else 112 sb.append(c); 113 } 114 } 115 } 116 return sb.toString(); 117 } 118 119 /** 120 * Escapes text for Markdown. 121 * 122 * <p> 123 * Whitespace runs (newlines included) collapse to one space and other control characters are dropped, so a value cannot 124 * start a new block or an indented code block. The characters <c>\ ` * _ [ ] < > | ~ &</c> are always 125 * backslash-escaped, as are <c>{ } ( ) ! .</c> and the colon of <c>://</c> (so a bare URL or address is not 126 * auto-linked and kramdown/MDX attribute braces are inert). The first visible character is also escaped when it 127 * could open a block: <c># + - =</c>, and a digit run followed by <c>)</c>. 128 */ 129 private static String markdownEncode(String s) { 130 var sb = new StringBuilder(s.length() + 8); 131 var space = false; 132 for (var i = 0; i < s.length(); i++) { 133 var c = s.charAt(i); 134 if (Character.isWhitespace(c)) { 135 space = ! sb.isEmpty() || space; 136 continue; 137 } 138 if (c < 0x20 || c == 0x7f) 139 continue; 140 if (space) { 141 sb.append(' '); 142 space = false; 143 } 144 sb.append(c); 145 } 146 if (space && ! sb.isEmpty()) 147 sb.append(' '); 148 var t = sb.toString(); 149 var out = new StringBuilder(t.length() + 8); 150 var lead = firstNonSpace(t); 151 for (var i = 0; i < t.length(); i++) { 152 var c = t.charAt(i); 153 if ("\\`*_[]<>|~&{}()!.".indexOf(c) >= 0 || (i == lead && "#+-=".indexOf(c) >= 0) 154 || (c == ':' && t.startsWith("//", i + 1))) 155 out.append('\\'); 156 if (i == lead && c >= '0' && c <= '9') { 157 var j = i; 158 while (j < t.length() && t.charAt(j) >= '0' && t.charAt(j) <= '9') 159 j++; 160 if (j < t.length() && t.charAt(j) == ')') { 161 out.append(t, i, j).append("\\)"); 162 i = j; 163 continue; 164 } 165 } 166 out.append(c); 167 } 168 return out.toString(); 169 } 170 171 private static int firstNonSpace(String t) { 172 var i = 0; 173 while (i < t.length() && t.charAt(i) == ' ') 174 i++; 175 return i; 176 } 177 178 //----------------------------------------------------------------------------------------------------------------- 179 // Renderers 180 //----------------------------------------------------------------------------------------------------------------- 181 182 private static String renderHtml(Structure s, Function<Object,String> toText) { 183 var sb = new StringBuilder(); 184 switch (s.kind()) { 185 case UL, OL -> { 186 var tag = s.kind() == Structure.Kind.UL ? "ul" : "ol"; 187 sb.append('<').append(tag).append('>'); 188 for (var o : s.items()) 189 sb.append("<li>").append(htmlItem(o, toText)).append("</li>"); 190 sb.append("</").append(tag).append('>'); 191 } 192 case BLOCK -> sb.append("<blockquote>").append(htmlItem(s.items().get(0), toText)).append("</blockquote>"); 193 case TABLE -> { 194 sb.append("<table><thead><tr>"); 195 for (var c : s.columns()) 196 sb.append("<th>").append(htmlEncode(c)).append("</th>"); 197 sb.append("</tr></thead><tbody>"); 198 for (var r : s.rows()) { 199 sb.append("<tr>"); 200 for (var o : r) 201 sb.append("<td>").append(htmlItem(o, toText)).append("</td>"); 202 sb.append("</tr>"); 203 } 204 sb.append("</tbody></table>"); 205 } 206 } 207 return sb.toString(); 208 } 209 210 private static String htmlItem(Object o, Function<Object,String> toText) { 211 return o instanceof Structure n ? renderHtml(n, toText) : htmlEncode(toText.apply(o)); 212 } 213 214 private static String renderMarkdown(Structure s, Function<Object,String> toText) { 215 var sb = new StringBuilder(); 216 switch (s.kind()) { 217 case UL, OL -> { 218 var n = 1; 219 for (var o : s.items()) { 220 var marker = s.kind() == Structure.Kind.UL ? "- " : (n++) + ". "; 221 line(sb, marker + indentRest(mdItem(o, toText), marker.length())); 222 } 223 } 224 case BLOCK -> { 225 for (var l : mdItem(s.items().get(0), toText).split("\n", -1)) 226 line(sb, "> " + l); 227 } 228 case TABLE -> { 229 var cols = new StringJoiner(" | ", "| ", " |"); 230 var rule = new StringJoiner(" | ", "| ", " |"); 231 for (var c : s.columns()) { 232 cols.add(markdownEncode(c)); 233 rule.add("---"); 234 } 235 line(sb, cols.toString()); 236 line(sb, rule.toString()); 237 for (var r : s.rows()) { 238 var row = new StringJoiner(" | ", "| ", " |"); 239 for (var o : r) 240 row.add(mdCell(o, toText)); 241 line(sb, row.toString()); 242 } 243 } 244 } 245 return sb.toString(); 246 } 247 248 private static String mdItem(Object o, Function<Object,String> toText) { 249 return o instanceof Structure n ? renderMarkdown(n, toText) : markdownEncode(toText.apply(o)); 250 } 251 252 private static String mdCell(Object o, Function<Object,String> toText) { 253 // A nested structure cannot span lines inside a pipe table: flatten it to one line. 254 return o instanceof Structure n ? markdownEncode(renderPlain(n, toText)) : markdownEncode(toText.apply(o)); 255 } 256 257 private static String indentRest(String text, int indent) { 258 return text.replace("\n", "\n" + " ".repeat(indent)); 259 } 260 261 private static String renderPlain(Structure s, Function<Object,String> toText) { 262 var sb = new StringBuilder(); 263 switch (s.kind()) { 264 case UL, OL -> { 265 var n = 1; 266 for (var o : s.items()) { 267 var marker = s.kind() == Structure.Kind.UL ? "- " : (n++) + ". "; 268 line(sb, marker + indentRest(plainItem(o, toText), marker.length())); 269 } 270 } 271 case BLOCK -> { 272 for (var l : plainItem(s.items().get(0), toText).split("\n", -1)) 273 line(sb, "> " + l); 274 } 275 case TABLE -> { 276 var grid = new ArrayList<List<String>>(); 277 var head = new ArrayList<String>(); 278 for (var c : s.columns()) 279 head.add(cell(c)); 280 grid.add(head); 281 for (var r : s.rows()) { 282 var row = new ArrayList<String>(); 283 for (var o : r) 284 row.add(o instanceof Structure n ? cell(renderPlain(n, toText)) : cell(toText.apply(o))); 285 grid.add(row); 286 } 287 var widths = new int[head.size()]; 288 for (var row : grid) 289 for (var i = 0; i < row.size(); i++) 290 widths[i] = Math.max(widths[i], row.get(i).length()); 291 for (var r = 0; r < grid.size(); r++) { 292 line(sb, padRow(grid.get(r), widths)); 293 if (r == 0) { 294 var rule = new ArrayList<String>(); 295 for (var w : widths) 296 rule.add("-".repeat(Math.max(3, w))); 297 line(sb, padRow(rule, widths)); 298 } 299 } 300 } 301 } 302 return sb.toString(); 303 } 304 305 private static String plainItem(Object o, Function<Object,String> toText) { 306 return o instanceof Structure n ? renderPlain(n, toText) : plainEncode(toText.apply(o)); 307 } 308 309 private static String cell(String s) { 310 return plainEncode(s).replace('\n', ' ').replace('\t', ' '); 311 } 312 313 private static String padRow(List<String> row, int[] widths) { 314 var sb = new StringBuilder(); 315 for (var i = 0; i < row.size(); i++) { 316 if (i > 0) 317 sb.append(" "); 318 sb.append(row.get(i)); 319 if (i < row.size() - 1) 320 sb.append(" ".repeat(Math.max(0, widths[i] - row.get(i).length()))); 321 } 322 return sb.toString(); 323 } 324 325 private static void line(StringBuilder sb, String s) { 326 if (! sb.isEmpty()) 327 sb.append('\n'); 328 sb.append(s); 329 } 330}