View Javadoc
1   /*
2    * Licensed to the Apache Software Foundation (ASF) under one
3    * or more contributor license agreements.  See the NOTICE file
4    * distributed with this work for additional information
5    * regarding copyright ownership.  The ASF licenses this file
6    * to you under the Apache License, Version 2.0 (the
7    * "License"); you may not use this file except in compliance
8    * with the License.  You may obtain a copy of the License at
9    *
10   *   http://www.apache.org/licenses/LICENSE-2.0
11   *
12   * Unless required by applicable law or agreed to in writing,
13   * software distributed under the License is distributed on an
14   * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15   * KIND, either express or implied.  See the License for the
16   * specific language governing permissions and limitations
17   * under the License.
18   */
19  package org.apache.maven.doxia.tools;
20  
21  import javax.inject.Inject;
22  import javax.inject.Named;
23  import javax.inject.Singleton;
24  
25  import java.io.File;
26  import java.io.IOException;
27  import java.io.Reader;
28  import java.io.StringReader;
29  import java.io.StringWriter;
30  import java.net.MalformedURLException;
31  import java.net.URL;
32  import java.util.AbstractMap;
33  import java.util.ArrayList;
34  import java.util.Arrays;
35  import java.util.Collections;
36  import java.util.List;
37  import java.util.Locale;
38  import java.util.Map;
39  import java.util.Objects;
40  import java.util.StringTokenizer;
41  
42  import org.apache.commons.io.FilenameUtils;
43  import org.apache.maven.RepositoryUtils;
44  import org.apache.maven.artifact.Artifact;
45  import org.apache.maven.artifact.DefaultArtifact;
46  import org.apache.maven.artifact.handler.ArtifactHandler;
47  import org.apache.maven.artifact.handler.manager.ArtifactHandlerManager;
48  import org.apache.maven.artifact.versioning.InvalidVersionSpecificationException;
49  import org.apache.maven.artifact.versioning.VersionRange;
50  import org.apache.maven.doxia.site.decoration.DecorationModel;
51  import org.apache.maven.doxia.site.decoration.Menu;
52  import org.apache.maven.doxia.site.decoration.MenuItem;
53  import org.apache.maven.doxia.site.decoration.Skin;
54  import org.apache.maven.doxia.site.decoration.inheritance.DecorationModelInheritanceAssembler;
55  import org.apache.maven.doxia.site.decoration.io.xpp3.DecorationXpp3Reader;
56  import org.apache.maven.doxia.site.decoration.io.xpp3.DecorationXpp3Writer;
57  import org.apache.maven.model.DistributionManagement;
58  import org.apache.maven.model.Plugin;
59  import org.apache.maven.project.MavenProject;
60  import org.apache.maven.project.ProjectBuilder;
61  import org.apache.maven.reporting.MavenReport;
62  import org.codehaus.plexus.i18n.I18N;
63  import org.codehaus.plexus.interpolation.EnvarBasedValueSource;
64  import org.codehaus.plexus.interpolation.InterpolationException;
65  import org.codehaus.plexus.interpolation.MapBasedValueSource;
66  import org.codehaus.plexus.interpolation.PrefixedObjectValueSource;
67  import org.codehaus.plexus.interpolation.PrefixedPropertiesValueSource;
68  import org.codehaus.plexus.interpolation.RegexBasedInterpolator;
69  import org.codehaus.plexus.util.IOUtil;
70  import org.codehaus.plexus.util.ReaderFactory;
71  import org.codehaus.plexus.util.StringUtils;
72  import org.codehaus.plexus.util.xml.Xpp3Dom;
73  import org.codehaus.plexus.util.xml.pull.XmlPullParserException;
74  import org.eclipse.aether.RepositorySystem;
75  import org.eclipse.aether.RepositorySystemSession;
76  import org.eclipse.aether.repository.RemoteRepository;
77  import org.eclipse.aether.resolution.ArtifactRequest;
78  import org.eclipse.aether.resolution.ArtifactResolutionException;
79  import org.eclipse.aether.resolution.ArtifactResult;
80  import org.eclipse.aether.transfer.ArtifactNotFoundException;
81  import org.slf4j.Logger;
82  import org.slf4j.LoggerFactory;
83  
84  /**
85   * Default implementation of the site tool.
86   *
87   * @author <a href="mailto:vincent.siveton@gmail.com">Vincent Siveton</a>
88   */
89  @Singleton
90  @Named
91  public class DefaultSiteTool implements SiteTool {
92      private static final Logger LOGGER = LoggerFactory.getLogger(DefaultSiteTool.class);
93  
94      // ----------------------------------------------------------------------
95      // Components
96      // ----------------------------------------------------------------------
97  
98      /**
99       * The component that is used to resolve additional required artifacts.
100      */
101     @Inject
102     protected RepositorySystem repositorySystem;
103 
104     /**
105      * The component used for getting artifact handlers.
106      */
107     @Inject
108     private ArtifactHandlerManager artifactHandlerManager;
109 
110     /**
111      * Internationalization.
112      */
113     @Inject
114     protected I18N i18n;
115 
116     /**
117      * The component for assembling inheritance.
118      */
119     @Inject
120     protected DecorationModelInheritanceAssembler assembler;
121 
122     /**
123      * Project builder.
124      */
125     @Inject
126     protected ProjectBuilder projectBuilder;
127 
128     // ----------------------------------------------------------------------
129     // Public methods
130     // ----------------------------------------------------------------------
131 
132     /** {@inheritDoc} */
133     public Artifact getSkinArtifactFromRepository(
134             RepositorySystemSession repoSession, List<RemoteRepository> remoteProjectRepositories, Skin skin)
135             throws SiteToolException {
136         Objects.requireNonNull(repoSession, "repoSession cannot be null");
137         Objects.requireNonNull(remoteProjectRepositories, "remoteProjectRepositories cannot be null");
138         Objects.requireNonNull(skin, "skin cannot be null");
139 
140         String version = skin.getVersion();
141         try {
142             if (version == null) {
143                 version = Artifact.RELEASE_VERSION;
144             }
145             VersionRange versionSpec = VersionRange.createFromVersionSpec(version);
146             String type = "jar";
147             Artifact artifact = new DefaultArtifact(
148                     skin.getGroupId(),
149                     skin.getArtifactId(),
150                     versionSpec,
151                     Artifact.SCOPE_RUNTIME,
152                     type,
153                     null,
154                     artifactHandlerManager.getArtifactHandler(type));
155             ArtifactRequest request =
156                     new ArtifactRequest(RepositoryUtils.toArtifact(artifact), remoteProjectRepositories, "remote-skin");
157             ArtifactResult result = repositorySystem.resolveArtifact(repoSession, request);
158 
159             return RepositoryUtils.toArtifact(result.getArtifact());
160         } catch (InvalidVersionSpecificationException e) {
161             throw new SiteToolException("The skin version '" + version + "' is not valid", e);
162         } catch (ArtifactResolutionException e) {
163             if (e.getCause() instanceof ArtifactNotFoundException) {
164                 throw new SiteToolException("The skin does not exist", e.getCause());
165             }
166 
167             throw new SiteToolException("Unable to find skin", e);
168         }
169     }
170 
171     /**
172      * This method is not implemented according to the URI specification and has many weird
173      * corner cases where it doesn't do the right thing. Please consider using a better
174      * implemented method from a different library such as org.apache.http.client.utils.URIUtils#resolve.
175      */
176     @Deprecated
177     public String getRelativePath(String to, String from) {
178         Objects.requireNonNull(to, "to cannot be null");
179         Objects.requireNonNull(from, "from cannot be null");
180 
181         if (to.contains(":") && from.contains(":")) {
182             String toScheme = to.substring(0, to.lastIndexOf(':'));
183             String fromScheme = from.substring(0, from.lastIndexOf(':'));
184             if (!toScheme.equals(fromScheme)) {
185                 return to;
186             }
187         }
188 
189         URL toUrl = null;
190         URL fromUrl = null;
191 
192         String toPath = to;
193         String fromPath = from;
194 
195         try {
196             toUrl = new URL(to);
197         } catch (MalformedURLException e) {
198             try {
199                 toUrl = new File(getNormalizedPath(to)).toURI().toURL();
200             } catch (MalformedURLException e1) {
201                 LOGGER.warn("Unable to load a URL for '" + to + "'", e);
202                 return to;
203             }
204         }
205 
206         try {
207             fromUrl = new URL(from);
208         } catch (MalformedURLException e) {
209             try {
210                 fromUrl = new File(getNormalizedPath(from)).toURI().toURL();
211             } catch (MalformedURLException e1) {
212                 LOGGER.warn("Unable to load a URL for '" + from + "'", e);
213                 return to;
214             }
215         }
216 
217         if (toUrl != null && fromUrl != null) {
218             // URLs, determine if they share protocol and domain info
219 
220             if ((toUrl.getProtocol().equalsIgnoreCase(fromUrl.getProtocol()))
221                     && (toUrl.getHost().equalsIgnoreCase(fromUrl.getHost()))
222                     && (toUrl.getPort() == fromUrl.getPort())) {
223                 // shared URL domain details, use URI to determine relative path
224 
225                 toPath = toUrl.getFile();
226                 fromPath = fromUrl.getFile();
227             } else {
228                 // don't share basic URL information, no relative available
229 
230                 return to;
231             }
232         } else if ((toUrl != null && fromUrl == null) || (toUrl == null && fromUrl != null)) {
233             // one is a URL and the other isn't, no relative available.
234 
235             return to;
236         }
237 
238         // either the two locations are not URLs or if they are they
239         // share the common protocol and domain info and we are left
240         // with their URI information
241 
242         String relativePath = getRelativeFilePath(fromPath, toPath);
243 
244         if (relativePath == null) {
245             relativePath = to;
246         }
247 
248         if (LOGGER.isDebugEnabled() && !relativePath.toString().equals(to)) {
249             LOGGER.debug("Mapped url: " + to + " to relative path: " + relativePath);
250         }
251 
252         return relativePath;
253     }
254 
255     private static String getRelativeFilePath(final String oldPath, final String newPath) {
256         // normalize the path delimiters
257 
258         String fromPath = new File(oldPath).getPath();
259         String toPath = new File(newPath).getPath();
260 
261         // strip any leading slashes if its a windows path
262         if (toPath.matches("^\\[a-zA-Z]:")) {
263             toPath = toPath.substring(1);
264         }
265         if (fromPath.matches("^\\[a-zA-Z]:")) {
266             fromPath = fromPath.substring(1);
267         }
268 
269         // lowercase windows drive letters.
270         if (fromPath.startsWith(":", 1)) {
271             fromPath = Character.toLowerCase(fromPath.charAt(0)) + fromPath.substring(1);
272         }
273         if (toPath.startsWith(":", 1)) {
274             toPath = Character.toLowerCase(toPath.charAt(0)) + toPath.substring(1);
275         }
276 
277         // check for the presence of windows drives. No relative way of
278         // traversing from one to the other.
279 
280         if ((toPath.startsWith(":", 1) && fromPath.startsWith(":", 1))
281                 && (!toPath.substring(0, 1).equals(fromPath.substring(0, 1)))) {
282             // they both have drive path element but they don't match, no
283             // relative path
284 
285             return null;
286         }
287 
288         if ((toPath.startsWith(":", 1) && !fromPath.startsWith(":", 1))
289                 || (!toPath.startsWith(":", 1) && fromPath.startsWith(":", 1))) {
290 
291             // one has a drive path element and the other doesn't, no relative
292             // path.
293 
294             return null;
295         }
296 
297         final String relativePath = buildRelativePath(toPath, fromPath, File.separatorChar);
298 
299         return relativePath.toString();
300     }
301 
302     /** {@inheritDoc} */
303     public File getSiteDescriptor(File siteDirectory, Locale locale) {
304         Objects.requireNonNull(siteDirectory, "siteDirectory cannot be null");
305         Objects.requireNonNull(locale, "locale cannot be null");
306 
307         String variant = locale.getVariant();
308         String country = locale.getCountry();
309         String language = locale.getLanguage();
310 
311         File siteDescriptor = null;
312 
313         if (!variant.isEmpty()) {
314             siteDescriptor = new File(siteDirectory, "site_" + language + "_" + country + "_" + variant + ".xml");
315         }
316 
317         if ((siteDescriptor == null || !siteDescriptor.isFile()) && !country.isEmpty()) {
318             siteDescriptor = new File(siteDirectory, "site_" + language + "_" + country + ".xml");
319         }
320 
321         if ((siteDescriptor == null || !siteDescriptor.isFile()) && !language.isEmpty()) {
322             siteDescriptor = new File(siteDirectory, "site_" + language + ".xml");
323         }
324 
325         if (siteDescriptor == null || !siteDescriptor.isFile()) {
326             siteDescriptor = new File(siteDirectory, "site.xml");
327         }
328 
329         return siteDescriptor;
330     }
331 
332     /**
333      * Get a site descriptor from one of the repositories.
334      *
335      * @param project the Maven project, not null.
336      * @param repoSession the repository system session, not null.
337      * @param remoteProjectRepositories the Maven remote project repositories, not null.
338      * @param locale the locale wanted for the site descriptor, not null.
339      * See {@link #getSiteDescriptor(File, Locale)} for details.
340      * @return the site descriptor into the local repository after download of it from repositories or null if not
341      * found in repositories.
342      * @throws SiteToolException if any
343      */
344     File getSiteDescriptorFromRepository(
345             MavenProject project,
346             RepositorySystemSession repoSession,
347             List<RemoteRepository> remoteProjectRepositories,
348             Locale locale)
349             throws SiteToolException {
350         Objects.requireNonNull(project, "project cannot be null");
351         Objects.requireNonNull(repoSession, "repoSession cannot be null");
352         Objects.requireNonNull(remoteProjectRepositories, "remoteProjectRepositories cannot be null");
353         Objects.requireNonNull(locale, "locale cannot be null");
354 
355         try {
356             return resolveSiteDescriptor(project, repoSession, remoteProjectRepositories, locale);
357         } catch (ArtifactNotFoundException e) {
358             LOGGER.debug("Unable to locate site descriptor", e);
359             return null;
360         } catch (ArtifactResolutionException e) {
361             throw new SiteToolException("Unable to locate site descriptor", e);
362         } catch (IOException e) {
363             throw new SiteToolException("Unable to locate site descriptor", e);
364         }
365     }
366 
367     /** {@inheritDoc} */
368     public DecorationModel getDecorationModel(
369             File siteDirectory,
370             Locale locale,
371             MavenProject project,
372             List<MavenProject> reactorProjects,
373             RepositorySystemSession repoSession,
374             List<RemoteRepository> remoteProjectRepositories)
375             throws SiteToolException {
376         Objects.requireNonNull(locale, "locale cannot be null");
377         Objects.requireNonNull(project, "project cannot be null");
378         Objects.requireNonNull(reactorProjects, "reactorProjects cannot be null");
379         Objects.requireNonNull(repoSession, "repoSession cannot be null");
380         Objects.requireNonNull(remoteProjectRepositories, "remoteProjectRepositories cannot be null");
381 
382         LOGGER.debug("Computing decoration model of '" + project.getId() + "' for "
383                 + (locale.equals(SiteTool.DEFAULT_LOCALE) ? "default locale" : "locale '" + locale + "'"));
384 
385         Map.Entry<DecorationModel, MavenProject> result =
386                 getDecorationModel(0, siteDirectory, locale, project, repoSession, remoteProjectRepositories);
387         DecorationModel decorationModel = result.getKey();
388         MavenProject parentProject = result.getValue();
389 
390         if (decorationModel == null) {
391             LOGGER.debug("Using default site descriptor");
392             decorationModel = getDefaultDecorationModel();
393         }
394 
395         // DecorationModel back to String to interpolate, then go back to DecorationModel
396         String siteDescriptorContent = decorationModelToString(decorationModel);
397 
398         // "classical" late interpolation, after full inheritance
399         siteDescriptorContent = getInterpolatedSiteDescriptorContent(project, siteDescriptorContent, false);
400 
401         decorationModel = readDecorationModel(siteDescriptorContent);
402 
403         if (parentProject != null) {
404             populateParentMenu(decorationModel, locale, project, parentProject, true);
405         }
406 
407         try {
408             populateModulesMenu(decorationModel, locale, project, reactorProjects, true);
409         } catch (IOException e) {
410             throw new SiteToolException("Error while populating modules menu", e);
411         }
412 
413         return decorationModel;
414     }
415 
416     /** {@inheritDoc} */
417     public String getInterpolatedSiteDescriptorContent(
418             Map<String, String> props, MavenProject aProject, String siteDescriptorContent) throws SiteToolException {
419         Objects.requireNonNull(props, "props cannot be null");
420 
421         // "classical" late interpolation
422         return getInterpolatedSiteDescriptorContent(aProject, siteDescriptorContent, false);
423     }
424 
425     private String getInterpolatedSiteDescriptorContent(
426             MavenProject aProject, String siteDescriptorContent, boolean isEarly) throws SiteToolException {
427         Objects.requireNonNull(aProject, "aProject cannot be null");
428         Objects.requireNonNull(siteDescriptorContent, "siteDescriptorContent cannot be null");
429 
430         RegexBasedInterpolator interpolator = new RegexBasedInterpolator();
431 
432         if (isEarly) {
433             interpolator.addValueSource(new PrefixedObjectValueSource("this.", aProject));
434             interpolator.addValueSource(new PrefixedPropertiesValueSource("this.", aProject.getProperties()));
435         } else {
436             interpolator.addValueSource(new PrefixedObjectValueSource("project.", aProject));
437             interpolator.addValueSource(new MapBasedValueSource(aProject.getProperties()));
438 
439             try {
440                 interpolator.addValueSource(new EnvarBasedValueSource());
441             } catch (IOException e) {
442                 // Prefer logging?
443                 throw new SiteToolException("Cannot interpolate environment properties", e);
444             }
445         }
446 
447         try {
448             // FIXME: this does not escape xml entities, see MSITE-226, PLXCOMP-118
449             return interpolator.interpolate(siteDescriptorContent);
450         } catch (InterpolationException e) {
451             throw new SiteToolException("Cannot interpolate site descriptor", e);
452         }
453     }
454 
455     /**
456      * Populate the pre-defined <code>parent</code> menu of the decoration model,
457      * if used through <code>&lt;menu ref="parent"/&gt;</code>.
458      *
459      * @param decorationModel the Doxia Sitetools DecorationModel, not null.
460      * @param locale the locale used for the i18n in DecorationModel, not null.
461      * @param project a Maven project, not null.
462      * @param parentProject a Maven parent project, not null.
463      * @param keepInheritedRefs used for inherited references.
464      */
465     private void populateParentMenu(
466             DecorationModel decorationModel,
467             Locale locale,
468             MavenProject project,
469             MavenProject parentProject,
470             boolean keepInheritedRefs) {
471         Objects.requireNonNull(decorationModel, "decorationModel cannot be null");
472         Objects.requireNonNull(locale, "locale cannot be null");
473         Objects.requireNonNull(project, "project cannot be null");
474         Objects.requireNonNull(parentProject, "parentProject cannot be null");
475 
476         Menu menu = decorationModel.getMenuRef("parent");
477 
478         if (menu == null) {
479             return;
480         }
481 
482         if (keepInheritedRefs && menu.isInheritAsRef()) {
483             return;
484         }
485 
486         String parentUrl = getDistMgmntSiteUrl(parentProject);
487 
488         if (parentUrl != null) {
489             if (parentUrl.endsWith("/")) {
490                 parentUrl += "index.html";
491             } else {
492                 parentUrl += "/index.html";
493             }
494 
495             parentUrl = getRelativePath(parentUrl, getDistMgmntSiteUrl(project));
496         } else {
497             // parent has no url, assume relative path is given by site structure
498             File parentBasedir = parentProject.getBasedir();
499             // First make sure that the parent is available on the file system
500             if (parentBasedir != null) {
501                 // Try to find the relative path to the parent via the file system
502                 String parentPath = parentBasedir.getAbsolutePath();
503                 String projectPath = project.getBasedir().getAbsolutePath();
504                 parentUrl = getRelativePath(parentPath, projectPath) + "/index.html";
505             }
506         }
507 
508         // Only add the parent menu if we were able to find a URL for it
509         if (parentUrl == null) {
510             LOGGER.warn("Unable to find a URL to the parent project. The parent menu will NOT be added.");
511         } else {
512             if (menu.getName() == null) {
513                 menu.setName(i18n.getString("site-tool", locale, "decorationModel.menu.parentproject"));
514             }
515 
516             MenuItem item = new MenuItem();
517             item.setName(parentProject.getName());
518             item.setHref(parentUrl);
519             menu.addItem(item);
520         }
521     }
522 
523     /**
524      * Populate the pre-defined <code>modules</code> menu of the decoration model,
525      * if used through <code>&lt;menu ref="modules"/&gt;</code>.
526      *
527      * @param decorationModel the Doxia Sitetools DecorationModel, not null.
528      * @param locale the locale used for the i18n in DecorationModel, not null.
529      * @param project a Maven project, not null.
530      * @param reactorProjects the Maven reactor projects, not null.
531      * @param keepInheritedRefs used for inherited references.
532      * @throws SiteToolException if any
533      * @throws IOException
534      */
535     private void populateModulesMenu(
536             DecorationModel decorationModel,
537             Locale locale,
538             MavenProject project,
539             List<MavenProject> reactorProjects,
540             boolean keepInheritedRefs)
541             throws SiteToolException, IOException {
542         Objects.requireNonNull(decorationModel, "decorationModel cannot be null");
543         Objects.requireNonNull(locale, "locale cannot be null");
544         Objects.requireNonNull(project, "project cannot be null");
545         Objects.requireNonNull(reactorProjects, "reactorProjects cannot be null");
546 
547         Menu menu = decorationModel.getMenuRef("modules");
548 
549         if (menu == null) {
550             return;
551         }
552 
553         if (keepInheritedRefs && menu.isInheritAsRef()) {
554             return;
555         }
556 
557         // we require child modules and reactors to process module menu
558         if (!project.getModules().isEmpty()) {
559             if (menu.getName() == null) {
560                 menu.setName(i18n.getString("site-tool", locale, "decorationModel.menu.projectmodules"));
561             }
562 
563             for (String module : project.getModules()) {
564                 MavenProject moduleProject = getModuleFromReactor(project, reactorProjects, module);
565 
566                 if (moduleProject == null) {
567                     LOGGER.debug("Module " + module + " not found in reactor");
568                     continue;
569                 }
570 
571                 final String pluginId = "org.apache.maven.plugins:maven-site-plugin";
572                 String skipFlag = getPluginParameter(moduleProject, pluginId, "skip");
573                 if (skipFlag == null) {
574                     skipFlag = moduleProject.getProperties().getProperty("maven.site.skip");
575                 }
576 
577                 String siteUrl = "true".equalsIgnoreCase(skipFlag) ? null : getDistMgmntSiteUrl(moduleProject);
578                 String itemName =
579                         (moduleProject.getName() == null) ? moduleProject.getArtifactId() : moduleProject.getName();
580                 String defaultSiteUrl = "true".equalsIgnoreCase(skipFlag) ? null : moduleProject.getArtifactId();
581 
582                 appendMenuItem(project, menu, itemName, siteUrl, defaultSiteUrl);
583             }
584         } else if (decorationModel.getMenuRef("modules").getInherit() == null) {
585             // only remove if project has no modules AND menu is not inherited, see MSHARED-174
586             decorationModel.removeMenuRef("modules");
587         }
588     }
589 
590     private MavenProject getModuleFromReactor(MavenProject project, List<MavenProject> reactorProjects, String module)
591             throws IOException {
592         File moduleBasedir = new File(project.getBasedir(), module).getCanonicalFile();
593 
594         for (MavenProject reactorProject : reactorProjects) {
595             if (moduleBasedir.equals(reactorProject.getBasedir())) {
596                 return reactorProject;
597             }
598         }
599 
600         // module not found in reactor
601         return null;
602     }
603 
604     /** {@inheritDoc} */
605     public void populateReportsMenu(
606             DecorationModel decorationModel, Locale locale, Map<String, List<MavenReport>> categories) {
607         Objects.requireNonNull(decorationModel, "decorationModel cannot be null");
608         Objects.requireNonNull(locale, "locale cannot be null");
609         Objects.requireNonNull(categories, "categories cannot be null");
610 
611         Menu menu = decorationModel.getMenuRef("reports");
612 
613         if (menu == null) {
614             return;
615         }
616 
617         if (menu.getName() == null) {
618             menu.setName(i18n.getString("site-tool", locale, "decorationModel.menu.projectdocumentation"));
619         }
620 
621         boolean found = false;
622         if (menu.getItems().isEmpty()) {
623             List<MavenReport> categoryReports = categories.get(MavenReport.CATEGORY_PROJECT_INFORMATION);
624             if (!isEmptyList(categoryReports)) {
625                 MenuItem item = createCategoryMenu(
626                         i18n.getString("site-tool", locale, "decorationModel.menu.projectinformation"),
627                         "/project-info.html",
628                         categoryReports,
629                         locale);
630                 menu.getItems().add(item);
631                 found = true;
632             }
633 
634             categoryReports = categories.get(MavenReport.CATEGORY_PROJECT_REPORTS);
635             if (!isEmptyList(categoryReports)) {
636                 MenuItem item = createCategoryMenu(
637                         i18n.getString("site-tool", locale, "decorationModel.menu.projectreports"),
638                         "/project-reports.html",
639                         categoryReports,
640                         locale);
641                 menu.getItems().add(item);
642                 found = true;
643             }
644         }
645         if (!found) {
646             decorationModel.removeMenuRef("reports");
647         }
648     }
649 
650     /** {@inheritDoc} */
651     public List<Locale> getSiteLocales(String locales) {
652         if (locales == null) {
653             return Collections.singletonList(DEFAULT_LOCALE);
654         }
655 
656         String[] localesArray = StringUtils.split(locales, ",");
657         List<Locale> localesList = new ArrayList<Locale>(localesArray.length);
658         List<Locale> availableLocales = Arrays.asList(Locale.getAvailableLocales());
659 
660         for (String localeString : localesArray) {
661             Locale locale = codeToLocale(localeString);
662 
663             if (locale == null) {
664                 continue;
665             }
666 
667             if (!availableLocales.contains(locale)) {
668                 if (LOGGER.isWarnEnabled()) {
669                     LOGGER.warn("The locale defined by '" + locale
670                             + "' is not available in this Java Virtual Machine ("
671                             + System.getProperty("java.version")
672                             + " from " + System.getProperty("java.vendor") + ") - IGNORING");
673                 }
674                 continue;
675             }
676 
677             Locale bundleLocale = i18n.getBundle("site-tool", locale).getLocale();
678             if (!(bundleLocale.equals(locale) || bundleLocale.getLanguage().equals(locale.getLanguage()))) {
679                 if (LOGGER.isWarnEnabled()) {
680                     LOGGER.warn("The locale '" + locale + "' (" + locale.getDisplayName(Locale.ENGLISH)
681                             + ") is not currently supported by Maven Site - IGNORING."
682                             + System.lineSeparator() + "Contributions are welcome and greatly appreciated!"
683                             + System.lineSeparator() + "If you want to contribute a new translation, please visit "
684                             + "https://maven.apache.org/plugins/localization.html for detailed instructions.");
685                 }
686 
687                 continue;
688             }
689 
690             localesList.add(locale);
691         }
692 
693         if (localesList.isEmpty()) {
694             localesList = Collections.singletonList(DEFAULT_LOCALE);
695         }
696 
697         return localesList;
698     }
699 
700     /**
701      * Converts a locale code like "en", "en_US" or "en_US_win" to a <code>java.util.Locale</code>
702      * object.
703      * <p>If localeCode = <code>system</code>, return the current value of the default locale for this instance
704      * of the Java Virtual Machine.</p>
705      * <p>If localeCode = <code>default</code>, return the root locale.</p>
706      *
707      * @param localeCode the locale code string.
708      * @return a java.util.Locale object instanced or null if errors occurred
709      * @see Locale#getDefault()
710      * @see SiteTool#DEFAULT_LOCALE
711      */
712     private Locale codeToLocale(String localeCode) {
713         if (localeCode == null) {
714             return null;
715         }
716 
717         if ("system".equalsIgnoreCase(localeCode)) {
718             return Locale.getDefault();
719         }
720 
721         if ("default".equalsIgnoreCase(localeCode)) {
722             return SiteTool.DEFAULT_LOCALE;
723         }
724 
725         String language = "";
726         String country = "";
727         String variant = "";
728 
729         StringTokenizer tokenizer = new StringTokenizer(localeCode, "_");
730         final int maxTokens = 3;
731         if (tokenizer.countTokens() > maxTokens) {
732             if (LOGGER.isWarnEnabled()) {
733                 LOGGER.warn("Invalid java.util.Locale format for '" + localeCode + "' entry - IGNORING");
734             }
735             return null;
736         }
737 
738         if (tokenizer.hasMoreTokens()) {
739             language = tokenizer.nextToken();
740             if (tokenizer.hasMoreTokens()) {
741                 country = tokenizer.nextToken();
742                 if (tokenizer.hasMoreTokens()) {
743                     variant = tokenizer.nextToken();
744                 }
745             }
746         }
747 
748         return new Locale(language, country, variant);
749     }
750 
751     // ----------------------------------------------------------------------
752     // Protected methods
753     // ----------------------------------------------------------------------
754 
755     /**
756      * @param path could be null.
757      * @return the path normalized, i.e. by eliminating "/../" and "/./" in the path.
758      * @see FilenameUtils#normalize(String)
759      */
760     protected static String getNormalizedPath(String path) {
761         String normalized = FilenameUtils.normalize(path);
762         if (normalized == null) {
763             normalized = path;
764         }
765         return (normalized == null) ? null : normalized.replace('\\', '/');
766     }
767 
768     // ----------------------------------------------------------------------
769     // Private methods
770     // ----------------------------------------------------------------------
771 
772     /**
773      * @param project not null
774      * @param localeStr not null
775      * @param remoteProjectRepositories not null
776      * @return the site descriptor artifact request
777      */
778     private ArtifactRequest createSiteDescriptorArtifactRequest(
779             MavenProject project, String localeStr, List<RemoteRepository> remoteProjectRepositories) {
780         String type = "xml";
781         ArtifactHandler artifactHandler = artifactHandlerManager.getArtifactHandler(type);
782         Artifact artifact = new DefaultArtifact(
783                 project.getGroupId(),
784                 project.getArtifactId(),
785                 project.getVersion(),
786                 Artifact.SCOPE_RUNTIME,
787                 type,
788                 "site" + (localeStr.isEmpty() ? "" : "_" + localeStr),
789                 artifactHandler);
790         return new ArtifactRequest(
791                 RepositoryUtils.toArtifact(artifact), remoteProjectRepositories, "remote-site-descriptor");
792     }
793 
794     /**
795      * @param project not null
796      * @param repoSession the repository system session not null
797      * @param remoteProjectRepositories not null
798      * @param locale not null
799      * @return the resolved site descriptor
800      * @throws IOException if any
801      * @throws ArtifactResolutionException if any
802      * @throws ArtifactNotFoundException if any
803      */
804     private File resolveSiteDescriptor(
805             MavenProject project,
806             RepositorySystemSession repoSession,
807             List<RemoteRepository> remoteProjectRepositories,
808             Locale locale)
809             throws IOException, ArtifactResolutionException, ArtifactNotFoundException {
810         String variant = locale.getVariant();
811         String country = locale.getCountry();
812         String language = locale.getLanguage();
813 
814         String localeStr = null;
815         File siteDescriptor = null;
816         boolean found = false;
817 
818         if (!variant.isEmpty()) {
819             localeStr = language + "_" + country + "_" + variant;
820             ArtifactRequest request =
821                     createSiteDescriptorArtifactRequest(project, localeStr, remoteProjectRepositories);
822 
823             try {
824                 ArtifactResult result = repositorySystem.resolveArtifact(repoSession, request);
825 
826                 siteDescriptor = result.getArtifact().getFile();
827                 found = true;
828             } catch (ArtifactResolutionException e) {
829                 if (e.getCause() instanceof ArtifactNotFoundException) {
830                     LOGGER.debug("No site descriptor found for '" + project.getId() + "' for locale '" + localeStr
831                             + "', trying without variant...");
832                 } else {
833                     throw e;
834                 }
835             }
836         }
837 
838         if (!found && !country.isEmpty()) {
839             localeStr = language + "_" + country;
840             ArtifactRequest request =
841                     createSiteDescriptorArtifactRequest(project, localeStr, remoteProjectRepositories);
842 
843             try {
844                 ArtifactResult result = repositorySystem.resolveArtifact(repoSession, request);
845 
846                 siteDescriptor = result.getArtifact().getFile();
847                 found = true;
848             } catch (ArtifactResolutionException e) {
849                 if (e.getCause() instanceof ArtifactNotFoundException) {
850                     LOGGER.debug("No site descriptor found for '" + project.getId() + "' for locale '" + localeStr
851                             + "', trying without country...");
852                 } else {
853                     throw e;
854                 }
855             }
856         }
857 
858         if (!found && !language.isEmpty()) {
859             localeStr = language;
860             ArtifactRequest request =
861                     createSiteDescriptorArtifactRequest(project, localeStr, remoteProjectRepositories);
862 
863             try {
864                 ArtifactResult result = repositorySystem.resolveArtifact(repoSession, request);
865 
866                 siteDescriptor = result.getArtifact().getFile();
867                 found = true;
868             } catch (ArtifactResolutionException e) {
869                 if (e.getCause() instanceof ArtifactNotFoundException) {
870                     LOGGER.debug("No site descriptor found for '" + project.getId() + "' for locale '" + localeStr
871                             + "', trying without language (default locale)...");
872                 } else {
873                     throw e;
874                 }
875             }
876         }
877 
878         if (!found) {
879             localeStr = "";
880             ArtifactRequest request =
881                     createSiteDescriptorArtifactRequest(project, localeStr, remoteProjectRepositories);
882             try {
883                 ArtifactResult result = repositorySystem.resolveArtifact(repoSession, request);
884 
885                 siteDescriptor = result.getArtifact().getFile();
886             } catch (ArtifactResolutionException e) {
887                 if (e.getCause() instanceof ArtifactNotFoundException) {
888                     LOGGER.debug("No site descriptor found for '" + project.getId() + "' with default locale.");
889                     throw (ArtifactNotFoundException) e.getCause();
890                 }
891 
892                 throw e;
893             }
894         }
895 
896         return siteDescriptor;
897     }
898 
899     /**
900      * @param depth depth of project
901      * @param siteDirectory, can be null if project.basedir is null, ie POM from repository
902      * @param locale not null
903      * @param project not null
904      * @param repoSession not null
905      * @param remoteProjectRepositories not null
906      * @return the decoration model depending the locale and the parent project
907      * @throws SiteToolException if any
908      */
909     private Map.Entry<DecorationModel, MavenProject> getDecorationModel(
910             int depth,
911             File siteDirectory,
912             Locale locale,
913             MavenProject project,
914             RepositorySystemSession repoSession,
915             List<RemoteRepository> remoteProjectRepositories)
916             throws SiteToolException {
917         // 1. get site descriptor File
918         File siteDescriptor;
919         if (project.getBasedir() == null) {
920             // POM is in the repository: look into the repository for site descriptor
921             try {
922                 siteDescriptor =
923                         getSiteDescriptorFromRepository(project, repoSession, remoteProjectRepositories, locale);
924             } catch (SiteToolException e) {
925                 throw new SiteToolException("The site descriptor cannot be resolved from the repository", e);
926             }
927         } else {
928             // POM is in build directory: look for site descriptor as local file
929             siteDescriptor = getSiteDescriptor(siteDirectory, locale);
930         }
931 
932         // 2. read DecorationModel from site descriptor File and do early interpolation (${this.*})
933         DecorationModel decorationModel = null;
934         Reader siteDescriptorReader = null;
935         try {
936             if (siteDescriptor != null && siteDescriptor.exists()) {
937                 LOGGER.debug("Reading" + (depth == 0 ? "" : (" parent level " + depth)) + " site descriptor from "
938                         + siteDescriptor);
939 
940                 siteDescriptorReader = ReaderFactory.newXmlReader(siteDescriptor);
941 
942                 String siteDescriptorContent = IOUtil.toString(siteDescriptorReader);
943 
944                 // interpolate ${this.*} = early interpolation
945                 siteDescriptorContent = getInterpolatedSiteDescriptorContent(project, siteDescriptorContent, true);
946 
947                 decorationModel = readDecorationModel(siteDescriptorContent);
948                 decorationModel.setLastModified(siteDescriptor.lastModified());
949             } else {
950                 LOGGER.debug("No" + (depth == 0 ? "" : (" parent level " + depth)) + " site descriptor.");
951             }
952         } catch (IOException e) {
953             throw new SiteToolException(
954                     "The site descriptor for '" + project.getId() + "' cannot be read from " + siteDescriptor, e);
955         } finally {
956             IOUtil.close(siteDescriptorReader);
957         }
958 
959         // 3. look for parent project
960         MavenProject parentProject = project.getParent();
961 
962         // 4. merge with parent project DecorationModel
963         if (parentProject != null && (decorationModel == null || decorationModel.isMergeParent())) {
964             depth++;
965             LOGGER.debug("Looking for site descriptor of level " + depth + " parent project: " + parentProject.getId());
966 
967             File parentSiteDirectory = null;
968             if (parentProject.getBasedir() != null) {
969                 // extrapolate parent project site directory
970                 String siteRelativePath = getRelativeFilePath(
971                         project.getBasedir().getAbsolutePath(),
972                         siteDescriptor.getParentFile().getAbsolutePath());
973 
974                 parentSiteDirectory = new File(parentProject.getBasedir(), siteRelativePath);
975                 // notice: using same siteRelativePath for parent as current project; may be wrong if site plugin
976                 // has different configuration. But this is a rare case (this only has impact if parent is from reactor)
977             }
978 
979             DecorationModel parentDecorationModel = getDecorationModel(
980                             depth, parentSiteDirectory, locale, parentProject, repoSession, remoteProjectRepositories)
981                     .getKey();
982 
983             // MSHARED-116 requires an empty decoration model (instead of a null one)
984             // MSHARED-145 requires us to do this only if there is a parent to merge it with
985             if (decorationModel == null && parentDecorationModel != null) {
986                 // we have no site descriptor: merge the parent into an empty one because the default one
987                 // (default-site.xml) will break menu and breadcrumb composition.
988                 decorationModel = new DecorationModel();
989             }
990 
991             String name = project.getName();
992             if (decorationModel != null && StringUtils.isNotEmpty(decorationModel.getName())) {
993                 name = decorationModel.getName();
994             }
995 
996             // Merge the parent and child DecorationModels
997             String projectDistMgmnt = getDistMgmntSiteUrl(project);
998             String parentDistMgmnt = getDistMgmntSiteUrl(parentProject);
999             if (LOGGER.isDebugEnabled()) {
1000                 LOGGER.debug("Site decoration model inheritance: assembling child with level " + depth
1001                         + " parent: distributionManagement.site.url child = " + projectDistMgmnt + " and parent = "
1002                         + parentDistMgmnt);
1003             }
1004             assembler.assembleModelInheritance(
1005                     name,
1006                     decorationModel,
1007                     parentDecorationModel,
1008                     projectDistMgmnt,
1009                     parentDistMgmnt == null ? projectDistMgmnt : parentDistMgmnt);
1010         }
1011 
1012         return new AbstractMap.SimpleEntry<DecorationModel, MavenProject>(decorationModel, parentProject);
1013     }
1014 
1015     /**
1016      * @param siteDescriptorContent not null
1017      * @return the decoration model object
1018      * @throws SiteToolException if any
1019      */
1020     private DecorationModel readDecorationModel(String siteDescriptorContent) throws SiteToolException {
1021         try {
1022             return new DecorationXpp3Reader().read(new StringReader(siteDescriptorContent));
1023         } catch (XmlPullParserException e) {
1024             throw new SiteToolException("Error parsing site descriptor", e);
1025         } catch (IOException e) {
1026             throw new SiteToolException("Error reading site descriptor", e);
1027         }
1028     }
1029 
1030     private DecorationModel getDefaultDecorationModel() throws SiteToolException {
1031         String siteDescriptorContent;
1032 
1033         Reader reader = null;
1034         try {
1035             reader = ReaderFactory.newXmlReader(getClass().getResourceAsStream("/default-site.xml"));
1036             siteDescriptorContent = IOUtil.toString(reader);
1037         } catch (IOException e) {
1038             throw new SiteToolException("Error reading default site descriptor", e);
1039         } finally {
1040             IOUtil.close(reader);
1041         }
1042 
1043         return readDecorationModel(siteDescriptorContent);
1044     }
1045 
1046     private String decorationModelToString(DecorationModel decoration) throws SiteToolException {
1047         StringWriter writer = new StringWriter();
1048 
1049         try {
1050             new DecorationXpp3Writer().write(writer, decoration);
1051             return writer.toString();
1052         } catch (IOException e) {
1053             throw new SiteToolException("Error reading site descriptor", e);
1054         } finally {
1055             IOUtil.close(writer);
1056         }
1057     }
1058 
1059     private static String buildRelativePath(final String toPath, final String fromPath, final char separatorChar) {
1060         // use tokenizer to traverse paths and for lazy checking
1061         StringTokenizer toTokeniser = new StringTokenizer(toPath, String.valueOf(separatorChar));
1062         StringTokenizer fromTokeniser = new StringTokenizer(fromPath, String.valueOf(separatorChar));
1063 
1064         int count = 0;
1065 
1066         // walk along the to path looking for divergence from the from path
1067         while (toTokeniser.hasMoreTokens() && fromTokeniser.hasMoreTokens()) {
1068             if (separatorChar == '\\') {
1069                 if (!fromTokeniser.nextToken().equalsIgnoreCase(toTokeniser.nextToken())) {
1070                     break;
1071                 }
1072             } else {
1073                 if (!fromTokeniser.nextToken().equals(toTokeniser.nextToken())) {
1074                     break;
1075                 }
1076             }
1077 
1078             count++;
1079         }
1080 
1081         // reinitialize the tokenizers to count positions to retrieve the
1082         // gobbled token
1083 
1084         toTokeniser = new StringTokenizer(toPath, String.valueOf(separatorChar));
1085         fromTokeniser = new StringTokenizer(fromPath, String.valueOf(separatorChar));
1086 
1087         while (count-- > 0) {
1088             fromTokeniser.nextToken();
1089             toTokeniser.nextToken();
1090         }
1091 
1092         StringBuilder relativePath = new StringBuilder();
1093 
1094         // add back refs for the rest of from location.
1095         while (fromTokeniser.hasMoreTokens()) {
1096             fromTokeniser.nextToken();
1097 
1098             relativePath.append("..");
1099 
1100             if (fromTokeniser.hasMoreTokens()) {
1101                 relativePath.append(separatorChar);
1102             }
1103         }
1104 
1105         if (relativePath.length() != 0 && toTokeniser.hasMoreTokens()) {
1106             relativePath.append(separatorChar);
1107         }
1108 
1109         // add fwd fills for whatever's left of to.
1110         while (toTokeniser.hasMoreTokens()) {
1111             relativePath.append(toTokeniser.nextToken());
1112 
1113             if (toTokeniser.hasMoreTokens()) {
1114                 relativePath.append(separatorChar);
1115             }
1116         }
1117         return relativePath.toString();
1118     }
1119 
1120     /**
1121      * @param project not null
1122      * @param menu not null
1123      * @param name not null
1124      * @param href could be null
1125      * @param defaultHref could be null
1126      */
1127     private void appendMenuItem(MavenProject project, Menu menu, String name, String href, String defaultHref) {
1128         String selectedHref = href;
1129 
1130         if (selectedHref == null) {
1131             selectedHref = defaultHref;
1132         }
1133 
1134         MenuItem item = new MenuItem();
1135         item.setName(name);
1136 
1137         if (selectedHref != null) {
1138             String baseUrl = getDistMgmntSiteUrl(project);
1139             if (baseUrl != null) {
1140                 selectedHref = getRelativePath(selectedHref, baseUrl);
1141             }
1142 
1143             if (selectedHref.endsWith("/")) {
1144                 item.setHref(selectedHref + "index.html");
1145             } else {
1146                 item.setHref(selectedHref + "/index.html");
1147             }
1148         }
1149         menu.addItem(item);
1150     }
1151 
1152     /**
1153      * @param name not null
1154      * @param href not null
1155      * @param categoryReports not null
1156      * @param locale not null
1157      * @return the menu item object
1158      */
1159     private MenuItem createCategoryMenu(String name, String href, List<MavenReport> categoryReports, Locale locale) {
1160         MenuItem item = new MenuItem();
1161         item.setName(name);
1162         item.setCollapse(true);
1163         item.setHref(href);
1164 
1165         // MSHARED-172, allow reports to define their order in some other way?
1166         // Collections.sort( categoryReports, new ReportComparator( locale ) );
1167 
1168         for (MavenReport report : categoryReports) {
1169             MenuItem subitem = new MenuItem();
1170             subitem.setName(report.getName(locale));
1171             subitem.setHref(report.getOutputName() + ".html");
1172             item.getItems().add(subitem);
1173         }
1174 
1175         return item;
1176     }
1177 
1178     // ----------------------------------------------------------------------
1179     // static methods
1180     // ----------------------------------------------------------------------
1181 
1182     /**
1183      * Convenience method.
1184      *
1185      * @param list could be null
1186      * @return true if the list is <code>null</code> or empty
1187      */
1188     private static boolean isEmptyList(List<?> list) {
1189         return list == null || list.isEmpty();
1190     }
1191 
1192     /**
1193      * Return distributionManagement.site.url if defined, null otherwise.
1194      *
1195      * @param project not null
1196      * @return could be null
1197      */
1198     private static String getDistMgmntSiteUrl(MavenProject project) {
1199         return getDistMgmntSiteUrl(project.getDistributionManagement());
1200     }
1201 
1202     private static String getDistMgmntSiteUrl(DistributionManagement distMgmnt) {
1203         if (distMgmnt != null
1204                 && distMgmnt.getSite() != null
1205                 && distMgmnt.getSite().getUrl() != null) {
1206             // TODO This needs to go, it is just logically wrong
1207             return urlEncode(distMgmnt.getSite().getUrl());
1208         }
1209 
1210         return null;
1211     }
1212 
1213     /**
1214      * @param project the project
1215      * @param pluginId The id of the plugin
1216      * @return The information about the plugin.
1217      */
1218     private static Plugin getPlugin(MavenProject project, String pluginId) {
1219         if ((project.getBuild() == null) || (project.getBuild().getPluginsAsMap() == null)) {
1220             return null;
1221         }
1222 
1223         Plugin plugin = project.getBuild().getPluginsAsMap().get(pluginId);
1224 
1225         if ((plugin == null)
1226                 && (project.getBuild().getPluginManagement() != null)
1227                 && (project.getBuild().getPluginManagement().getPluginsAsMap() != null)) {
1228             plugin = project.getBuild().getPluginManagement().getPluginsAsMap().get(pluginId);
1229         }
1230 
1231         return plugin;
1232     }
1233 
1234     /**
1235      * @param project the project
1236      * @param pluginId The pluginId
1237      * @param param The child which should be checked.
1238      * @return The value of the dom tree.
1239      */
1240     private static String getPluginParameter(MavenProject project, String pluginId, String param) {
1241         Plugin plugin = getPlugin(project, pluginId);
1242         if (plugin != null) {
1243             Xpp3Dom xpp3Dom = (Xpp3Dom) plugin.getConfiguration();
1244             if (xpp3Dom != null
1245                     && xpp3Dom.getChild(param) != null
1246                     && StringUtils.isNotEmpty(xpp3Dom.getChild(param).getValue())) {
1247                 return xpp3Dom.getChild(param).getValue();
1248             }
1249         }
1250 
1251         return null;
1252     }
1253 
1254     private static String urlEncode(final String url) {
1255         if (url == null) {
1256             return null;
1257         }
1258 
1259         try {
1260             return new File(url).toURI().toURL().toExternalForm();
1261         } catch (MalformedURLException ex) {
1262             return url; // this will then throw somewhere else
1263         }
1264     }
1265 }