diff --git a/pom.xml b/pom.xml index 12a78ca..ab84f1f 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ de.morphbit thymeleaf-component-dialect - 0.1.0-SNAPSHOT + 0.1.1-SNAPSHOT jar thymeleaf-component-dialect diff --git a/src/main/java/de/morphbit/thymeleaf/processor/ComponentNamedElementProcessor.java b/src/main/java/de/morphbit/thymeleaf/processor/ComponentNamedElementProcessor.java index 0f82de1..4919765 100644 --- a/src/main/java/de/morphbit/thymeleaf/processor/ComponentNamedElementProcessor.java +++ b/src/main/java/de/morphbit/thymeleaf/processor/ComponentNamedElementProcessor.java @@ -1,12 +1,12 @@ -/* +/* * Copyright 2017, Danny Rottstegge - * + * * 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. @@ -29,11 +29,17 @@ import org.thymeleaf.context.ITemplateContext; import org.thymeleaf.engine.AttributeNames; import org.thymeleaf.model.IAttribute; +import org.thymeleaf.model.ICloseElementTag; import org.thymeleaf.model.IElementTag; import org.thymeleaf.model.IModel; import org.thymeleaf.model.IModelFactory; +import org.thymeleaf.model.IOpenElementTag; import org.thymeleaf.model.IProcessableElementTag; +import org.thymeleaf.model.IStandaloneElementTag; +import org.thymeleaf.model.ITemplateEnd; import org.thymeleaf.model.ITemplateEvent; +import org.thymeleaf.model.ITemplateStart; +import org.thymeleaf.model.IText; import org.thymeleaf.processor.element.AbstractElementModelProcessor; import org.thymeleaf.processor.element.IElementModelStructureHandler; import org.thymeleaf.standard.StandardDialect; @@ -42,7 +48,9 @@ public class ComponentNamedElementProcessor extends AbstractElementModelProcessor { private static final String FRAGMENT_ATTRIBUTE = "fragment"; - private static final String REPLACE_CONTENT_TAG = "tc:content"; + private static final String CONTENT_TAG = "tc:content"; + private static final String SLOT_TAG = "tc:slot"; + private static final String NAME_ATTR = "name"; private static final int PRECEDENCE = 350; private static final Pattern REPLACE_PATTERN = Pattern.compile(".*\\?\\[([\\w.\\-_]*)\\].*"); @@ -52,7 +60,7 @@ public class ComponentNamedElementProcessor extends AbstractElementModelProcesso /** * Constructor - * + * * @param dialectPrefix * Dialect prefix (tc) * @param tagName @@ -86,7 +94,13 @@ protected void doProcess(ITemplateContext context, IModel model, IElementModelSt model.reset(); IModel replacedFragmentModel = replaceAllAttributeValues(attributes, context, fragmentModel); - model.addModel(mergeModels(replacedFragmentModel, componentModel, REPLACE_CONTENT_TAG)); + + IModelFactory modelFactory = context.getModelFactory(); + Map namedSlots = new HashMap<>(); + IModel defaultContent = modelFactory.createModel(); + extractSlots(componentModel, namedSlots, defaultContent, modelFactory); + + model.addModel(mergeSlots(replacedFragmentModel, namedSlots, defaultContent, modelFactory)); processVariables(attributes, context, structureHandler, excludeAttributes); } @@ -138,41 +152,143 @@ private boolean isDynamicAttribute(String attribute, String prefix) { return attribute.startsWith(prefix + ":") || attribute.startsWith("data-" + prefix + "-"); } - private IModel mergeModels(IModel base, IModel insert, String replaceTag) { - IModel mergedModel = insertModel(base, insert, replaceTag); - mergedModel = removeTag(mergedModel, replaceTag); - mergedModel = removeTag(mergedModel, replaceTag); - return mergedModel; + private void extractSlots(IModel model, Map namedSlots, IModel defaultContent, + IModelFactory modelFactory) { + int i = 0; + int size = model.size(); + + while (i < size) { + ITemplateEvent event = model.get(i); + String elementName = getElementName(event); + + if (SLOT_TAG.equals(elementName) && event instanceof IOpenElementTag openTag) { + String slotName = openTag.getAttributeValue(NAME_ATTR); + IModel slotContent = modelFactory.createModel(); + i++; + int depth = 1; + while (i < size) { + event = model.get(i); + elementName = getElementName(event); + if (SLOT_TAG.equals(elementName) && event instanceof IOpenElementTag) { + depth++; + } else if (SLOT_TAG.equals(elementName) && event instanceof ICloseElementTag) { + depth--; + if (depth == 0) { + i++; + break; + } + } + slotContent.add(event); + i++; + } + if (slotName != null && !slotName.isEmpty()) { + namedSlots.put(slotName, slotContent); + } else { + for (int j = 0; j < slotContent.size(); j++) { + defaultContent.add(slotContent.get(j)); + } + } + } else { + defaultContent.add(event); + i++; + } + } } - private IModel insertModel(IModel base, IModel insert, String replaceTag) { - IModel clonedModel = base.cloneModel(); - int index = findTagIndex(base, replaceTag, IElementTag.class); - if (index > -1) { - clonedModel.insertModel(index, insert); + private IModel mergeSlots(IModel fragmentModel, Map namedSlots, IModel defaultContent, + IModelFactory modelFactory) { + IModel result = modelFactory.createModel(); + int i = 0; + int size = fragmentModel.size(); + + while (i < size) { + ITemplateEvent event = fragmentModel.get(i); + String elementName = getElementName(event); + + if (CONTENT_TAG.equals(elementName)) { + String contentName = null; + if (event instanceof IProcessableElementTag processableTag) { + contentName = processableTag.getAttributeValue(NAME_ATTR); + } + + if (event instanceof IStandaloneElementTag) { + IModel slotContent = resolveSlotContent(contentName, namedSlots, defaultContent, null); + if (slotContent != null) { + result.addModel(slotContent); + } + i++; + } else if (event instanceof IOpenElementTag) { + IModel fallbackContent = modelFactory.createModel(); + i++; + int depth = 1; + while (i < size) { + event = fragmentModel.get(i); + elementName = getElementName(event); + if (CONTENT_TAG.equals(elementName) && event instanceof IOpenElementTag) { + depth++; + } else if (CONTENT_TAG.equals(elementName) && event instanceof ICloseElementTag) { + depth--; + if (depth == 0) { + i++; + break; + } + } + fallbackContent.add(event); + i++; + } + IModel slotContent = resolveSlotContent(contentName, namedSlots, defaultContent, fallbackContent); + if (slotContent != null) { + result.addModel(slotContent); + } + } else { + i++; + } + } else if (event instanceof ITemplateStart || event instanceof ITemplateEnd) { + i++; + } else { + result.add(event); + i++; + } } - return clonedModel; + + return result; } - private IModel removeTag(IModel model, final String tag) { - IModel clonedModel = model.cloneModel(); - int index = findTagIndex(model, tag, IElementTag.class); - if (index > -1) { - clonedModel.remove(index); + private IModel resolveSlotContent(String contentName, Map namedSlots, IModel defaultContent, + IModel fallbackContent) { + if (contentName != null && !contentName.isEmpty()) { + IModel slotContent = namedSlots.get(contentName); + if (slotContent != null && !isEmptyOrWhitespace(slotContent)) { + return slotContent; + } + return fallbackContent; + } else { + if (defaultContent != null && !isEmptyOrWhitespace(defaultContent)) { + return defaultContent; + } + return fallbackContent; } - return clonedModel; } - private int findTagIndex(IModel model, final String search, Class clazz) { - int size = model.size(); - ITemplateEvent event = null; - for (int i = 0; i < size; i++) { - event = model.get(i); - if ((clazz == null || clazz.isInstance(event)) && event.toString().contains(search)) { - return i; + private boolean isEmptyOrWhitespace(IModel model) { + for (int i = 0; i < model.size(); i++) { + ITemplateEvent event = model.get(i); + if (event instanceof IText text) { + if (!text.getText().trim().isEmpty()) { + return false; + } + } else if (event instanceof IElementTag) { + return false; } } - return -1; + return true; + } + + private String getElementName(ITemplateEvent event) { + if (event instanceof IElementTag elementTag) { + return elementTag.getElementCompleteName(); + } + return null; } private IModel replaceAllAttributeValues(Map attributes, ITemplateContext context, IModel model) { diff --git a/src/test/java/de/morphbit/thymeleaf/dialect/NamedSlotTest.java b/src/test/java/de/morphbit/thymeleaf/dialect/NamedSlotTest.java new file mode 100644 index 0000000..18e62eb --- /dev/null +++ b/src/test/java/de/morphbit/thymeleaf/dialect/NamedSlotTest.java @@ -0,0 +1,86 @@ +package de.morphbit.thymeleaf.dialect; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import de.morphbit.thymeleaf.base.AbstractThymeleafComponentDialectTest; +import org.junit.jupiter.api.Test; +import org.thymeleaf.context.Context; + +public class NamedSlotTest extends AbstractThymeleafComponentDialectTest { + + @Test + public void itShouldDistributeContentToNamedSlots() { + String html = processThymeleafFile("named_slots.html", new Context()); + + assertNotNull(html); + + // No tc: tags should remain in output + assertFalse(html.contains("tc:page-layout")); + assertFalse(html.contains("tc:content")); + assertFalse(html.contains("tc:slot")); + + // Layout structure should be present + assertTrue(html.contains("class=\"layout\"")); + assertTrue(html.contains("class=\"header\"")); + assertTrue(html.contains("class=\"body\"")); + assertTrue(html.contains("class=\"footer\"")); + + // Named slot "header" should be in the header section + assertTrue(html.contains("

My Page Title

")); + + // Named slot "footer" should be in the footer section + assertTrue(html.contains("Copyright 2026")); + + // Default content should be in the body section + assertTrue(html.contains("

Main content here

")); + } + + @Test + public void itShouldPlaceNamedSlotsInCorrectSections() { + String html = processThymeleafFile("named_slots.html", new Context()); + + assertNotNull(html); + + // Verify header content is within header section + int headerSectionStart = html.indexOf("class=\"header\""); + int bodySectionStart = html.indexOf("class=\"body\""); + int footerSectionStart = html.indexOf("class=\"footer\""); + int headerContentPos = html.indexOf("My Page Title"); + int bodyContentPos = html.indexOf("Main content here"); + int footerContentPos = html.indexOf("Copyright 2026"); + + assertTrue(headerContentPos > headerSectionStart && headerContentPos < bodySectionStart, + "Header content should be within header section"); + assertTrue(bodyContentPos > bodySectionStart && bodyContentPos < footerSectionStart, + "Body content should be within body section"); + assertTrue(footerContentPos > footerSectionStart, "Footer content should be within footer section"); + } + + @Test + public void itShouldOverrideDefaultContentWithNamedSlot() { + String html = processThymeleafFile("named_slots_default_content.html", new Context()); + + assertNotNull(html); + + // Named slot "header" should override the default + assertTrue(html.contains("Custom Header")); + assertFalse(html.contains("Default Header")); + + // Default slot should contain custom body content + assertTrue(html.contains("

Custom body content

")); + assertFalse(html.contains("Default Body")); + } + + @Test + public void itShouldUseFallbackContentWhenNoSlotProvided() { + String html = processThymeleafFile("named_slots_fallback.html", new Context()); + + assertNotNull(html); + + // No slots provided, so default content should be used + assertTrue(html.contains("Default Header")); + assertTrue(html.contains("Default Body")); + } +} diff --git a/src/test/resources/templates/components/layout_component.html b/src/test/resources/templates/components/layout_component.html new file mode 100644 index 0000000..406f6fd --- /dev/null +++ b/src/test/resources/templates/components/layout_component.html @@ -0,0 +1,29 @@ + + + + + + +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ Default Header +
+
+ Default Body +
+
+ + + \ No newline at end of file diff --git a/src/test/resources/templates/named_slots.html b/src/test/resources/templates/named_slots.html new file mode 100644 index 0000000..e1a9469 --- /dev/null +++ b/src/test/resources/templates/named_slots.html @@ -0,0 +1,17 @@ + + + + Test + + + + +

My Page Title

+ Copyright 2026 +

Main content here

+
+ + + \ No newline at end of file diff --git a/src/test/resources/templates/named_slots_default_content.html b/src/test/resources/templates/named_slots_default_content.html new file mode 100644 index 0000000..aa60898 --- /dev/null +++ b/src/test/resources/templates/named_slots_default_content.html @@ -0,0 +1,16 @@ + + + + Test + + + + + Custom Header +

Custom body content

+
+ + + \ No newline at end of file diff --git a/src/test/resources/templates/named_slots_fallback.html b/src/test/resources/templates/named_slots_fallback.html new file mode 100644 index 0000000..0866af9 --- /dev/null +++ b/src/test/resources/templates/named_slots_fallback.html @@ -0,0 +1,14 @@ + + + + Test + + + + + + + + \ No newline at end of file