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>&amp; &lt; &gt; " '</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>&gt; </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>\ ` * _ [ ] &lt; &gt; | ~ &amp;</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}