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.snippet;
17  
18  import com.google.common.collect.Lists;
19  import com.google.common.collect.Maps;
20  import java.io.File;
21  import java.io.IOException;
22  import java.net.URL;
23  import java.net.URLDecoder;
24  import java.nio.charset.StandardCharsets;
25  import java.nio.file.Path;
26  import java.nio.file.Paths;
27  import java.util.ArrayList;
28  import java.util.Arrays;
29  import java.util.Collections;
30  import java.util.Enumeration;
31  import java.util.Iterator;
32  import java.util.List;
33  import java.util.Map;
34  import java.util.jar.JarEntry;
35  import java.util.jar.JarFile;
36  import java.util.stream.Collectors;
37  import javax.annotation.Nonnull;
38  import javax.annotation.Nullable;
39  import org.apache.commons.io.FilenameUtils;
40  import org.apache.velocity.context.Context;
41  import org.codehaus.plexus.util.FileUtils;
42  import org.devacfr.maven.skins.reflow.ISkinConfig;
43  import org.devacfr.maven.skins.reflow.JsoupUtils;
44  import org.devacfr.maven.skins.reflow.snippet.Processor.WebComponentProcessor;
45  import org.devacfr.maven.skins.reflow.snippet.SnippetContext.SnippetResource;
46  import org.jsoup.nodes.Element;
47  import org.jsoup.nodes.Node;
48  import org.jsoup.select.Collector;
49  import org.jsoup.select.Elements;
50  import org.jsoup.select.QueryParser;
51  import org.slf4j.Logger;
52  import org.slf4j.LoggerFactory;
53  
54  /**
55   * @author Christophe Friederich
56   * @version 2.4
57   */
58  public class SnippetParser {
59  
60    /** */
61    private static final Logger LOGGER = LoggerFactory.getLogger(SnippetParser.class);
62  
63    /** */
64    private static final List<String> DEFAULT_PATHS = Lists.newArrayList("src/site/layouts/snippets",
65      "META-INF/skin/snippets");
66  
67    /** */
68    private final ComponentResolver resolver;
69  
70    /** */
71    private final ArrayList<ComponentToken> stack;
72  
73    /** */
74    private Iterator<Element> it;
75  
76    /** */
77    private Processor state = null;
78  
79    /** */
80    private final Processor processor;
81  
82    /** */
83    private final SnippetContext snippetContext;
84  
85    /** */
86    private ComponentToken currentToken;
87  
88    /** */
89    private final List<String> snippetPaths = Lists.newArrayList(DEFAULT_PATHS);
90  
91    /** */
92    private final Map<String, SnippetResource> snippetResources = Maps.newHashMap();
93  
94    /**
95     * Constructor.
96     */
97    public SnippetParser() {
98      stack = Lists.newArrayListWithCapacity(32);
99      snippetContext = new SnippetContext(this);
100     resolver = new ComponentResolver(this);
101     processor = new WebComponentProcessor(this);
102     snippetResources.putAll(loadSnippetResources(getSnippetPaths()));
103     if (LOGGER.isTraceEnabled()) {
104       LOGGER.trace("Snippet Paths: {}", getSnippetPaths());
105       LOGGER.trace("Loaded {} snippet resources: {}", snippetResources.size(), getSnippets());
106     }
107   }
108 
109   /**
110    * @return Returns the snippet resources.
111    */
112   @Nonnull
113   protected Map<String, SnippetResource> getSnippetResources() {
114     return Collections.unmodifiableMap(snippetResources);
115   }
116 
117   /**
118    * @return Returns the snippet names.
119    */
120   @Nonnull
121   protected List<String> getSnippets() {
122     return snippetResources.keySet().stream().collect(Collectors.toList());
123   }
124 
125   /**
126    * @return Returns the snippet resource paths.
127    */
128   @Nonnull
129   public List<String> getSnippetPaths() {
130     return Collections.unmodifiableList(snippetPaths);
131   }
132 
133   /**
134    * Insert a resource path at the given index.
135    *
136    * @param path
137    *          the resource path to add
138    * @param index
139    *          the index where to insert the path
140    * @return Returns this parser.
141    */
142   public SnippetParser insertResourcePath(final int index, final @Nonnull String path) {
143     this.snippetPaths.add(index, path);
144     this.refreshParser();
145     return this;
146   }
147 
148   /**
149    * Add a resource path.
150    *
151    * @param path
152    *          the resource path to add
153    * @return Returns this parser.
154    */
155   public SnippetParser addResourcePath(final @Nonnull String... path) {
156     this.snippetPaths.addAll(Arrays.asList(path));
157     this.refreshParser();
158     return this;
159   }
160 
161   /**
162    * Refresh the parser by reloading snippet resources.
163    *
164    * @return Returns this parser.
165    */
166   public SnippetParser refreshParser() {
167     snippetResources.clear();
168     snippetResources.putAll(loadSnippetResources(getSnippetPaths()));
169     if (LOGGER.isTraceEnabled()) {
170       LOGGER.trace("Reloaded {} snippet resources: {}", snippetResources.size(), getSnippets());
171     }
172     return this;
173   }
174 
175   /**
176    * @param node
177    *          the Jsoup node to use
178    * @return Returns {@code true} if the node is a snippet component.
179    */
180   public boolean isSnippet(final Node node) {
181     return getSnippets().stream().anyMatch(snippet -> snippet.equalsIgnoreCase(JsoupUtils.getNodeName(node)));
182   }
183 
184   /**
185    * @param nodeName
186    *          the Jsoup node name to use
187    * @return Returns {@code true} if the node name is a snippet component.
188    */
189   public boolean isSnippet(final String nodeName) {
190     return getSnippets().stream().anyMatch(snippet -> snippet.equalsIgnoreCase(nodeName));
191   }
192 
193   /**
194    * Check if the document contains at least one snippet component.
195    *
196    * @param document
197    *          the Jsoup element to use
198    * @return Returns {@code true} if the document contains at least one snippet component.
199    */
200   public boolean hasIncludedSnippetComponent(final Element document) {
201     final String tags = getSnippets().stream().collect(Collectors.joining(","));
202     if (LOGGER.isDebugEnabled()) {
203       LOGGER.debug("Check document for snippet components: {}", tags);
204     }
205     return Collector.findFirst(QueryParser.parse(tags), document) != null;
206   }
207 
208   /**
209    * Parse the given HTML source.
210    *
211    * @param config
212    *          the skin config
213    * @param htmlSource
214    *          the HTML source to parse
215    * @return Returns the snippet context.
216    * @throws IOException
217    *           if an I/O error occurs.
218    */
219   public SnippetContext parse(final ISkinConfig config, @Nullable String htmlSource) throws IOException {
220     if (htmlSource == null) {
221       htmlSource = "";
222     }
223 
224     snippetContext.reset();
225     snippetContext.setConfig(config);
226     snippetContext.setHtmlSource(htmlSource);
227 
228     if (LOGGER.isDebugEnabled()) {
229       LOGGER.debug("Parse Snippet");
230       LOGGER.debug(htmlSource);
231     }
232 
233     // find all snippets
234     final Element doc = resolver.normalize(JsoupUtils.createHtmlDocument(htmlSource));
235 
236     if (LOGGER.isDebugEnabled()) {
237       LOGGER.debug("Normalized HTML source:");
238       LOGGER.debug(doc.html());
239     }
240 
241     final Elements elements = resolver.collect(doc);
242 
243     for (it = elements.iterator(); it.hasNext();) {
244       try {
245         parse();
246       } catch (final Exception ex) {
247         throw new SnippetParseException(
248             "error on parse token " + currentToken + " when generate file " + config.getFileId(), ex);
249       }
250     }
251     snippetContext.setHtmlSource(doc.html());
252     return snippetContext;
253   }
254 
255   /**
256    * Parse the current element.
257    */
258   protected void parse() {
259     if (!it.hasNext()) {
260       throw new SnippetParseException("EOF");
261     }
262     final Element element = it.next();
263     currentToken = resolver.create(element);
264     if (currentToken == null) {
265       throw new SnippetParseException("unknown component: " + element);
266     }
267     state = processor;
268     parse(currentToken);
269     currentToken = null;
270   }
271 
272   protected void parse(final ComponentToken token) {
273     state.parse(token);
274   }
275 
276   protected ComponentToken currentToken() {
277     final int size = stack.size();
278     return size > 0 ? stack.get(size - 1) : null;
279   }
280 
281   protected ComponentToken pop() {
282     final int size = stack.size();
283     if (LOGGER.isDebugEnabled()) {
284       LOGGER.debug("Stack size befor pop: {}", size);
285     }
286 
287     if (size == 0) {
288       throw new SnippetParseException("Cannot pop from empty stack");
289     }
290     try {
291 
292       final ComponentToken element = stack.remove(size - 1);
293       if (LOGGER.isDebugEnabled()) {
294         LOGGER.debug("Remove component from stack: {}", element);
295       }
296       return element;
297     } catch (final Exception e) {
298       throw new SnippetParseException("Error while popping from stack", e);
299     }
300   }
301 
302   /**
303    * Push a component token onto the stack.
304    *
305    * @param token
306    *          the component token to push
307    */
308   protected void push(final ComponentToken token) {
309     stack.add(token);
310     if (LOGGER.isDebugEnabled()) {
311       LOGGER.debug("Add component to stack: {}", token);
312     }
313   }
314 
315   /**
316    * @return Returns the snippet context.
317    */
318   public SnippetContext getSnippetContext() {
319     return snippetContext;
320   }
321 
322   public Context getVelocityContext() {
323     return this.snippetContext.getConfig().getVelocityContext();
324   }
325 
326   private Map<String, SnippetResource> loadSnippetResources(final List<String> snippetPaths) {
327     final Map<String, SnippetResource> resources = Maps.newHashMap();
328     final List<String> paths = Lists.newArrayList(snippetPaths);
329     Collections.reverse(paths);
330     for (final String path : paths) {
331       try {
332         List<String> files = getResources(path);
333         if (files.isEmpty()) {
334           files = FileUtils.getFiles(Path.of(path).toFile(), "*", null)
335               .stream()
336               .map(File::getAbsolutePath)
337               .collect(Collectors.toList());
338         }
339         for (final String file : files) {
340           final SnippetResource resource = new SnippetResource(file, path + '/' + file);
341           final String name = FilenameUtils.getBaseName(file);
342           final String ext = FilenameUtils.getExtension(file);
343           if (!ext.equalsIgnoreCase("vm")) {
344             // if (LOGGER.isDebugEnabled()) {
345             // LOGGER.debug("Ignore snippet resource with unsupported extension: {}
346             // (allowed: vm)", file);
347             // }
348             continue;
349           }
350           if (!name.startsWith("_")) {
351             resources.put(name, resource);
352           }
353         }
354       } catch (final Exception e) {
355         LOGGER.warn("Cannot load snippet resources from path: {}", path, e);
356       }
357     }
358     return resources;
359   }
360 
361   /**
362    * Get resource as stream.
363    *
364    * @param resource
365    *          the resource
366    * @return the resource as stream
367    */
368   private List<String> getResources(final String path) throws Exception {
369     final List<String> filenames = Lists.newArrayList();
370 
371     final URL url = getResource(path);
372     if (LOGGER.isTraceEnabled()) {
373       LOGGER.trace("Loading resources from path: {} (url={})", path, url);
374     }
375     if (url != null) {
376       if (url.getProtocol().equals("file")) {
377         final File file = Paths.get(url.toURI()).toFile();
378         if (file != null) {
379           final File[] files = file.listFiles();
380           if (files != null) {
381             for (final File filename : files) {
382               filenames.add(filename.toString());
383             }
384           }
385         }
386       } else if (url.getProtocol().equals("jar")) {
387         final String dirname = path + "/";
388         final String p = url.getPath();
389         final String jarPath = p.substring(5, p.indexOf("!"));
390         try (JarFile jar = new JarFile(URLDecoder.decode(jarPath, StandardCharsets.UTF_8.name()))) {
391           final Enumeration<JarEntry> entries = jar.entries();
392           while (entries.hasMoreElements()) {
393             final JarEntry entry = entries.nextElement();
394             final String name = entry.getName();
395             if (name.startsWith(dirname) && !dirname.equals(name)) {
396               final URL resource = getResource(name);
397               filenames.add(resource.toString());
398             }
399           }
400         }
401       }
402     }
403     if (LOGGER.isTraceEnabled()) {
404       LOGGER.trace("Found {} resources ({}) in path: {}", filenames.size(), filenames, path);
405     }
406     return filenames;
407   }
408 
409   /**
410    * Get resource.
411    *
412    * @param resource
413    *          the resource
414    * @return the resource
415    */
416   private URL getResource(final String resource) {
417     final URL url = getContextClassLoader().getResource(resource);
418     return url == null ? this.getClass().getClassLoader().getResource(resource) : url;
419   }
420 
421   /**
422    * Get the context class loader.
423    *
424    * @return the context class loader
425    */
426   private static ClassLoader getContextClassLoader() {
427     return Thread.currentThread().getContextClassLoader();
428   }
429 
430 }