View Javadoc
1   /*
2   * Copyright 2012-2025 Christophe Friederich
3   *
4   * Licensed under the Apache License, Version 2.0 (the "License");
5   * you may not use this file except in compliance with the License.
6   * You may obtain a copy of the License at
7   *
8   * http://www.apache.org/licenses/LICENSE-2.0
9   *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16  package org.devacfr.maven.skins.reflow;
17  
18  import static java.util.Objects.requireNonNull;
19  
20  import com.google.common.base.Strings;
21  import java.text.ParseException;
22  import java.text.SimpleDateFormat;
23  import java.util.Date;
24  import java.util.List;
25  import java.util.Optional;
26  import javax.annotation.Nonnull;
27  import javax.annotation.Nullable;
28  import org.apache.commons.lang3.StringUtils;
29  import org.apache.maven.doxia.site.PublishDate;
30  import org.apache.maven.doxia.site.SiteModel;
31  import org.apache.maven.project.MavenProject;
32  import org.apache.velocity.tools.ToolContext;
33  import org.apache.velocity.tools.config.DefaultKey;
34  import org.apache.velocity.tools.generic.DateTool;
35  import org.apache.velocity.tools.generic.RenderTool;
36  import org.apache.velocity.tools.generic.ResourceTool;
37  import org.apache.velocity.tools.generic.SafeConfig;
38  import org.apache.velocity.tools.generic.ValueParser;
39  import org.codehaus.plexus.util.PathTool;
40  import org.codehaus.plexus.util.xml.Xpp3Dom;
41  import org.devacfr.maven.skins.reflow.context.Context;
42  import org.devacfr.maven.skins.reflow.context.PositionType;
43  import org.devacfr.maven.skins.reflow.snippet.Component;
44  import org.devacfr.maven.skins.reflow.snippet.SnippetComponent;
45  import org.devacfr.maven.skins.reflow.snippet.SnippetContext;
46  import org.devacfr.maven.skins.reflow.snippet.SnippetParser;
47  import org.jsoup.nodes.Document;
48  import org.slf4j.Logger;
49  import org.slf4j.LoggerFactory;
50  
51  /**
52   * An Apache Velocity tool that simplifies retrieval of custom configuration values for a Maven Site.
53   * <p>
54   * The tool is configured to access Maven site configuration of a skin inside {@code <custom>} element of site
55   * descriptor. It supports global properties (defined at skin level) and per-page properties (defined in
56   * {@code <page><mypage>} element). The per-page properties override the global ones.
57   * </p>
58   * <p>
59   * A sample configuration would be like that:
60   * </p>
61   *
62   * <pre>
63   * {@code
64   * <custom>
65   *   <reflowSkin>
66   *     <prop1>value1</prop1>
67   *     <prop2>
68   *       <prop21>value2</prop21>
69   *     </prop2>
70   *     <pages>
71   *       <mypage project="myproject">
72   *         <prop1>override value1</prop1>
73   *       </mypage>
74   *     </pages>
75   *   </reflowSkin>
76   * </custom>
77   * }
78   * </pre>
79   * <p>
80   * To get the value of {@code prop1}, one would simply use {@code $config.prop1}. This would return "override value1".
81   * Then {@code $config.prop2} would return "value2" - the global value.
82   * </p>
83   * <p>
84   * The tool allows querying the value easily, falling back from page to global configuration to {@code null}, if none is
85   * available. It also provides convenience accessors for common values.
86   * </p>
87   * <p>
88   * Note
89   * </p>
90   *
91   * @author Andrius Velykis
92   * @author Christophe Friederich
93   * @since 1.0
94   */
95  @DefaultKey("config")
96  public class SkinConfigTool extends SafeConfig implements ISkinConfig {
97  
98    /** */
99    private static final Logger LOGGER = LoggerFactory.getLogger(SkinConfigTool.class);
100 
101   // ISO 8601 BASIC is used by build timestamp
102   private static final SimpleDateFormat ISO_8601BASIC_DATE = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'");
103 
104   private static final String PROJECT_BUILD_OUTPUTTIMESTAMP = "project.build.outputTimestamp";
105 
106   /** */
107   public static final String DEFAULT_KEY = "config";
108 
109   /** By default use Reflow skin configuration tag. */
110   public static final String SKIN_KEY = "reflowSkin";
111 
112   /** */
113   private String key = DEFAULT_KEY;
114 
115   /** */
116   private String skinKey = SKIN_KEY;
117 
118   /** Create dummy nodes to avoid null checks. */
119   private Xpp3Dom globalProperties = new Xpp3Dom("");
120 
121   /** */
122   private Xpp3Dom pageProperties = new Xpp3Dom("");
123 
124   /** */
125   private String namespace = "";
126 
127   /** */
128   private String projectId = null;
129 
130   /** */
131   private String fileId = null;
132 
133   /** */
134   private Context<?> context = null;
135 
136   /** */
137   private MavenProject project = null;
138 
139   /** */
140   private SiteModel siteModel;
141 
142   /** */
143   private ToolContext velocityContext;
144 
145   /**
146    * {@inheritDoc}
147    *
148    * @see SafeConfig#configure(ValueParser)
149    */
150   @Override
151   protected void configure(final ValueParser values) {
152     final String altkey = values.getString("key");
153     if (altkey != null) {
154       setKey(altkey);
155     }
156 
157     // allow changing skin key in the configuration
158     final String altSkinKey = values.getString("skinKey");
159     if (altSkinKey != null) {
160       this.skinKey = altSkinKey;
161     }
162 
163     // retrieve the site model from Velocity context
164     final Object vc = values.get("velocityContext");
165 
166     if (!(vc instanceof ToolContext)) {
167       return;
168     }
169 
170     this.velocityContext = (ToolContext) vc;
171 
172     final Object projectObj = velocityContext.get("project");
173     if (projectObj instanceof MavenProject) {
174       this.project = (MavenProject) projectObj;
175       final String artifactId = project.getArtifactId();
176       // use artifactId "sluggified" as the projectId
177       projectId = HtmlTool.slug(artifactId);
178     }
179 
180     // calculate the page ID from the current file name
181     final String currentFileObj = getCurrentFileName();
182     fileId = slugFilename(currentFileObj);
183 
184     final Object siteModelObj = velocityContext.get("site");
185 
186     if (!(siteModelObj instanceof SiteModel)) {
187       return;
188     }
189 
190     this.siteModel = (SiteModel) siteModelObj;
191     final Object customObj = siteModel.getCustom();
192 
193     if (!(customObj instanceof Xpp3Dom)) {
194       return;
195     }
196 
197     // Now that we have the custom node, get the global properties
198     // under the skin tag
199     final Xpp3Dom customNode = (Xpp3Dom) customObj;
200     Xpp3Dom skinNode = customNode.getChild(skinKey);
201     final String namespaceKey = ":" + skinKey;
202 
203     if (skinNode == null) {
204       // try searching with any namespace
205       for (final Xpp3Dom child : customNode.getChildren()) {
206         if (child.getName().endsWith(namespaceKey)) {
207           skinNode = child;
208           break;
209         }
210       }
211     }
212 
213     if (skinNode != null) {
214       globalProperties = skinNode;
215 
216       if (skinNode.getName().endsWith(namespaceKey)) {
217         // extract the namespace (including the colon)
218         namespace = Strings
219             .emptyToNull(skinNode.getName().substring(0, skinNode.getName().length() - namespaceKey.length() + 1));
220       }
221 
222       // for page properties, retrieve the file name and drop the `.html`
223       // extension - this will be used, i.e. `index` instead of `index.html`
224       final Xpp3Dom pagesNode = Xpp3Utils.getFirstChild(skinNode, "pages", namespace);
225       if (pagesNode != null) {
226 
227         // Get the page for the file
228         Xpp3Dom page = Xpp3Utils.getFirstChild(pagesNode, fileId, namespace);
229 
230         // Now check if the project artifact ID is set, and if so, if it matches the
231         // current project. This allows preventing accidental reuse of parent page
232         // configs in children modules
233         if (page != null && projectId != null) {
234           final String pageProject = page.getAttribute("project");
235           if (pageProject != null && !projectId.equals(pageProject)) {
236             // project ID indicated, and is different - do not use the config
237             page = null;
238           }
239         }
240 
241         if (page != null) {
242           pageProperties = page;
243         }
244       }
245 
246       // Config option <localResources>true</localResources> to force CDN-less
247       // Bootstrap & jQuery
248       this.velocityContext.put("localResources", is("localResources"));
249       // Use config option
250       // <absoluteResourceURL>http://mysite.com/</absoluteResourceURL>
251       this.velocityContext.put("resourcePath", getResourcePath());
252 
253       this.context = Context.buildContext(this);
254     }
255     if (LOGGER.isDebugEnabled()) {
256       LOGGER.debug("Current Filename: {}", currentFileObj);
257       LOGGER.debug("title: {}", velocityContext.get("title"));
258       LOGGER.debug("shortTitle: {}", velocityContext.get("shortTitle"));
259       LOGGER.debug("Project id: {}", projectId);
260       LOGGER.debug("File id: {}", fileId);
261       LOGGER.debug("Context: {}", this.context);
262       LOGGER.debug("Namespace: {}", this.namespace);
263       LOGGER.debug("---------------------------------------------------");
264     }
265   }
266 
267   /**
268    * {@inheritDoc}
269    */
270   @Nonnull
271   @Override
272   public String renderSnippets(final String bodyContent) throws Exception {
273     final SnippetContext snippetContext = new SnippetParser().parse(this, bodyContent);
274     return snippetContext.html();
275   }
276 
277   /**
278    * {@inheritDoc}
279    */
280   @Nonnull
281   @Override
282   public String renderSnippet(final String snippet) throws Exception {
283     final SnippetParser parser = new SnippetParser();
284     final Document doc = getHtmlTool().parse(snippet);
285     final SnippetContext context = parser.getSnippetContext();
286     final Component<?> component = context.create(doc.body().firstElementChild(), null);
287     context.setConfig(this);
288     if (component instanceof SnippetComponent<?>) {
289       ((SnippetComponent<?>) component).render(context);
290     }
291     return doc.body().html();
292   }
293 
294   /**
295    * Sets the key under which this tool has been configured.
296    *
297    * @param key
298    *          the key of config
299    * @since 1.0
300    */
301   protected void setKey(final String key) {
302     this.key = requireNonNull(key, "SkinConfigTool key cannot be null");
303   }
304 
305   /**
306    * @return Returns the key under which this tool has been configured. The default is `config`.
307    * @since 1.0
308    */
309   public String getKey() {
310     return this.key;
311   }
312 
313   /**
314    * {@inheritDoc}
315    */
316   @Override
317   @Nullable public <T> T getContextValue(@Nonnull final String key, @Nonnull final Class<T> type) {
318     requireNonNull(type);
319     if (String.class.isAssignableFrom(type)) {
320       return this.eval("$" + key, type);
321     } else {
322       throw new UnsupportedOperationException();
323     }
324   }
325 
326   /**
327    * {@inheritDoc}
328    */
329   @Override
330   public void setContextValue(@Nonnull final String key, @Nullable final Object value) {
331     requireNonNull(key);
332     if (value instanceof String) {
333       this.eval("#set( $" + key + "= \"" + value.toString() + "\")", Void.class);
334     } else {
335       throw new UnsupportedOperationException();
336     }
337   }
338 
339   /**
340    * {@inheritDoc}
341    */
342   @Override
343   @Nullable @SuppressWarnings("unchecked")
344   public <T> T getToolbox(@Nonnull final String toolName, @Nonnull final Class<T> toolType) {
345     requireNonNull(toolType);
346     return (T) this.velocityContext.getToolbox().get(requireNonNull(toolName));
347   }
348 
349   /**
350    * Gets the tool {@link HtmlTool}.
351    *
352    * @return Returns the tool {@link HtmlTool}.
353    * @since 2.1
354    */
355   @Override
356   @Nonnull
357   public HtmlTool getHtmlTool() {
358     return requireNonNull(getToolbox("htmlTool", HtmlTool.class), "htmlTool is required");
359   }
360 
361   /**
362    * {@inheritDoc}
363    */
364   @Override
365   @Nullable public Xpp3Dom get(@Nonnull final String property) {
366     requireNonNull(property);
367     // first try page properties
368     Xpp3Dom propNode = Xpp3Utils.getFirstChild(pageProperties, property, namespace);
369     if (propNode == null) {
370       // try global
371       propNode = Xpp3Utils.getFirstChild(globalProperties, property, namespace);
372     }
373 
374     return propNode;
375   }
376 
377   /**
378    * Retrieves the text value of the given {@code property}, e.g. as in {@code <myprop>value</myprop>}.
379    *
380    * @param property
381    *          the property of interest
382    * @return the configuration value if found in page or globally, {@code null} otherwise.
383    * @see #get(String)
384    * @since 1.0
385    */
386   @Nullable public String value(@Nonnull final String property) {
387     requireNonNull(property);
388     final Xpp3Dom propNode = get(property);
389 
390     if (propNode == null) {
391       // not found
392       return null;
393     }
394 
395     return propNode.getValue();
396   }
397 
398   /**
399    * {@inheritDoc}
400    */
401   @Override
402   @Nullable public <T> T getPropertyValue(@Nonnull final String property,
403     @Nonnull final Class<T> targetType,
404     @Nullable final T defaultValue) {
405     return getPropertyValue(property, targetType).orElse(defaultValue);
406   }
407 
408   /**
409    * {@inheritDoc}
410    */
411   @Override
412   @SuppressWarnings("unchecked")
413   @Nonnull
414   public <T> Optional<T> getPropertyValue(@Nonnull final String property, @Nonnull final Class<T> targetType) {
415     requireNonNull(property, "property is required");
416     requireNonNull(targetType, "targetType is required");
417     final String value = value(property);
418     if (value == null) {
419       return Optional.empty();
420     }
421     Object returnedValue = value;
422     if (targetType.isAssignableFrom(Boolean.class)) {
423       returnedValue = Boolean.valueOf(value);
424     } else if (targetType.isAssignableFrom(Integer.class)) {
425       returnedValue = Integer.valueOf(value);
426     } else if (targetType.isAssignableFrom(Long.class)) {
427       returnedValue = Long.valueOf(value);
428     }
429     return Optional.ofNullable((T) returnedValue);
430   }
431 
432   /**
433    * Gets the list of all children name for the {@code parentNode}.
434    *
435    * @param parentNode
436    *          the parent node to use (can be {@code null}.
437    * @return Returns a list of {@link String} representing the name of all children, which may be empty but never
438    *         {@code null}.
439    * @since 1.3
440    */
441   public List<String> getChildren(final Xpp3Dom parentNode) {
442     return Xpp3Utils.getChildren(parentNode);
443   }
444 
445   /**
446    * {@inheritDoc}
447    */
448   @Override
449   @Nullable public <T> T getAttributeValue(@Nonnull final String property,
450     @Nonnull final String attribute,
451     @Nonnull final Class<T> targetType,
452     @Nullable final T defaultValue) {
453     return getAttributeValue(property, attribute, targetType).orElse(defaultValue);
454   }
455 
456   /**
457    * {@inheritDoc}
458    */
459   @Override
460   @SuppressWarnings("unchecked")
461   @Nonnull
462   public <T> Optional<T> getAttributeValue(@Nonnull final String property,
463     @Nonnull final String attribute,
464     @Nonnull final Class<T> targetType) {
465     requireNonNull(property, "property is required");
466     requireNonNull(attribute, "attribute is required");
467     requireNonNull(targetType, "targetType is required");
468 
469     Xpp3Dom element = get(property);
470     if (element == null) {
471       return Optional.empty();
472     }
473     String value = element.getAttribute(attribute);
474     if (value == null) {
475       return Optional.empty();
476     }
477 
478     if ("inherit".equals(value) || Strings.isNullOrEmpty(value)) {
479       element = Xpp3Utils.getFirstChild(globalProperties, property, namespace);
480       if (LOGGER.isDebugEnabled()) {
481         LOGGER.debug("Inherit value property '{}': {}", property, element);
482       }
483     }
484     if (element == null) {
485       return Optional.empty();
486     }
487     value = element.getAttribute(attribute);
488     if (value == null) {
489       return Optional.empty();
490     }
491 
492     Object returnedValue = value;
493     if (targetType.isAssignableFrom(Boolean.class)) {
494       returnedValue = Boolean.valueOf(value);
495     } else if (targetType.isAssignableFrom(Integer.class)) {
496       returnedValue = Integer.valueOf(value);
497     } else if (targetType.isAssignableFrom(Long.class)) {
498       returnedValue = Long.valueOf(value);
499     }
500     return Optional.ofNullable((T) returnedValue);
501   }
502 
503   /**
504    * {@inheritDoc}
505    */
506   @Override
507   @Nullable public <T> T getAttributeValue(@Nonnull final Xpp3Dom element,
508     @Nonnull final String attribute,
509     @Nonnull final Class<T> targetType,
510     @Nullable final T defaultValue) {
511     return getAttributeValue(element, attribute, targetType).orElse(defaultValue);
512   }
513 
514   /**
515    * {@inheritDoc}
516    */
517   @Override
518   @SuppressWarnings("unchecked")
519   @Nonnull
520   public <T> Optional<T> getAttributeValue(@Nonnull final Xpp3Dom element,
521     @Nonnull final String attribute,
522     @Nonnull final Class<T> targetType) {
523     final String value = element.getAttribute(attribute);
524     if (value == null) {
525       return Optional.empty();
526     }
527     Object returnedValue = value;
528     if (targetType.isAssignableFrom(Boolean.class)) {
529       returnedValue = Boolean.valueOf(value);
530     } else if (targetType.isAssignableFrom(Integer.class)) {
531       returnedValue = Integer.valueOf(value);
532     } else if (targetType.isAssignableFrom(Long.class)) {
533       returnedValue = Long.valueOf(value);
534     }
535     return Optional.ofNullable((T) returnedValue);
536   }
537 
538   /**
539    * A convenience method to check if the value of the {@code property} is {@code "true"}.
540    *
541    * @param property
542    *          the property of interest
543    * @return {@code true} if the configuration value is set either in page or globally, and is equal to {@code "true"}.
544    * @see #get(String)
545    * @since 1.0
546    */
547   public boolean is(final String property) {
548     return "true".equals(value(property));
549   }
550 
551   /**
552    * {@inheritDoc}
553    */
554   @Override
555   public boolean not(final String property) {
556     return "false".equals(value(property));
557   }
558 
559   /**
560    * A convenience method to check if the {@code property} is set to a specific value.
561    *
562    * @param property
563    *          the property of interest
564    * @param value
565    *          the property value to check
566    * @return {@code true} if the configuration value is set either in page or globally, and is equal to {@code value}.
567    * @see #get(String)
568    * @since 1.0
569    */
570   public boolean isValue(final String property, final String value) {
571     return value != null && value.equals(value(property));
572   }
573 
574   /**
575    * {@inheritDoc}
576    */
577   @Override
578   @Nullable public String getProjectId() {
579     return projectId;
580   }
581 
582   /**
583    * {@inheritDoc}
584    */
585   @Override
586   @Nullable public String getFileId() {
587     return fileId;
588   }
589 
590   /**
591    * {@inheritDoc}
592    */
593   @SuppressWarnings("unchecked")
594   @Override
595   @Nonnull
596   public <T extends Context<Context<?>>> T getContext() {
597     return (T) context;
598   }
599 
600   /**
601    * {@inheritDoc}
602    */
603   @Override
604   public org.apache.velocity.context.Context getVelocityContext() {
605     return velocityContext;
606   }
607 
608   /**
609    * {@inheritDoc}
610    */
611   @Override
612   @Nonnull
613   public MavenProject getProject() {
614     return project;
615   }
616 
617   /**
618    * {@inheritDoc}
619    */
620   @Override
621   @Nonnull
622   public SiteModel getSiteModel() {
623     return siteModel;
624   }
625 
626   /**
627    * {@inheritDoc}
628    */
629   @Override
630   @Nonnull
631   public Xpp3Dom getPageProperties() {
632     return pageProperties;
633   }
634 
635   /**
636    * {@inheritDoc}
637    */
638   @Override
639   @Nonnull
640   public Xpp3Dom getGlobalProperties() {
641     return globalProperties;
642   }
643 
644   /**
645    * {@inheritDoc}
646    */
647   @SuppressWarnings("null")
648   @Override
649   @Nonnull
650   public String getShortTitle() {
651     String shortTitle = getPropertyValue("shortTitle", String.class).orElse((String) velocityContext.get("shortTitle"));
652     // Page title generation is enabled by default, so check for false and negate
653     if (shortTitle == null || shortTitle.isEmpty() || "generate".equalsIgnoreCase(shortTitle)
654         || "true".equalsIgnoreCase(shortTitle)) {
655       // Generate the title.
656       // To generate the title, we take the contents of the first h1 tag in the
657       // document.
658       // If it does not exist, we take the contents of the first h2 tag.
659       final HtmlTool htmlTool = getHtmlTool();
660       final String body = getBodyContent();
661       List<String> hTexts = htmlTool.text(body, "h1");
662       if (hTexts.isEmpty()) {
663         hTexts = htmlTool.text(body, "h2");
664       }
665       if (!hTexts.isEmpty()) {
666         shortTitle = hTexts.get(0);
667       }
668     }
669     return shortTitle;
670   }
671 
672   /**
673    * {@inheritDoc}
674    */
675   @Override
676   @Nonnull
677   public String getTitle() {
678     final String shortTitle = this.getShortTitle();
679     String title = getPropertyValue("title", String.class).orElse((String) velocityContext.get("title"));;
680     // If title template is provided, use it to generate title.
681     // Also regenerate title if a new short title was generated.
682     String titleTemplate = getPropertyValue("titleTemplate", String.class).orElse("%1$s - %2$s");
683     if (shortTitle != null) {
684       final SiteModel model = this.getSiteModel();
685       // the company/project name is retrieved from site model ( <project name="Foo">)
686       // or from the project itself
687       String titleProjectName = title;
688       if (titleProjectName == null || titleProjectName.isEmpty()) {
689         titleProjectName = null;
690         if (model.getName() != null) {
691           titleProjectName = model.getName();
692         } else if (getProject() != null && getProject().getName() != null) {
693           titleProjectName = getProject().getName();
694         }
695       }
696 
697       if (titleProjectName == null) {
698         return shortTitle;
699       }
700       // use String.format() to create new title
701       try {
702         // try to format the title according to the template
703         title = String.format(titleTemplate, titleProjectName, shortTitle);
704       } catch (final Exception e) {
705         LOGGER.warn(
706           "Cannot format title with template '{}' and tiltle '{}' and short title '{}', falling back to default format.",
707           titleTemplate,
708           titleProjectName,
709           title,
710           e);
711       }
712     }
713     return title;
714   }
715 
716   /**
717    * {@inheritDoc}
718    */
719   @SuppressWarnings("null")
720   @Override
721   @Nonnull
722   public String getPublishDate() {
723     final SiteModel model = this.getSiteModel();
724     final PublishDate publishDate = model.getPublishDate();
725     String publishDateValue = null;
726     String format = "yyyy-MM-dd";
727     if (!"none".equals(publishDate.getPosition())) {
728       if (publishDate.getFormat() != null && !publishDate.getFormat().isEmpty()) {
729         format = publishDate.getFormat();
730       }
731       Date outputTimestamp = null;
732       try {
733         outputTimestamp = getBuildOutputTimestamp();
734       } catch (final ParseException e) {
735         LOGGER.warn("Cannot parse the build output timestamp!", e);
736         return null;
737       }
738 
739       final DateTool dateTool = getToolbox("date", DateTool.class);
740       if (dateTool != null) {
741         if (outputTimestamp != null) {
742           publishDateValue = dateTool.format(format, outputTimestamp);
743         } else {
744           publishDateValue = dateTool.get(format);
745         }
746       }
747     }
748     return publishDateValue;
749   }
750 
751   /**
752    * {@inheritDoc}
753    */
754   @SuppressWarnings("null")
755   @Override
756   @Nonnull
757   public String getPublishDateMessage() {
758     final String publishDate = getPublishDate();
759     if (Strings.isNullOrEmpty(publishDate)) {
760       return null;
761     }
762     final ResourceTool text = this.getToolbox("text", ResourceTool.class);
763     if (text == null) {
764       return null;
765     }
766     return text.get("template.lastpublished") + ": " + publishDate;
767   }
768 
769   /**
770    * {@inheritDoc}
771    */
772   @Override
773   @Nonnull
774   public String getDatePosition() {
775     // resolve date position, or set the default otherwise (bottom)
776     // Note, that currently we do not support "navigation-top" position for
777     // either publishDate or version.
778     PositionType datePosition = PositionType.none;
779     final SiteModel model = this.getSiteModel();
780     if (model != null) {
781       final PublishDate publishDate = model.getPublishDate();
782       if (publishDate != null) {
783         final String position = publishDate.getPosition();
784         if (position != null && !position.isEmpty()) {
785           datePosition = PositionType.valueOfHyphen(position);
786         }
787       }
788     }
789     if (datePosition == null) {
790       datePosition = PositionType.none;
791     }
792     return datePosition.toString();
793   }
794 
795   /**
796    * {@inheritDoc}
797    */
798   @Override
799   @Nonnull
800   public String getVersion() {
801     final MavenProject project = this.getProject();
802     return project.getVersion();
803   }
804 
805   /**
806    * {@inheritDoc}
807    */
808   @Override
809   @Nonnull
810   public String getVersionPosition() {
811     final SiteModel model = this.getSiteModel();
812     if (model == null || model.getVersion() == null) {
813       return PositionType.none.toString();
814     }
815     PositionType position = PositionType.valueOfHyphen(model.getVersion().getPosition());
816     if (position == null) {
817       position = PositionType.none;
818     }
819     return position.toString();
820   }
821 
822   /**
823    * {@inheritDoc}
824    */
825   @SuppressWarnings("null")
826   @Override
827   @Nonnull
828   public String getVersionMessage() {
829     final String version = getVersion();
830     if (Strings.isNullOrEmpty(version)) {
831       return null;
832     }
833     final ResourceTool text = this.getToolbox("text", ResourceTool.class);
834     if (text == null) {
835       return null;
836     }
837     return text.get("template.version") + ": " + version;
838   }
839 
840   /**
841    * {@inheritDoc}
842    */
843   @Override
844   @Nonnull
845   public String getNamespace() {
846     return namespace;
847   }
848 
849   /**
850    * @return Returns the html body content stored in velocity context.
851    * @since 2.1
852    */
853   @Override
854   @Nonnull
855   public String getBodyContent() {
856     return requireNonNull(getContextValue("bodyContent", String.class));
857   }
858 
859   /**
860    * @return Returns the location of project.
861    */
862   @Nonnull
863   public String getProjectLocation() {
864     String projectSiteLoc = getProject().getUrl();
865     if (!Strings.isNullOrEmpty(projectSiteLoc)) {
866 
867       if (!projectSiteLoc.endsWith("/")) {
868         projectSiteLoc += "/";
869       }
870     } else {
871       LOGGER.warn("the property project.url is required");
872       projectSiteLoc = "https://localhost/";
873     }
874     return projectSiteLoc;
875   }
876 
877   /**
878    * <p>
879    * See <a href= "https://maven.apache.org/doxia/doxia-sitetools/doxia-site-renderer/">Doxia Sitetools - Site
880    * Renderer</a> for more information.
881    *
882    * @return Returns a {@link String} representing the name of current file of the (HTML) document being rendered,
883    *         relative to the site root.
884    */
885   @Nonnull
886   public String getCurrentFileName() {
887     return (String) velocityContext.get("currentFileName");
888   }
889 
890   /**
891    * @return Returns a {@link String} representing the location path of current rendered file.
892    */
893   @Nonnull
894   public String getCurrentFileLocation() {
895     final String projectSiteLoc = getProjectLocation();
896     return URITool.toURI(projectSiteLoc).resolve(getCurrentFileName()).toString();
897   }
898 
899   /**
900    * {@inheritDoc}
901    */
902   @Override
903   @SuppressWarnings("unchecked")
904   @Nullable public <T> T eval(@Nullable final String vtl, @Nonnull final Class<T> requiredClass) {
905     if (vtl == null) {
906       return null;
907     }
908     final RenderTool renderTool = (RenderTool) getVelocityContext().get("render");
909     try {
910       return (T) renderTool.eval(getVelocityContext(), vtl);
911     } catch (final Exception ex) {
912       throw new RuntimeException("error when try evaluate '" + vtl + "'", ex);
913     }
914   }
915 
916   /**
917    * {@inheritDoc}
918    */
919   @Override
920   public String relativeLink(final String href) {
921     if (href == null) {
922       return null;
923     }
924     if (isExternalLink(href)) {
925       return href;
926     }
927     final String relativePath = (String) velocityContext.get("relativePath");
928     String relativeLink = PathTool.calculateLink(href, relativePath);
929     relativeLink = relativeLink.replaceAll("\\\\", "/");
930     if (Strings.isNullOrEmpty(relativeLink)) {
931       relativeLink = "./";
932     }
933     // Attempt to normalise the relative link - this is useful for active link
934     // calculations and better relative links for subdirectories.
935     //
936     // The issue is particularly visible with pages in subdirectories,
937     // so that if you are in <root>/dev/index.html, the relative menu link to
938     // the _same_ page would likely be ../dev/index.html instead of '' or
939     // 'index.html'.
940     final String currentFileLoc = getCurrentFileLocation();
941     final String absoluteLink = URITool.toURI(currentFileLoc).resolve(relativeLink).normalize().toString();
942     if (currentFileLoc.equals(absoluteLink)) {
943       // for matching link, use empty relative link
944       relativeLink = StringUtils.EMPTY;
945     } else {
946       // relativize the absolute link based on current directory
947       // (uses Maven project link relativization)
948       final String currentFileDir = PathTool.getDirectoryComponent(currentFileLoc);
949       relativeLink = URITool.relativizeLink(currentFileDir, absoluteLink);
950     }
951     if (LOGGER.isTraceEnabled()) {
952       LOGGER.trace("-- Relative Link ----------------------------------");
953       LOGGER.trace("link: {}", href);
954       LOGGER.trace("currentFileLoc: {}", currentFileLoc);
955       LOGGER.trace("absoluteLink: {}", absoluteLink);
956       LOGGER.trace("relativeLink: {}", relativeLink);
957       LOGGER.trace("---------------------------------------------------");
958     }
959     return relativeLink;
960   }
961 
962   /**
963    * @param url
964    *          a url.
965    * @return Returns {@code true} whether the link is a external link to the site.
966    */
967   @SuppressWarnings("null")
968   @Override
969   public boolean isExternalLink(final String url) {
970     if (url == null) {
971       return false;
972     }
973     final String absoluteResourceURL = this.value("absoluteResourceURL");
974     if (!Strings.isNullOrEmpty(absoluteResourceURL) && url.startsWith(absoluteResourceURL)) {
975       return false;
976     }
977     return url.toLowerCase().startsWith("http:/") || url.toLowerCase().startsWith("https:/")
978         || url.toLowerCase().startsWith("ftp:/") || url.toLowerCase().startsWith("mailto:")
979         || url.toLowerCase().startsWith("file:/") || url.toLowerCase().indexOf("://") != -1;
980   }
981 
982   /**
983    * {@inheritDoc}
984    */
985   @Override
986   public boolean isActiveLink(@Nullable final String href) {
987     final String alignedFileName = (String) velocityContext.get("alignedFileName");
988     if (href == null) {
989       return false;
990     }
991     // either empty link (pointing to a page), or if the current file is index.html,
992     // the link may point to the whole directory
993     return Strings.isNullOrEmpty(href) || alignedFileName.endsWith("index.html") && ".".equals(href);
994   }
995 
996   /**
997    * Converts a filename to pageId format.
998    *
999    * @param fileName
1000    *          the filename to convert
1001    * @return Returns a {@link String} representing the pageId of {@code filename}.
1002    */
1003   @Nullable public static String slugFilename(@Nullable final String fileName) {
1004     if (fileName == null) {
1005       return null;
1006     }
1007     String currentFile = fileName;
1008 
1009     // drop the extension
1010     final int lastDot = currentFile.lastIndexOf(".");
1011     if (lastDot >= 0) {
1012       currentFile = currentFile.substring(0, lastDot);
1013     }
1014 
1015     // get the short ID (in case of nested files)
1016     // String fileName = new File(currentFile).getName();
1017     // fileShortId = HtmlTool.slug(fileName);
1018 
1019     // full file ID includes the nested dirs
1020     // replace nesting "/" with "-"
1021     return HtmlTool.slug(currentFile.replace("/", "-").replace("\\", "-"));
1022   }
1023 
1024   /**
1025    * @return Returns a {@link String} representing the relative path to root site.
1026    */
1027   @SuppressWarnings("null")
1028   @Override
1029   @Nonnull
1030   public String getResourcePath() {
1031     final String absoluteResourceURL = this.value("absoluteResourceURL");
1032     String projectUrl = getProjectLocation();
1033     final String currentFileName = getCurrentFileName();
1034     if (!Strings.isNullOrEmpty(projectUrl) && currentFileName != null) {
1035       if (projectUrl.charAt(projectUrl.length() - 1) != '/') {
1036         projectUrl += '/';
1037       }
1038       final String currentFileDir = URITool.toURI(projectUrl).resolve(currentFileName).resolve(".").toString();
1039       return URITool.relativizeLink(currentFileDir, absoluteResourceURL);
1040     }
1041     return (String) velocityContext.get("relativePath");
1042   }
1043 
1044   /**
1045    * Gets the reproduce build timestamp whether property 'project.build.outputTimestamp' is fill in.
1046    *
1047    * @return Returns a instance of {@code Date} representing the reproduce build timestamp whether property
1048    *         'project.build.outputTimestamp' is fill in.
1049    */
1050   @Override
1051   @Nullable public Date getBuildOutputTimestamp() throws ParseException {
1052     if (!this.velocityContext.containsKey(PROJECT_BUILD_OUTPUTTIMESTAMP)) {
1053       return null;
1054     }
1055     final Object outputTimestamp = this.velocityContext.get(PROJECT_BUILD_OUTPUTTIMESTAMP);
1056     if (outputTimestamp != null) {
1057       return ISO_8601BASIC_DATE.parse(outputTimestamp.toString());
1058     }
1059     return null;
1060   }
1061 
1062 }