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}