001package ball.spring.dialect; 002/*- 003 * ########################################################################## 004 * Reusable Spring Components 005 * %% 006 * Copyright (C) 2018 - 2022 Allen D. Ball 007 * %% 008 * Licensed under the Apache License, Version 2.0 (the "License"); 009 * you may not use this file except in compliance with the License. 010 * You may obtain a copy of the License at 011 * 012 * http://www.apache.org/licenses/LICENSE-2.0 013 * 014 * Unless required by applicable law or agreed to in writing, software 015 * distributed under the License is distributed on an "AS IS" BASIS, 016 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 017 * See the License for the specific language governing permissions and 018 * limitations under the License. 019 * ########################################################################## 020 */ 021import ball.annotation.CompileTimeCheck; 022import java.util.Collection; 023import java.util.Collections; 024import java.util.LinkedHashSet; 025import java.util.Map; 026import java.util.Set; 027import java.util.regex.Matcher; 028import java.util.regex.Pattern; 029import java.util.stream.Stream; 030import lombok.Getter; 031import lombok.NoArgsConstructor; 032import lombok.ToString; 033import lombok.extern.log4j.Log4j2; 034import org.springframework.util.AntPathMatcher; 035import org.thymeleaf.IEngineConfiguration; 036import org.thymeleaf.context.IExpressionContext; 037import org.thymeleaf.context.ITemplateContext; 038import org.thymeleaf.dialect.AbstractProcessorDialect; 039import org.thymeleaf.dialect.IExpressionObjectDialect; 040import org.thymeleaf.engine.AttributeName; 041import org.thymeleaf.expression.IExpressionObjectFactory; 042import org.thymeleaf.model.IProcessableElementTag; 043import org.thymeleaf.processor.IProcessor; 044import org.thymeleaf.processor.element.AbstractAttributeTagProcessor; 045import org.thymeleaf.processor.element.IElementTagStructureHandler; 046import org.thymeleaf.standard.expression.IStandardExpression; 047import org.thymeleaf.standard.expression.IStandardExpressionParser; 048import org.thymeleaf.standard.expression.StandardExpressions; 049import org.webjars.WebJarAssetLocator; 050 051import static java.util.stream.Collectors.toCollection; 052import static java.util.stream.Collectors.toSet; 053import static org.thymeleaf.templatemode.TemplateMode.HTML; 054 055/** 056 * {@link WebJarAssetLocator} Thymeleaf dialect. 057 * 058 * @author {@link.uri mailto:ball@hcf.dev Allen D. Ball} 059 */ 060@ToString @Log4j2 061public class WebJarsDialect extends AbstractProcessorDialect implements IExpressionObjectDialect { 062 private static final String NAME = "WebJars Dialect"; 063 private static final String PREFIX = "webjars"; 064 private static final int PRECEDENCE = 9999; 065 066 @CompileTimeCheck 067 private static final Pattern PATTERN = 068 Pattern.compile("(?is)" 069 + "(?<prefix>.*" + WebJarAssetLocator.WEBJARS_PATH_PREFIX + ")" 070 + "(?<path>/.*)"); 071 072 private static final String LOCAL_FORMAT = "/webjars%s"; 073 private static final String CDN_FORMAT = "%s://cdn.jsdelivr.net/webjars/%s%s"; 074 075 @Getter(lazy = true) 076 private final IExpressionObjectFactory expressionObjectFactory = new ExpressionObjectFactory(); 077 078 /** 079 * Sole constructor. 080 */ 081 public WebJarsDialect() { super(NAME, PREFIX, PRECEDENCE); } 082 083 @Override 084 public Set<IProcessor> getProcessors(String prefix) { 085 Set<IProcessor> set = 086 Stream.of("href", "src") 087 .map(t -> new PathAttributeTagProcessor(prefix, t)) 088 .collect(toSet()); 089 090 return set; 091 } 092 093 private static String path(WebJarAssetLocator locator, 094 boolean useCdn, String scheme, String path) { 095 String resource = locator.getFullPath(path); 096 Matcher matcher = PATTERN.matcher(resource); 097 098 if (matcher.matches()) { 099 /* 100 * FIX: groupId will always be null in a shaded JAR. 101 */ 102 String groupId = locator.groupId(resource); 103 104 if (useCdn && groupId != null) { 105 path = String.format(CDN_FORMAT, (scheme != null) ? scheme : "http", groupId, matcher.group("path")); 106 } else { 107 path = String.format(LOCAL_FORMAT, matcher.group("path")); 108 } 109 } 110 111 return path; 112 } 113 114 @ToString 115 private static class PathAttributeTagProcessor extends AbstractAttributeTagProcessor { 116 private final WebJarAssetLocator locator = new WebJarAssetLocator(); 117 118 public PathAttributeTagProcessor(String prefix, String name) { 119 super(HTML, prefix, null, false, name, true, PRECEDENCE, true); 120 } 121 122 @Override 123 protected void doProcess(ITemplateContext context, IProcessableElementTag tag, AttributeName name, String value, IElementTagStructureHandler handler) { 124 IEngineConfiguration configuration = context.getConfiguration(); 125 IStandardExpressionParser parser = StandardExpressions.getExpressionParser(configuration); 126 IStandardExpression expression = parser.parseExpression(context, value); 127 String path = (String) expression.execute(context); 128 String scheme = (String) parser.parseExpression(context, "${#request.scheme}").execute(context); 129 130 try { 131 path = path(locator, false, scheme, path); 132 } catch (IllegalArgumentException exception) { 133 } 134 135 handler.setAttribute(name.getAttributeName(), path); 136 } 137 } 138 139 @NoArgsConstructor @ToString 140 private static class ExpressionObjectFactory implements IExpressionObjectFactory { 141 private final Map<String,Object> map = Collections.singletonMap(PREFIX, new WebJars()); 142 143 @Override 144 public Set<String> getAllExpressionObjectNames() { 145 return map.keySet(); 146 } 147 148 @Override 149 public Object buildObject(IExpressionContext context, String name) { 150 return (name != null) ? map.get(name) : null; 151 } 152 153 @Override 154 public boolean isCacheable(String name) { 155 return name != null && map.containsKey(name); 156 } 157 } 158 159 /** 160 * {@link WebJars} Thymeleaf dialect expression object implementation. 161 */ 162 @NoArgsConstructor @ToString 163 public static class WebJars { 164 @CompileTimeCheck 165 private static final Pattern PATTERN = Pattern.compile("(?i)[\\p{Space},]+"); 166 167 private final WebJarAssetLocator locator = new WebJarAssetLocator(); 168 private final Set<String> assets = locator.listAssets(); 169 private final AntPathMatcher matcher = new AntPathMatcher(); 170 171 /** 172 * Method to return WebJar resources matching {@link AntPathMatcher} 173 * patterns. 174 * 175 * @param patterns The {@link AntPathMatcher} patterns to 176 * match. 177 * 178 * @return The matching resource paths. 179 */ 180 public Collection<String> assets(String... patterns) { 181 return assets(false, null, patterns); 182 } 183 184 /** 185 * Method to return WebJar resources matching {@link AntPathMatcher} 186 * patterns. 187 * 188 * @param useCdn {@code true} to provide a CDN 189 * {@code URI}; {@code false} for a local 190 * path. 191 * @param scheme The {@code URI} scheme. 192 * @param patterns The {@link AntPathMatcher} patterns to 193 * match. 194 * 195 * @return The matching resource paths. 196 */ 197 public Collection<String> assets(boolean useCdn, String scheme, String... patterns) { 198 Collection<String> collection = 199 Stream.of(patterns) 200 .flatMap(t -> PATTERN.splitAsStream(t)) 201 .flatMap(t -> assets.stream().filter(a -> matcher.match(t, a))) 202 .map(t -> path(locator, useCdn, scheme, t)) 203 .collect(toCollection(LinkedHashSet::new)); 204 205 return collection; 206 } 207 208 /** 209 * Method to convert a WebJar resource (partial) path to its 210 * corresponding CDN (URI) path. Typical Thymeleaf usage: 211 * 212 * {@code @{${#webjars.cdn(#request.scheme, path)}}}. 213 * 214 * @param scheme The {@code URI} scheme. 215 * @param path The (possibly partial) path. 216 * 217 * @return The CDN URI if one may be constructed; {@code path} 218 * otherwise. 219 */ 220 public String cdn(String scheme, String path) { 221 try { 222 path = path(locator, false, null, path); 223 } catch (IllegalArgumentException exception) { 224 } 225 226 return path; 227 } 228 229 /** 230 * See {@link WebJarAssetLocator#getWebJars()}. 231 * 232 * @return Result of {@link WebJarAssetLocator#getWebJars()} call. 233 */ 234 public Map<String,String> getJars() { return locator.getWebJars(); } 235 } 236}