Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
c77dd86
Update version
Serbroda Oct 17, 2017
4cff18d
Rename variables
Serbroda Oct 18, 2017
3f34d9a
Delete unused file
Serbroda Oct 18, 2017
e62ee8f
Add junit dependency
Serbroda Oct 18, 2017
a309585
Remove 'Web' from gitignore and add test classes and resources
Serbroda Oct 18, 2017
7728aa9
Add tests
Serbroda Oct 18, 2017
47db145
Remame file
Serbroda Oct 18, 2017
67ee080
Add html that doesn't contain a component
Serbroda Oct 18, 2017
4ad359f
Add named component
Serbroda Oct 18, 2017
8df3a5f
Add assertions
Serbroda Oct 18, 2017
91d08ff
Rename file
Serbroda Oct 18, 2017
cfbe8f1
Complete configuration
Serbroda Oct 18, 2017
3123ca0
Fix for test
Serbroda Oct 18, 2017
41b3764
Add component test
Serbroda Oct 18, 2017
e1989eb
Add assertions
Serbroda Oct 18, 2017
c560b3a
Make class abstract
Serbroda Oct 18, 2017
6e0dee7
Rename html files
Serbroda Oct 18, 2017
5444143
Rename files
Serbroda Oct 18, 2017
2c2e6ce
Add test
Serbroda Oct 18, 2017
fd8e469
Rename test
Serbroda Oct 18, 2017
fa14f1f
Add test
Serbroda Oct 18, 2017
0b32938
Remove spring specific methods
Serbroda Oct 18, 2017
8435536
Add OnceAttributeTest
Serbroda Oct 18, 2017
3a7178d
Fixed issue with "NullPointerException" after building into .jar.
pabsilva Mar 2, 2018
a2b0b59
Merge pull request #2 from DeadalusVIII/develop
Serbroda Mar 6, 2018
4f92520
Add quality gate badge to readme
Serbroda Mar 9, 2018
3d9ce89
Sonar: Remove unused import java.net.URL
Serbroda Mar 9, 2018
9c5c7c3
Sonar: Remove unused parameter 'recursively'
Serbroda Mar 9, 2018
ec7ac5c
Add comment
Serbroda Mar 12, 2018
578eae4
Add slf4j-api dependency
Serbroda Mar 12, 2018
c370453
Use slf4j logger instead of System.out.println to log error
Serbroda Mar 12, 2018
a1d213a
Sonar: Make return statement conditional
Serbroda Mar 12, 2018
701c261
Add possibility to register variables globally via parameter
Serbroda Oct 11, 2018
1adb83c
Replace keyword 'params' with 'tc:constructor'
Serbroda Oct 12, 2018
691f2f4
Rename varibale
Serbroda Oct 12, 2018
601432a
Fix tests
Serbroda Oct 12, 2018
c8c9429
Fix sonarcloud badge
Serbroda Oct 12, 2018
058cdf2
Refactor tests to use JUnit 5 and update Maven dependencies
Serbroda Mar 20, 2026
4233376
Implement named slots functionality in Thymeleaf components
Serbroda Mar 20, 2026
4ef6b8f
Merge main into feature/named-content
Serbroda Mar 20, 2026
81d215d
Bump version to 0.1.1-SNAPSHOT in pom.xml
Serbroda Mar 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

<groupId>de.morphbit</groupId>
<artifactId>thymeleaf-component-dialect</artifactId>
<version>0.1.0-SNAPSHOT</version>
<version>0.1.1-SNAPSHOT</version>
<packaging>jar</packaging>

<name>thymeleaf-component-dialect</name>
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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;
Expand All @@ -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.\\-_]*)\\].*");
Expand All @@ -52,7 +60,7 @@ public class ComponentNamedElementProcessor extends AbstractElementModelProcesso

/**
* Constructor
*
*
* @param dialectPrefix
* Dialect prefix (tc)
* @param tagName
Expand Down Expand Up @@ -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<String, IModel> namedSlots = new HashMap<>();
IModel defaultContent = modelFactory.createModel();
extractSlots(componentModel, namedSlots, defaultContent, modelFactory);

model.addModel(mergeSlots(replacedFragmentModel, namedSlots, defaultContent, modelFactory));

processVariables(attributes, context, structureHandler, excludeAttributes);
}
Expand Down Expand Up @@ -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<String, IModel> 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<String, IModel> 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<String, IModel> 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<String, String> attributes, ITemplateContext context, IModel model) {
Expand Down
86 changes: 86 additions & 0 deletions src/test/java/de/morphbit/thymeleaf/dialect/NamedSlotTest.java
Original file line number Diff line number Diff line change
@@ -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("<h1>My Page Title</h1>"));

// Named slot "footer" should be in the footer section
assertTrue(html.contains("<span>Copyright 2026</span>"));

// Default content should be in the body section
assertTrue(html.contains("<p>Main content here</p>"));
}

@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("<b>Custom Header</b>"));
assertFalse(html.contains("Default Header"));

// Default slot should contain custom body content
assertTrue(html.contains("<p>Custom body content</p>"));
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"));
}
}
29 changes: 29 additions & 0 deletions src/test/resources/templates/components/layout_component.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:tc="http://www.morphbit.com/thymeleaf/component">
<head>
</head>
<body>

<div th:fragment="page-layout" class="layout">
<header class="header">
<tc:content name="header"></tc:content>
</header>
<main class="body">
<tc:content></tc:content>
</main>
<footer class="footer">
<tc:content name="footer"></tc:content>
</footer>
</div>

<div th:fragment="card-with-defaults" class="card">
<div class="card-header">
<tc:content name="header">Default Header</tc:content>
</div>
<div class="card-body">
<tc:content>Default Body</tc:content>
</div>
</div>

</body>
</html>
17 changes: 17 additions & 0 deletions src/test/resources/templates/named_slots.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html
xmlns:th="http://www.thymeleaf.org"
xmlns:tc="http://www.morphbit.com/thymeleaf/component">
<head>
<title>Test</title>
</head>
<body>

<tc:page-layout>
<tc:slot name="header"><h1>My Page Title</h1></tc:slot>
<tc:slot name="footer"><span>Copyright 2026</span></tc:slot>
<p>Main content here</p>
</tc:page-layout>

</body>
</html>
Loading
Loading