DefaultBlogViewController.java

  1. /*
  2.  * #%L
  3.  * *********************************************************************************************************************
  4.  *
  5.  * NorthernWind - lightweight CMS
  6.  * http://northernwind.tidalwave.it - git clone https://bitbucket.org/tidalwave/northernwind-src.git
  7.  * %%
  8.  * Copyright (C) 2011 - 2023 Tidalwave s.a.s. (http://tidalwave.it)
  9.  * %%
  10.  * *********************************************************************************************************************
  11.  *
  12.  * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
  13.  * the License. You may obtain a copy of the License at
  14.  *
  15.  *     http://www.apache.org/licenses/LICENSE-2.0
  16.  *
  17.  * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
  18.  * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  See the License for the
  19.  * specific language governing permissions and limitations under the License.
  20.  *
  21.  * *********************************************************************************************************************
  22.  *
  23.  *
  24.  * *********************************************************************************************************************
  25.  * #L%
  26.  */
  27. package it.tidalwave.northernwind.frontend.ui.component.blog;

  28. import javax.annotation.Nonnull;
  29. import java.time.Instant;
  30. import java.time.ZoneId;
  31. import java.time.ZonedDateTime;
  32. import java.time.format.DateTimeFormatter;
  33. import java.time.format.FormatStyle;
  34. import java.util.ArrayList;
  35. import java.util.Collection;
  36. import java.util.Comparator;
  37. import java.util.HashMap;
  38. import java.util.List;
  39. import java.util.Locale;
  40. import java.util.Map;
  41. import java.util.Optional;
  42. import java.util.function.Function;
  43. import it.tidalwave.util.Finder;
  44. import it.tidalwave.util.Key;
  45. import it.tidalwave.util.spi.HierarchicFinderSupport;
  46. import it.tidalwave.northernwind.core.model.Content;
  47. import it.tidalwave.northernwind.core.model.HttpStatusException;
  48. import it.tidalwave.northernwind.core.model.RequestLocaleManager;
  49. import it.tidalwave.northernwind.core.model.ResourcePath;
  50. import it.tidalwave.northernwind.core.model.ResourceProperties;
  51. import it.tidalwave.northernwind.core.model.SiteNode;
  52. import it.tidalwave.northernwind.frontend.ui.RenderContext;
  53. import it.tidalwave.northernwind.frontend.ui.spi.VirtualSiteNode;
  54. import lombok.AllArgsConstructor;
  55. import lombok.EqualsAndHashCode;
  56. import lombok.Getter;
  57. import lombok.RequiredArgsConstructor;
  58. import lombok.With;
  59. import lombok.extern.slf4j.Slf4j;
  60. import static java.util.Collections.reverseOrder;
  61. import static java.util.Collections.*;
  62. import static java.util.Comparator.*;
  63. import static java.util.stream.Collectors.*;
  64. import static javax.servlet.http.HttpServletResponse.*;
  65. import static it.tidalwave.util.CollectionUtils.split;
  66. import static it.tidalwave.util.LocalizedDateTimeFormatters.getDateTimeFormatterFor;
  67. import static it.tidalwave.northernwind.core.model.Content.*;
  68. import static it.tidalwave.northernwind.frontend.ui.component.Properties.*;
  69. import static it.tidalwave.northernwind.frontend.ui.component.nodecontainer.NodeContainerViewController.*;
  70. import static it.tidalwave.northernwind.util.UrlEncoding.encodedUtf8;
  71. import static lombok.AccessLevel.PUBLIC;

  72. /***********************************************************************************************************************
  73.  *
  74.  * <p>A default implementation of the {@link BlogViewController} that is independent of the presentation technology.
  75.  * This class is capable to render:</p>
  76.  *
  77.  * <ul>
  78.  * <li>blog posts (in various ways)</li>
  79.  * <li>an index of the blog</li>
  80.  * <li>a tag cloud</li>
  81.  * </ul>
  82.  *
  83.  * <p>It accepts path parameters as follows:</p>
  84.  *
  85.  * <ul>
  86.  * <li>{@code <uri>}: selects a single post with the given uri;</li>
  87.  * <li>{@code <category>}: selects posts with the given category;</li>
  88.  * <li>{@code tags/<tag>}: selects posts with the given tag;</li>
  89.  * <li>{@code index}: renders a post index, with links to single posts;</li>
  90.  * <li>{@code index/<category>}: renders an index of posts with the given category;</li>
  91.  * <li>{@code index/tag/<tag>}: renders an index of posts with the given tag.</li>
  92.  * </ul>
  93.  *
  94.  * <p>Supported properties of the {@link SiteNode}:</p>
  95.  *
  96.  * <ul>
  97.  * <li>{@code P_CONTENT_PATHS}: one or more {@code Content} that contains the posts to render; they are folders and can have
  98.  *     sub-folders, which will be searched for in a recursive fashion;</li>
  99.  * <li>{@code P_MAX_FULL_ITEMS}: the max. number of posts to be rendered in full;</li>
  100.  * <li>{@code P_MAX_LEADIN_ITEMS}: the max. number of posts to be rendered with lead-in text;</li>
  101.  * <li>{@code P_MAX_ITEMS}: the max. number of posts to be rendered as links;</li>
  102.  * <li>{@code P_DATE_FORMAT}: the pattern for formatting date and times;</li>
  103.  * <li>{@code P_TIME_ZONE}: the time zone for rendering dates (defaults to CET);</li>
  104.  * <li>{@code P_INDEX}: if {@code true}, forces an index rendering (useful e.g. when used in sidebars);</li>
  105.  * <li>{@code P_TAG_CLOUD}: if {@code true}, forces a tag cloud rendering (useful e.g. when used in sidebars).</li>
  106.  * </ul>
  107.  *
  108.  * <p>The {@code P_DATE_FORMAT} property accepts any valid pattern in Java 8, plus the values {@code S-}, {@code M-},
  109.  * {@code L-}, {@code F-}, which stand for small/medium/large and full patterns for a given locale.</p>
  110.  *
  111.  * <p>Supported properties of the {@link Content}:</p>
  112.  *
  113.  * <ul>
  114.  * <li>{@code P_TITLE}: the title;</li>
  115.  * <li>{@code P_FULL_TEXT}: the full text;</li>
  116.  * <li>{@code P_LEADIN_TEXT}: the lead-in text;</li>
  117.  * <li>{@code P_ID}: the unique id;</li>
  118.  * <li>{@code P_IMAGE_ID}: the id of an image representative of the post;</li>
  119.  * <li>{@code P_PUBLISHING_DATE}: the publishing date;</li>
  120.  * <li>{@code P_CREATION_DATE}: the creation date;</li>
  121.  * <li>{@code P_TAGS}: the tags;</li>
  122.  * <li>{@code P_CATEGORY}: the category.</li>
  123.  * </ul>
  124.  *
  125.  * <p>When preparing for rendering, the following dynamic properties will be set, only if a single post is rendered:</p>
  126.  *
  127.  * <ul>
  128.  * <li>{@code PD_URL}: the canonical URL of the post;</li>
  129.  * <li>{@code PD_ID}: the unique id of the post;</li>
  130.  * <li>{@code PD_IMAGE_ID}: the id of the representative image.</li>
  131.  * </ul>
  132.  *
  133.  * <p>Concrete implementations must provide two methods for rendering the blog posts and the tag cloud:</p>
  134.  *
  135.  * <ul>
  136.  * <li>{@link #renderPosts(java.util.List, java.util.List, java.util.List) }</li>
  137.  * <li>{@link #renderTagCloud(java.util.Collection)  }</li>
  138.  * </ul>
  139.  *
  140.  * @author  Fabrizio Giudici
  141.  *
  142.  **********************************************************************************************************************/
  143. @RequiredArgsConstructor @Slf4j
  144. public abstract class DefaultBlogViewController implements BlogViewController
  145.   {
  146.     /*******************************************************************************************************************
  147.      *
  148.      *
  149.      ******************************************************************************************************************/
  150.     @AllArgsConstructor(access = PUBLIC) @Getter @EqualsAndHashCode
  151.     public static class TagAndCount
  152.       {
  153.         public final String tag;
  154.         public final int count;

  155.         @With
  156.         public final String rank;

  157.         public TagAndCount (@Nonnull final String tag)
  158.           {
  159.             this(tag, 1, "");
  160.           }

  161.         @Nonnull
  162.         public TagAndCount reduced (@Nonnull final TagAndCount other)
  163.           {
  164.             if (!this.tag.equals(other.tag))
  165.               {
  166.                 throw new IllegalArgumentException("Mismatching " + this + " vs " + other);
  167.               }

  168.             return new TagAndCount(tag, this.count + other.count, "");
  169.           }

  170.         @Override @Nonnull
  171.         public String toString()
  172.           {
  173.             return String.format("TagAndCount(%s, %d, %s)", tag, count, rank);
  174.           }
  175.       }

  176.     /*******************************************************************************************************************
  177.      *
  178.      * A {@link Finder} which returns virtual {@link SiteNode}s representing the multiple contents served by the
  179.      * {@link SiteNode} associated to this controller. This is typically used to create site maps.
  180.      *
  181.      ******************************************************************************************************************/
  182.     // TODO: add eventual localized versions
  183.     @RequiredArgsConstructor
  184.     private static class VirtualSiteNodeFinder extends HierarchicFinderSupport<SiteNode, VirtualSiteNodeFinder>
  185.       {
  186.         private static final long serialVersionUID = 1L;

  187.         @Nonnull
  188.         private final transient DefaultBlogViewController controller;

  189.         public VirtualSiteNodeFinder (@Nonnull final VirtualSiteNodeFinder other, @Nonnull final Object override)
  190.           {
  191.             super(other, override);
  192.             final var source = getSource(VirtualSiteNodeFinder.class, other, override);
  193.             this.controller = source.controller;
  194.           }

  195.         @Override @Nonnull
  196.         protected List<SiteNode> computeResults()
  197.           {
  198.             return controller.findAllPosts(controller.getViewProperties())
  199.                              .stream()
  200.                              .peek(p -> log.trace(">>>> virtual node for: {}", p.getExposedUri()))
  201.                              .flatMap(post -> createVirtualNode(post).stream())
  202.                              .collect(toList());
  203.           }

  204.         @Nonnull
  205.         private Optional<VirtualSiteNode> createVirtualNode (@Nonnull final Content post)
  206.           {
  207.             final var siteNode = controller.siteNode;
  208.             return post.getExposedUri().map(uri -> new VirtualSiteNode(siteNode,
  209.                                                                        siteNode.getRelativeUri().appendedWith(uri),
  210.                                                                        post.getProperties()));
  211.           }
  212.       }

  213.     private static final Map<String, Function<Locale, DateTimeFormatter>> DATETIME_FORMATTER_MAP_BY_STYLE = new HashMap<>();

  214.     static
  215.       {
  216.         DATETIME_FORMATTER_MAP_BY_STYLE.put("S-", locale -> getDateTimeFormatterFor(FormatStyle.SHORT, locale));
  217.         DATETIME_FORMATTER_MAP_BY_STYLE.put("M-", locale -> getDateTimeFormatterFor(FormatStyle.MEDIUM, locale));
  218.         DATETIME_FORMATTER_MAP_BY_STYLE.put("L-", locale -> getDateTimeFormatterFor(FormatStyle.LONG, locale));
  219.         DATETIME_FORMATTER_MAP_BY_STYLE.put("F-", locale -> getDateTimeFormatterFor(FormatStyle.FULL, locale));
  220.       }

  221.     protected static final List<Key<ZonedDateTime>> DATE_KEYS = List.of(P_PUBLISHING_DATE, P_CREATION_DATE);

  222.     public static final ZonedDateTime TIME0 = Instant.ofEpochMilli(0).atZone(ZoneId.of("GMT"));

  223.     public static final String DEFAULT_TIMEZONE = "CET";

  224.     private static final int NO_LIMIT = 9999;

  225.     private static final String INDEX_PREFIX = "index";

  226.     private static final String TAG_PREFIX = "tag";

  227.     private static final ResourcePath TAG_CLOUD = ResourcePath.of("tags");

  228.     private static final Comparator<Content> REVERSE_DATE_COMPARATOR = (p1, p2) ->
  229.         p2.getProperty(DATE_KEYS).orElse(TIME0).compareTo(p1.getProperty(DATE_KEYS).orElse(TIME0));

  230.     @Nonnull
  231.     private final SiteNode siteNode;

  232.     @Nonnull
  233.     private final BlogView view;

  234.     @Nonnull
  235.     private final RequestLocaleManager requestLocaleManager;

  236.     private Optional<String> tag = Optional.empty();

  237.     private Optional<String> uriOrCategory = Optional.empty();

  238.     private boolean indexMode;

  239.     private boolean tagCloudMode;

  240.     protected Optional<String> title = Optional.empty();

  241.     /* VisibleForTesting */ final List<Content> fullPosts = new ArrayList<>();

  242.     /* VisibleForTesting */ final List<Content> leadInPosts = new ArrayList<>();

  243.     /* VisibleForTesting */ final List<Content> linkedPosts = new ArrayList<>();

  244.     /*******************************************************************************************************************
  245.      *
  246.      * {@inheritDoc}
  247.      *
  248.      ******************************************************************************************************************/
  249.     @Override
  250.     public void prepareRendering (@Nonnull final RenderContext context)
  251.       throws HttpStatusException
  252.       {
  253.         log.info("prepareRendering(RenderContext) for {}", siteNode);

  254.         final var viewProperties = getViewProperties();
  255.         indexMode  = viewProperties.getProperty(P_INDEX).orElse(false);
  256.         var pathParams = context.getPathParams(siteNode);
  257.         tagCloudMode = viewProperties.getProperty(P_TAG_CLOUD).orElse(false);

  258.         if (pathParams.equals(TAG_CLOUD))
  259.           {
  260.             tagCloudMode = true;
  261.           }
  262.         else if (pathParams.startsWith(INDEX_PREFIX))
  263.           {
  264.             indexMode = true;
  265.             pathParams = pathParams.withoutLeading();
  266.           }

  267.         if (pathParams.startsWith(TAG_PREFIX) && (pathParams.getSegmentCount() == 2)) // matches(TAG_PREFIX, ".*")
  268.           {
  269.             tag = Optional.of(pathParams.getTrailing());
  270.           }
  271.         else if (pathParams.getSegmentCount() == 1)
  272.           {
  273.             uriOrCategory = Optional.of(pathParams.getLeading());
  274.           }
  275.         else if (!pathParams.isEmpty())
  276.           {
  277.             throw new HttpStatusException(SC_BAD_REQUEST);
  278.           }

  279.         if (tagCloudMode)
  280.           {
  281.             setTitle(context);
  282.           }
  283.         else
  284.           {
  285.             prepareBlogPosts(context, viewProperties);

  286.             if ((fullPosts.size() == 1) && leadInPosts.isEmpty() && linkedPosts.isEmpty())
  287.               {
  288.                 setDynamicProperties(context, fullPosts.get(0));
  289.               }
  290.             else
  291.               {
  292.                 setTitle(context);
  293.               }
  294.           }
  295.       }

  296.     /*******************************************************************************************************************
  297.      *
  298.      * {@inheritDoc}
  299.      *
  300.      ******************************************************************************************************************/
  301.     @Override
  302.     public void renderView (@Nonnull final RenderContext context)
  303.       throws Exception
  304.       {
  305.         log.info("renderView() for {}", siteNode);

  306.         if (tagCloudMode)
  307.           {
  308.             renderTagCloud();
  309.           }
  310.         else
  311.           {
  312.             renderPosts(fullPosts, leadInPosts, linkedPosts);
  313.           }
  314.       }

  315.     /*******************************************************************************************************************
  316.      *
  317.      * {@inheritDoc}
  318.      *
  319.      ******************************************************************************************************************/
  320.     @Override @Nonnull
  321.     public Finder<SiteNode> findVirtualSiteNodes()
  322.       {
  323.         return new VirtualSiteNodeFinder(this);
  324.       }

  325.     /*******************************************************************************************************************
  326.      *
  327.      * Renders the blog posts. Must be implemented by concrete subclasses.
  328.      *
  329.      * @param       fullPosts       the posts to be rendered in full
  330.      * @param       leadinPosts     the posts to be rendered with lead in text
  331.      * @param       linkedPosts     the posts to be rendered as references
  332.      * @throws      Exception       if something fails
  333.      *
  334.      ******************************************************************************************************************/
  335.     @SuppressWarnings("squid:S00112")
  336.     protected abstract void renderPosts (@Nonnull List<? extends Content> fullPosts,
  337.                                          @Nonnull List<? extends Content> leadinPosts,
  338.                                          @Nonnull List<? extends Content> linkedPosts)
  339.       throws Exception;

  340.     /*******************************************************************************************************************
  341.      *
  342.      * Renders the tag cloud. Must be implemented by concrete subclasses.
  343.      *
  344.      * @param       tagsAndCount    the tags
  345.      *
  346.      ******************************************************************************************************************/
  347.     @SuppressWarnings("squid:S00112")
  348.     protected abstract void renderTagCloud (@Nonnull Collection<? extends TagAndCount> tagsAndCount);

  349.     /*******************************************************************************************************************
  350.      *
  351.      * Creates a link for a {@link ResourcePath}.
  352.      *
  353.      * @param       path    the path
  354.      * @return              the link
  355.      *
  356.      ******************************************************************************************************************/
  357.     @Nonnull
  358.     protected final String createLink (@Nonnull final ResourcePath path)
  359.       {
  360.         return siteNode.getSite().createLink(siteNode.getRelativeUri().appendedWith(path));
  361.       }

  362.     /*******************************************************************************************************************
  363.      *
  364.      * Creates a link for a tag.
  365.      *
  366.      * @param       tag     the tag
  367.      * @return              the link
  368.      *
  369.      ******************************************************************************************************************/
  370.     @Nonnull
  371.     protected final String createTagLink (final String tag)
  372.       {
  373.         // TODO: shouldn't ResourcePath always encode incoming strings?
  374.         var link = siteNode.getSite().createLink(siteNode.getRelativeUri().appendedWith(TAG_PREFIX)
  375.                                                          .appendedWith(encodedUtf8(tag)));

  376.         // TODO: Workaround because createLink() doesn't append trailing / if the link contains a dot.
  377.         // Refactor by passing a parameter to createLink that overrides the default behaviour.
  378.         if (!link.endsWith("/") && !link.contains("?"))
  379.           {
  380.             link += "/";
  381.           }

  382.         return link;
  383.       }

  384.     /*******************************************************************************************************************
  385.      *
  386.      *
  387.      ******************************************************************************************************************/
  388.     @Nonnull
  389.     protected final ResourceProperties getViewProperties()
  390.       {
  391.         return siteNode.getPropertyGroup(view.getId());
  392.       }

  393.     /*******************************************************************************************************************
  394.      *
  395.      * Formats a date with the settings taken from the configuration and the request settings.
  396.      *
  397.      * @param       dateTime        the date to render
  398.      * @return                      the formatted date
  399.      *
  400.      ******************************************************************************************************************/
  401.     @Nonnull
  402.     protected final String formatDateTime (@Nonnull final ZonedDateTime dateTime)
  403.       {
  404.         return dateTime.format(findDateTimeFormatter());
  405.       }

  406.     /*******************************************************************************************************************
  407.      *
  408.      * Prepares the blog posts.
  409.      *
  410.      * @param       context               the rendering context
  411.      * @param       properties            the view properties
  412.      * @throws      HttpStatusException   status 404 if no post found
  413.      *
  414.      ******************************************************************************************************************/
  415.     protected final void prepareBlogPosts (@Nonnull final RenderContext context, @Nonnull final ResourceProperties properties)
  416.       throws HttpStatusException
  417.       {
  418.         final var maxFullItems   = indexMode ? 0        : properties.getProperty(P_MAX_FULL_ITEMS).orElse(NO_LIMIT);
  419.         final var maxLeadinItems = indexMode ? 0        : properties.getProperty(P_MAX_LEADIN_ITEMS).orElse(NO_LIMIT);
  420.         final var maxItems       = indexMode ? NO_LIMIT : properties.getProperty(P_MAX_ITEMS).orElse(NO_LIMIT);

  421.         log.debug(">>>> preparing blog posts for {}: maxFullItems: {}, maxLeadinItems: {}, maxItems: {} (index: {}, tag: {}, uri: {})",
  422.                   view.getId(), maxFullItems, maxLeadinItems, maxItems, indexMode, tag.orElse(""), uriOrCategory.orElse(""));

  423.         final var posts = findPosts(context, properties)
  424.                 .stream()
  425.                 .filter(post -> post.getProperty(P_TITLE).isPresent())
  426.                 .sorted(REVERSE_DATE_COMPARATOR)
  427.                 .collect(toList());

  428.         if (posts.isEmpty())
  429.           {
  430.             throw new HttpStatusException(SC_NOT_FOUND);
  431.           }

  432.         final var split = split(posts, 0, maxFullItems, maxFullItems + maxLeadinItems, maxItems);
  433.         fullPosts.addAll(split.get(0));
  434.         leadInPosts.addAll(split.get(1));
  435.         linkedPosts.addAll(split.get(2));
  436.       }

  437.     /*******************************************************************************************************************
  438.      *
  439.      * Renders the tag cloud.
  440.      *
  441.      ******************************************************************************************************************/
  442.     private void renderTagCloud()
  443.       {
  444.         final var tagsAndCount = findAllPosts(getViewProperties())
  445.                 .stream()
  446.                 .flatMap(post -> post.getProperty(P_TAGS).stream().flatMap(Collection::stream))
  447.                 .collect(toMap(t -> t, TagAndCount::new, TagAndCount::reduced))
  448.                 .values()
  449.                 .stream()
  450.                 .sorted(comparing(TagAndCount::getTag))
  451.                 .collect(toList());
  452.         renderTagCloud(withRanks(tagsAndCount));
  453.       }

  454.     /*******************************************************************************************************************
  455.      *
  456.      * Finds all the relevant posts, applying filtering as needed.
  457.      *
  458.      ******************************************************************************************************************/
  459.     // TODO: use some short circuit to prevent from loading unnecessary data
  460.     @Nonnull
  461.     private List<Content> findPosts (@Nonnull final RenderContext context, @Nonnull final ResourceProperties properties)
  462.       {
  463.         final var pathParams = context.getPathParams(siteNode);
  464.         final var filtering  = tag.isPresent() || uriOrCategory.isPresent();
  465.         final var allPosts = findAllPosts(properties);
  466.         final var posts = new ArrayList<Content>();
  467.         //
  468.         // The thing works differently in function of pathParams:
  469.         //      when no pathParams, return all the posts;
  470.         //      when it matches a category, return all the posts in that category;
  471.         //      when it matches an exposed URI of a single specific post:
  472.         //          if not in 'index' mode, return only that post;
  473.         //          if in 'index' mode, returns all the posts.
  474.         //
  475.         if (indexMode && !filtering)
  476.           {
  477.             posts.addAll(allPosts);
  478.           }
  479.         else
  480.           {
  481.             if (tag.isPresent())
  482.               {
  483.                 posts.addAll(filteredByTag(allPosts, tag.get()));
  484.               }
  485.             else
  486.               {
  487.                 posts.addAll(filteredByExposedUri(allPosts, pathParams)
  488.                             // pathParams matches an exposedUri; thus it's not a category, so an index wants all
  489.                             .map(singlePost -> indexMode ? allPosts : singletonList(singlePost))
  490.                             // pathParams didn't match an exposedUri, so it's interpreted as a category to filter posts
  491.                             .orElseGet(() -> filteredByCategory(allPosts, uriOrCategory)));
  492.               }
  493.           }

  494.         log.debug(">>>> found {} items", posts.size());

  495.         return posts;
  496.       }

  497.     /*******************************************************************************************************************
  498.      *
  499.      * Finds all the posts.
  500.      *
  501.      ******************************************************************************************************************/
  502.     @Nonnull
  503.     private List<Content> findAllPosts (@Nonnull final ResourceProperties properties)
  504.       {
  505.         return properties.getProperty(P_CONTENT_PATHS).orElse(emptyList()).stream()
  506.                 .flatMap(path -> siteNode.getSite().find(_Content_).withRelativePath(path).stream()
  507.                                                                    .flatMap(folder -> folder.findChildren().stream()))
  508.                 .collect(toList());
  509.       }

  510.     /*******************************************************************************************************************
  511.      *
  512.      * Returns the proper {@link DateTimeFormatter}. It is built from an explicit pattern, if defined in the current
  513.      * {@link SiteNode}; otherwise the one provided by the {@link RequestLocaleManager} is used. The formatter is
  514.      * configured with the time zone defined in the {@code SiteNode}, or a default is used.
  515.      *
  516.      * @return      the {@code DateTimeFormatter}
  517.      *
  518.      ******************************************************************************************************************/
  519.     @Nonnull
  520.     private DateTimeFormatter findDateTimeFormatter()
  521.       {
  522.         final var locale = requestLocaleManager.getLocales().get(0);
  523.         final var viewProperties = getViewProperties();
  524.         final var dtf = viewProperties.getProperty(P_DATE_FORMAT)
  525.                                       .map(s -> s.replaceAll("EEEEE+", "EEEE"))
  526.                                       .map(s -> s.replaceAll("MMMMM+", "MMMM"))
  527.                                       .map(p -> (((p.length() == 2) ? DATETIME_FORMATTER_MAP_BY_STYLE.get(p).apply(locale)
  528.                                           : DateTimeFormatter.ofPattern(p)).withLocale(locale)))
  529.                                       .orElse(requestLocaleManager.getDateTimeFormatter());

  530.         final var zoneId = viewProperties.getProperty(P_TIME_ZONE).orElse(DEFAULT_TIMEZONE);
  531.         return dtf.withZone(ZoneId.of(zoneId));
  532.       }

  533.     /*******************************************************************************************************************
  534.      *
  535.      *
  536.      *
  537.      ******************************************************************************************************************/
  538.     private void setDynamicProperties (@Nonnull final RenderContext context, @Nonnull final Content post)
  539.       {
  540.         context.setDynamicNodeProperty(PD_TITLE, computeTitle(post));
  541.         post.getExposedUri().map(this::createLink).ifPresent(l -> context.setDynamicNodeProperty(PD_URL, l));
  542.         post.getProperty(P_ID).ifPresent(id -> context.setDynamicNodeProperty(PD_ID, id));
  543.         post.getProperty(P_IMAGE_ID).ifPresent(id -> context.setDynamicNodeProperty(PD_IMAGE_ID, id));
  544.       }

  545.     /*******************************************************************************************************************
  546.      *
  547.      *
  548.      ******************************************************************************************************************/
  549.     private void setTitle (@Nonnull final RenderContext context)
  550.       {
  551.         if (tagCloudMode)
  552.           {
  553.             title = Optional.of("Tags");
  554.           }
  555.         else if (indexMode)
  556.           {
  557.             title = Optional.of("Post index");

  558.             if (tag.isPresent())
  559.               {
  560.                 title = Optional.of(String.format("Posts tagged as '%s'", tag.get()));
  561.               }
  562.             else uriOrCategory.ifPresent(s -> title = Optional.of(String.format("Posts in category '%s'", s)));
  563.           }
  564.         else
  565.           {
  566.             title = getViewProperties().getProperty(P_TITLE).map(String::trim).flatMap(DefaultBlogViewController::filterEmptyString);
  567.           }

  568.         title.ifPresent(view::setTitle);
  569.         title.ifPresent(s -> context.setDynamicNodeProperty(PD_TITLE, s));
  570.       }

  571.     /*******************************************************************************************************************
  572.      *
  573.      *
  574.      ******************************************************************************************************************/
  575.     @Nonnull
  576.     private String computeTitle (@Nonnull final Content post)
  577.       {
  578.         final var prefix    = siteNode.getProperty(P_TITLE).orElse("");
  579.         final var title     = post.getProperty(P_TITLE).orElse("");
  580.         final var separator = "".equals(prefix) || "".equals(title) ? "" : " - ";

  581.         return prefix + separator + title;
  582.       }

  583.     /*******************************************************************************************************************
  584.      *
  585.      *
  586.      ******************************************************************************************************************/
  587.     @Nonnull
  588.     private static List<TagAndCount> withRanks (@Nonnull final Collection<? extends TagAndCount> tagsAndCount)
  589.       {
  590.         final var counts = tagsAndCount.stream()
  591.                                        .map(TagAndCount::getCount)
  592.                                        .distinct()
  593.                                        .sorted(reverseOrder())
  594.                                        .collect(toList());
  595.         return tagsAndCount.stream().map(tac -> tac.withRank(rankOf(tac.count, counts))).collect(toList());
  596.       }

  597.     /*******************************************************************************************************************
  598.      *
  599.      * Filters the given posts that match the selected category; returns all the posts if the category is empty.
  600.      *
  601.      * @param  posts          the source posts
  602.      * @param  category       the category
  603.      * @return                the filtered posts
  604.      *
  605.      ******************************************************************************************************************/
  606.     @Nonnull
  607.     private static List<Content> filteredByCategory (@Nonnull final List<? extends Content> posts,
  608.                                                      @Nonnull final Optional<String> category)
  609.       {
  610.         return posts.stream().filter(post -> hasCategory(post, category)).collect(toList());
  611.       }

  612.     /*******************************************************************************************************************
  613.      *
  614.      * Filters the {@code sourcePosts} that matches the selected{@code tag}; returns all
  615.      * posts if the category is empty.
  616.      *
  617.      * @param  posts          the source posts
  618.      * @param  tag            the tag
  619.      * @return                the filtered posts
  620.      *
  621.      ******************************************************************************************************************/
  622.     @Nonnull
  623.     private static List<Content> filteredByTag (@Nonnull final List<? extends Content> posts, @Nonnull final String tag)
  624.       {
  625.         return posts.stream().filter(post -> hasTag(post, tag)).collect(toList());
  626.       }

  627.     /*******************************************************************************************************************
  628.      *
  629.      *
  630.      ******************************************************************************************************************/
  631.     @Nonnull
  632.     private static Optional<Content> filteredByExposedUri (@Nonnull final List<Content> posts,
  633.                                                            @Nonnull final ResourcePath exposedUri)
  634.       {
  635.         return posts.stream().filter(post -> post.getExposedUri().map(exposedUri::equals).orElse(false)).findFirst();
  636.       }

  637.     /*******************************************************************************************************************
  638.      *
  639.      *
  640.      ******************************************************************************************************************/
  641.     @Nonnull
  642.     private static String rankOf (final int count, final List<Integer> counts)
  643.       {
  644.         assert counts.contains(count);
  645.         final var rank = counts.indexOf(count) + 1;
  646.         return (rank <= 10) ? Integer.toString(rank) : "Others";
  647.       }

  648.     /*******************************************************************************************************************
  649.      *
  650.      *
  651.      ******************************************************************************************************************/
  652.     private static boolean hasCategory (@Nonnull final Content post, @Nonnull final Optional<String> category)
  653.       {
  654.         return category.isEmpty() || post.getProperty(P_CATEGORY).equals(category);
  655.       }

  656.     /*******************************************************************************************************************
  657.      *
  658.      *
  659.      ******************************************************************************************************************/
  660.     private static boolean hasTag (@Nonnull final Content post, @Nonnull final String tag)
  661.       {
  662.         return post.getProperty(P_TAGS).orElse(emptyList()).contains(tag);
  663.       }

  664.     /*******************************************************************************************************************
  665.      *
  666.      *
  667.      *
  668.      ******************************************************************************************************************/
  669.     @Nonnull
  670.     private static Optional<String> filterEmptyString (@Nonnull final String s)
  671.       {
  672.         return "".equals(s) ? Optional.empty() : Optional.of(s);
  673.       }
  674.   }