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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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