
io.vertigo.ui.impl.thymeleaf.components.NamedComponentElementProcessor Maven / Gradle / Ivy
/**
* vertigo - application development platform
*
* Copyright (C) 2013-2022, Vertigo.io, [email protected]
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.vertigo.ui.impl.thymeleaf.components;
import static java.util.Collections.singleton;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import org.thymeleaf.context.IEngineContext;
import org.thymeleaf.context.ITemplateContext;
import org.thymeleaf.exceptions.TemplateProcessingException;
import org.thymeleaf.model.IAttribute;
import org.thymeleaf.model.ICloseElementTag;
import org.thymeleaf.model.IElementTag;
import org.thymeleaf.model.IModel;
import org.thymeleaf.model.IOpenElementTag;
import org.thymeleaf.model.IProcessableElementTag;
import org.thymeleaf.model.IStandaloneElementTag;
import org.thymeleaf.model.ITemplateEvent;
import org.thymeleaf.model.IText;
import org.thymeleaf.processor.element.AbstractElementModelProcessor;
import org.thymeleaf.processor.element.IElementModelStructureHandler;
import org.thymeleaf.standard.StandardDialect;
import org.thymeleaf.standard.expression.Assignation;
import org.thymeleaf.standard.expression.AssignationSequence;
import org.thymeleaf.standard.expression.AssignationUtils;
import org.thymeleaf.standard.expression.IStandardExpression;
import org.thymeleaf.standard.expression.IStandardExpressionParser;
import org.thymeleaf.standard.expression.StandardExpressions;
import org.thymeleaf.standard.expression.VariableExpression;
import org.thymeleaf.templatemode.TemplateMode;
import org.thymeleaf.util.EscapedAttributeUtils;
import org.thymeleaf.util.EvaluationUtils;
import org.thymeleaf.util.StringUtils;
import io.vertigo.core.lang.Assertion;
import io.vertigo.core.util.StringUtil;
public class NamedComponentElementProcessor extends AbstractElementModelProcessor {
private static final String NO_RESERVED_FIRST_CHAR_PATTERN_STR = "^(([^$@]\\{)|[^#|']).*$";
private static final String NO_RESERVED_TEXT_PATTERN_STR = "^[^$#@|'][^$#@]*$";
private static final String NUMBER_PATTERN_STR = "^[0-9\\.]+";
private static final String SIMPLE_TEXT_PATTERN_STR = "^[a-zA-Z]*$";
private static final Pattern NO_RESERVED_FIRST_CHAR_PATTERN = Pattern.compile(NO_RESERVED_FIRST_CHAR_PATTERN_STR);
private static final Pattern NO_RESERVED_TEXT_PATTERN = Pattern.compile(NO_RESERVED_TEXT_PATTERN_STR);
private static final Pattern NUMBER_PATTERN = Pattern.compile(NUMBER_PATTERN_STR);
private static final Pattern SIMPLE_TEXT_PATTERN = Pattern.compile(SIMPLE_TEXT_PATTERN_STR);
private static final String VARIABLE_PLACEHOLDER_SEPARATOR = "_";
private static final String SLOTS_SUFFIX = "slot";
private static final String ATTRS_SUFFIX = "attrs";
private static final String CONTENT_TAGS = "contentTags";
private static final int PRECEDENCE = 350;
private final Set excludeAttributes = singleton("params");
private final String componentName;
private final Optional selectionExpressionOpt;
private final List parameterNames;
private final Set slotNames;
private final List placeholderPrefixes;
private final Optional unnamedPlaceholderPrefix;
private final String frag;
/**
* Constructor
*
* @param dialectPrefix Dialect prefix (tc)
* @param tagName Tag name to search for (e.g. panel)
* @param componentName Fragment to search for
*/
public NamedComponentElementProcessor(final String dialectPrefix, final NamedComponentDefinition thymeleafComponent) {
super(TemplateMode.HTML, dialectPrefix, thymeleafComponent.getName(), true, null, false, PRECEDENCE);
componentName = thymeleafComponent.getFragmentTemplate();
frag = thymeleafComponent.getFrag();
selectionExpressionOpt = thymeleafComponent.getSelectionExpression();
parameterNames = thymeleafComponent.getParameters();
slotNames = parameterNames.stream()
.filter(key -> key.endsWith(VARIABLE_PLACEHOLDER_SEPARATOR + SLOTS_SUFFIX))
.collect(Collectors.toSet());
placeholderPrefixes = parameterNames.stream()
.filter(parameterName -> parameterName.endsWith(VARIABLE_PLACEHOLDER_SEPARATOR + ATTRS_SUFFIX))
.map(parameterName -> parameterName.substring(0, parameterName.length() - ATTRS_SUFFIX.length()))
.collect(Collectors.toList());
unnamedPlaceholderPrefix = placeholderPrefixes.isEmpty() ? Optional.empty() : Optional.of(placeholderPrefixes.get(placeholderPrefixes.size() - 1));
}
@Override
protected void doProcess(final ITemplateContext context, final IModel model, final IElementModelStructureHandler structureHandler) {
if (selectionExpressionOpt.isEmpty() //no selector
|| (boolean) selectionExpressionOpt.get().execute(context)) { //or selector valid
final IProcessableElementTag tag = processElementTag(context, model);
final Map attributes = processAttribute(model, context, structureHandler);
final String param = attributes.get("params");
final IModel contentModel = cloneAndCleanModel(model);
removeContainerTag(contentModel);
if (parameterNames.contains(CONTENT_TAGS)) {
structureHandler.setLocalVariable(CONTENT_TAGS, tag instanceof IStandaloneElementTag ? Collections.emptyList() : asList(contentModel, context));
}
if (!slotNames.isEmpty()) {
final Map slotContents = removeAndExtractSlots(contentModel, context);
for (final Map.Entry entry : slotContents.entrySet()) {
Assertion.check().isTrue(slotNames.contains(entry.getKey()), "Component {0} have no slot {1} (accepted slots : {3})", componentName, entry.getKey(), slotNames);
//-----
structureHandler.setLocalVariable(entry.getKey(), entry.getValue());
}
}
final String fragmentToUse = "~{" + componentName + " :: " + frag + "}";
final IModel fragmentModel = FragmentUtil.getFragmentModel(context, fragmentToUse + (param == null ? "" : "(" + param + ")"), structureHandler);
final IModel clonedFragmentModel = fragmentModel.cloneModel(); //le clone change l'index des éléments
final IModel replacedContentFragmentModel = replaceContentTag(clonedFragmentModel, tag instanceof IStandaloneElementTag ? Optional.empty() : Optional.ofNullable(contentModel));
//We replace the whole model
model.reset();
model.addModel(replacedContentFragmentModel);
processVariables(attributes, context, structureHandler, excludeAttributes);
} // else nothing
}
private static Map removeAndExtractSlots(final IModel contentModel, final ITemplateContext context) {
final Map slotContents = new HashMap<>();
final IModel buildingModel = contentModel.cloneModel(); //contains each first level tag (and all it's sub-tags)
buildingModel.reset();
int tapDepth = 0;
String slotName = null;
final int fullContentSize = contentModel.size();
for (int i = 0; i < fullContentSize; i++) {
final ITemplateEvent templateEvent = contentModel.get(0); //get always first (because we remove it)
if (templateEvent instanceof IOpenElementTag) {
if ("vu:slot".equals(((IElementTag) templateEvent).getElementCompleteName())) {
Assertion.check().isTrue(tapDepth == 0, "Can't parse slot {0} it contains another slot", slotName);
slotName = ((IProcessableElementTag) templateEvent).getAttributeValue("name");
} else if (tapDepth == 0) {
break; //slots must be set at first
}
tapDepth++;
} else if (templateEvent instanceof ICloseElementTag) {
tapDepth--;
} else if (templateEvent instanceof IStandaloneElementTag) {
if ("vu:slot".equals(((IElementTag) templateEvent).getElementCompleteName())) {
//we accept empty slot (to clear a component default slot)
Assertion.check().isTrue(tapDepth == 0, "Can't parse slot {0} it contains another slot", slotName);
slotName = ((IProcessableElementTag) templateEvent).getAttributeValue("name");
} else if (tapDepth == 0) {
break;
}
}
buildingModel.add(templateEvent); //add first
contentModel.remove(0); //remove first : in fact we move slot's tags from content model to building model
if (tapDepth == 0) {
if ("vu:slot".equals(((IElementTag) templateEvent).getElementCompleteName())) {
Assertion.check().isNotNull(slotName);
//Si on est à la base, on ajout que le model qu'on a préparé, on le close et on reset pour la boucle suivante
final IModel firstLevelTagModel = buildingModel.cloneModel();
if (isVisible(context, firstLevelTagModel)) {
removeContainerTag(firstLevelTagModel); //we remove the slot tag itself
slotContents.put(slotName, firstLevelTagModel);
}
buildingModel.reset(); //we prepare next buildingModel
} else {
break; //slots must be set at first
}
}
}
Assertion.check().isTrue(tapDepth == 0, "Can't extract component slots, tags may be missclosed in slot {0}", slotName);
return slotContents;
}
private static IModel replaceContentTag(final IModel fragmentModel, final Optional contentModel) {
int index = findTagIndex("vu:content", 0, fragmentModel, IProcessableElementTag.class);//Open or standalone
while (index > -1) {
final int size;
final int indexEnd = findTagIndex("vu:content", index, fragmentModel, ICloseElementTag.class);
if (indexEnd > -1) {
fragmentModel.remove(indexEnd);
}
if (contentModel.isPresent()) {
size = contentModel.get().size();
//We remove old body
if (indexEnd > -1) {
for (int i = indexEnd - 1; i > index; i--) {
fragmentModel.remove(i);
}
}
//We insert new body after content tag (index+1)
fragmentModel.insertModel(index + 1, contentModel.get());
} else {
size = 0;
}
fragmentModel.remove(index);
index = findTagIndex("vu:content", index + size, fragmentModel, IProcessableElementTag.class);//Open or standalone
}
return fragmentModel;
}
private static int findTagIndex(final String tagName, final int from, final IModel model, final Class extends IElementTag> tagClass) {
ITemplateEvent event = null;
final int size = model.size();
for (int i = from; i < size; i++) {
event = model.get(i);
if (tagClass.isInstance(event)
&& ((IElementTag) event).getElementCompleteName().equals(tagName)) {
return i;
}
}
return -1;
}
private static IModel cloneAndCleanModel(final IModel model) {
final IModel cleanerModel = model.cloneModel();
final int size = cleanerModel.size();
for (int i = size - 1; i > 0; i--) { //We loop decreasly for remove by index
if (cleanerModel.get(i) instanceof IText) {
final IText innerText = (IText) cleanerModel.get(i);
if (StringUtil.isBlank(innerText.getText())) {
cleanerModel.remove(i);
}
}
}
return cleanerModel;
}
private static void removeContainerTag(final IModel contentModel) {
//Remove container tag
contentModel.remove(0);
if (contentModel.size() >= 1) {
contentModel.remove(contentModel.size() - 1);
}
}
private static List asList(final IModel componentModel, final ITemplateContext context) {
final List asList = new ArrayList<>();
final IModel buildingModel = componentModel.cloneModel(); //contains each first level tag (and all it's sub-tags)
buildingModel.reset();
int tapDepth = 0;
for (int i = 0; i < componentModel.size(); i++) {
final ITemplateEvent templateEvent = componentModel.get(i);
buildingModel.add(templateEvent);
if (templateEvent instanceof IOpenElementTag) {
tapDepth++;
} else if (templateEvent instanceof ICloseElementTag) {
tapDepth--;
}
if (tapDepth == 0) {
//Si on est à la base, on ajout que le model qu'on a préparé, on le close et on reset pour la boucle suivante
final IModel firstLevelTagModel = buildingModel.cloneModel();
//si on a un tag if, on l'evalue avant d'ajouter le contenu, pour n'avoir que des composants avec un rendu
if (isVisible(context, firstLevelTagModel)) {
final NamedComponentContentComponent contentComponent = new NamedComponentContentComponent(firstLevelTagModel);
asList.add(contentComponent);
}
buildingModel.reset();
}
}
return asList;
}
private static boolean isVisible(final ITemplateContext context, final IModel firstLevelTagModel) {
final ITemplateEvent firstLevelTag = firstLevelTagModel.get(0);
if (firstLevelTag instanceof IProcessableElementTag) {
final IAttribute ifAttribute = ((IProcessableElementTag) firstLevelTag).getAttribute("th:if");
if (ifAttribute != null) {
final IStandardExpressionParser expressionParser = StandardExpressions.getExpressionParser(context.getConfiguration());
final IStandardExpression expression = expressionParser.parseExpression(context, ifAttribute.getValue());
final Object value = expression.execute(context);
return EvaluationUtils.evaluateAsBoolean(value);
}
}
return true;
}
private static IProcessableElementTag processElementTag(final ITemplateContext context, final IModel model) {
final ITemplateEvent firstEvent = model.get(0);
if (firstEvent instanceof IOpenElementTag) {
final String elementCompleteName = ((IOpenElementTag) firstEvent).getElementCompleteName();
final ITemplateEvent lastEvent = model.get(model.size() - 1);
Assertion.check().isTrue(lastEvent instanceof ICloseElementTag
&& !((ICloseElementTag) lastEvent).isSynthetic()
&& elementCompleteName.equals(((ICloseElementTag) lastEvent).getElementCompleteName()),
"Can't find endTag of {0} in {1} line {2} col {3}", elementCompleteName, firstEvent.getTemplateName(), firstEvent.getLine(), firstEvent.getCol());
}
for (final IProcessableElementTag tag : context.getElementStack()) {
if (locationMatches(firstEvent, tag)) {
return tag;
}
}
return null;
}
private static boolean locationMatches(final ITemplateEvent a, final ITemplateEvent b) {
return Objects.equals(a.getTemplateName(), b.getTemplateName())
&& Objects.equals(a.getLine(), b.getLine())
&& Objects.equals(a.getCol(), b.getCol());
}
private void processVariables(final Map attributes,
final ITemplateContext context,
final IElementModelStructureHandler structureHandler,
final Set excludeAttr) {
final Map> placeholders = new HashMap<>();
for (final Map.Entry entry : attributes.entrySet()) {
if (excludeAttr.contains(entry.getKey()) || isDynamicAttribute(entry.getKey(), getDialectPrefix())) {
continue;
}
processWith(context, entry.getKey(), entry.getValue(), structureHandler, placeholders);
}
//we set placeholders as localvariables (inner components shouldn't affect these in case of name conflict)
setLocalPlaceholderVariables(context, structureHandler, placeholders);
}
private static Object encodeAttributeValue(final Object attributeValue, final boolean isPlaceholder) {
if (attributeValue == null) {
return "${true}";
} else if ("".equals(((String) attributeValue).trim())) {
return "''";
} else if (attributeValue instanceof String
&& !"true".equalsIgnoreCase((String) attributeValue) //not boolean
&& !"false".equalsIgnoreCase((String) attributeValue)
&& !NUMBER_PATTERN.matcher((String) attributeValue).matches() //not number
&& (isPlaceholder //is a placeholder => escape for thymeleaf
|| NO_RESERVED_TEXT_PATTERN.matcher((String) attributeValue).matches()) //no reserved char
&& NO_RESERVED_FIRST_CHAR_PATTERN.matcher((String) attributeValue).matches()) { //don't start with reserved char
//We escape :
//IF placeholder or no thymeleaf's reserved char ($ @ # | ) (but authorized || )
//AND dont start with reserved char (for case like ${value} )
//BUT IF true, false or number (it become string instead)
return "'" + ((String) attributeValue).replace("'", "\\'") + "'"; //escape as text
}
return attributeValue;
}
private static String encodeAttributeName(final String attributeName, final Object attributeValue) {
if (!attributeName.startsWith(":") && !attributeName.startsWith("'")
&& (attributeValue == null
|| attributeValue instanceof String
&& "true".equalsIgnoreCase((String) attributeValue) //boolean
&& "false".equalsIgnoreCase((String) attributeValue))) {
return "':" + attributeName + "'";
} else if (!attributeName.startsWith("'") //if not only char and don't already start by ' add them
&& !SIMPLE_TEXT_PATTERN.matcher(attributeName).matches()) {
return "'" + attributeName + "'";
}
return attributeName;
}
private void setLocalPlaceholderVariables(final ITemplateContext context, final IElementModelStructureHandler structureHandler, final Map> placeholders) {
for (final String placeholderPrefix : placeholderPrefixes) {
final String placeholder = placeholderPrefix + ATTRS_SUFFIX;
final String affectationString;
String preAffectationString = "";
if (context.containsVariable(placeholder)) {
preAffectationString = context.getVariable(placeholder) + ", ";
}
final Map placeholderValues = placeholders.get(placeholder);
if (placeholderValues != null) {
affectationString = placeholderValues
.entrySet().stream()
.map((entry1) -> {
if (entry1.getKey().isEmpty()) {
return (String) entry1.getValue();
}
return entry1.getKey() + "=" + entry1.getValue();
})
.collect(Collectors.joining(", "));
} else {
affectationString = "noOp=_";
}
structureHandler.setLocalVariable(placeholder, preAffectationString + affectationString);
}
}
private static Map processAttribute(final IModel model, final ITemplateContext context, final IElementModelStructureHandler structureHandler) {
final ITemplateEvent firstEvent = model.get(0);
final Map attributes = new HashMap<>();
if (firstEvent instanceof IProcessableElementTag) {
final IProcessableElementTag processableElementTag = (IProcessableElementTag) firstEvent;
for (final IAttribute attribute : processableElementTag.getAllAttributes()) {
final String completeName = attribute.getAttributeCompleteName();
if (!isDynamicAttribute(completeName, StandardDialect.PREFIX)) {
attributes.put(completeName, attribute.getValue());
}
}
}
final Map contentAttrs = (Map) context.getVariable("contentAttrs");
if (contentAttrs != null && !contentAttrs.isEmpty()) {
structureHandler.removeLocalVariable("contentAttrs");
for (final Entry attribute : contentAttrs.entrySet()) {
if (shouldConcat(attribute.getKey())) {
attributes.compute(attribute.getKey(), (k, v) -> (v == null ? "" : v + " ") + attribute.getValue());
} else {
attributes.put(attribute.getKey(), attribute.getValue());
}
}
}
return attributes;
}
private static boolean shouldConcat(final String key) {
return "class".equals(key); //TODO : found the great rule
}
private void addPlaceholderVariable(final Map> placeholders, final String prefixedVariableName, final Object value) {
for (final String placeholderPrefix : placeholderPrefixes) {
if (prefixedVariableName.startsWith(placeholderPrefix)) {
final String attributeName = prefixedVariableName.substring(placeholderPrefix.length());
addPlaceholderVariable(placeholders, placeholderPrefix, attributeName, value);
}
}
}
private static void addPlaceholderVariable(final Map> placeholders, final String placeholderPrefix, final String attributeName, final Object value) {
Map previousPlaceholderValues = placeholders.get(placeholderPrefix + ATTRS_SUFFIX);
if (previousPlaceholderValues == null) {
previousPlaceholderValues = new HashMap<>();
placeholders.put(placeholderPrefix + ATTRS_SUFFIX, previousPlaceholderValues);
}
previousPlaceholderValues.put(encodeAttributeName(attributeName, value), encodeAttributeValue(value, true));
}
private boolean isPlaceholderable(final String prefixedVariableName) {
for (final String placeholderPrefix : placeholderPrefixes) {
if (prefixedVariableName.startsWith(placeholderPrefix)) {
return true;
}
}
return false;
}
private boolean isPlaceholder(final String prefixedVariableName) {
for (final String placeholderPrefix : placeholderPrefixes) {
if (prefixedVariableName.equals(placeholderPrefix + ATTRS_SUFFIX)) {
return true;
}
}
return false;
}
private static boolean isDynamicAttribute(final String attribute, final String prefix) {
return attribute.startsWith(prefix + ":") || attribute.startsWith("data-" + prefix + "-");
}
private void processWith(
final ITemplateContext context,
final String attributeKey,
final Object attributeValue,
final IElementModelStructureHandler structureHandler,
final Map> placeholders) {
Assertion.check().isNotBlank(attributeKey, "Variable name can't be null or empty");
//-----
if (!isPlaceholder(attributeKey) && isPlaceholderable(attributeKey)) {
//We prepared prefixed placeholders variables.
addPlaceholderVariable(placeholders, attributeKey, attributeValue);
} else if (!parameterNames.contains(attributeKey)) {
Assertion.check().isTrue(
unnamedPlaceholderPrefix.isPresent(),
"Component '{0}' can't accept this parameter : '{1}' (accepted params : {2})", componentName, attributeKey, parameterNames);
//We prepared unnamed placeholder variable
Object unescapedattributeValue = attributeValue;
if (attributeValue instanceof String) {
//We need to unescape placeholder : it will be escaped again when used in component (th:attr="__${other_attrs}__" => escape value)
unescapedattributeValue = EscapedAttributeUtils.unescapeAttribute(context.getTemplateMode(), (String) attributeValue);
}
addPlaceholderVariable(placeholders, unnamedPlaceholderPrefix.get(), attributeKey, unescapedattributeValue);
} else {
//See StandardWithTagProcessor
// Normally we would just allow the structure handler to be in charge of declaring the local variables
// by using structureHandler.setLocalVariable(...) but in this case we want each variable defined at an
// expression to be available for the next expressions, and that forces us to cast our ITemplateContext into
// a more specific interface --which shouldn't be used directly except in this specific, special case-- and
// put the local variables directly into it.
IEngineContext engineContext = null;
if (context instanceof IEngineContext) {
// NOTE this interface is internal and should not be used in users' code
engineContext = (IEngineContext) context;
}
final Object encodedAttributeValue = encodeAttributeValue(attributeValue, false);
final AssignationSequence assignations = AssignationUtils.parseAssignationSequence(context, attributeKey + "=" + encodedAttributeValue, false /* no parameters without value */);
if (assignations == null) {
throw new TemplateProcessingException("Could not parse value as attribute assignations: \"" + attributeKey + "=" + encodedAttributeValue + "\"");
}
for (final Assignation assignation : assignations.getAssignations()) {
final IStandardExpression leftExpr = assignation.getLeft();
final Object leftValue = leftExpr.execute(context);
final String newVariableName = leftValue == null ? null : leftValue.toString();
if (StringUtils.isEmptyOrWhitespace(newVariableName)) {
throw new TemplateProcessingException("Variable name expression evaluated as null or empty: \"" + leftExpr + "\"");
}
final IStandardExpression rightExpr = assignation.getRight();
final Object rightValue = rightExpr.execute(context);
if (engineContext != null) {
// The advantage of this vs. using the structure handler is that we will be able to
// use this newly created value in other expressions in the same 'th:with'
engineContext.setVariable(newVariableName, rightValue);
} else {
// The problem is, these won't be available until we execute the next processor
structureHandler.setLocalVariable(newVariableName, rightValue);
}
}
}
}
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy