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