diff --git a/pom.xml b/pom.xml
index ab84f1f..aacf6de 100644
--- a/pom.xml
+++ b/pom.xml
@@ -4,7 +4,7 @@
de.morphbit
thymeleaf-component-dialect
- 0.1.1-SNAPSHOT
+ 0.1.2-SNAPSHOT
jar
thymeleaf-component-dialect
diff --git a/src/main/java/de/morphbit/thymeleaf/dialect/ComponentDialect.java b/src/main/java/de/morphbit/thymeleaf/dialect/ComponentDialect.java
index 2348231..e8bce38 100644
--- a/src/main/java/de/morphbit/thymeleaf/dialect/ComponentDialect.java
+++ b/src/main/java/de/morphbit/thymeleaf/dialect/ComponentDialect.java
@@ -28,32 +28,62 @@
import org.thymeleaf.processor.IProcessor;
/**
- * A dialect for creating reusable composite components with thymeleaf
+ * A Thymeleaf dialect for creating reusable UI components, similar to React or
+ * Vue components. Components are defined using standard {@code th:fragment}
+ * attributes and used via the {@code tc:} namespace.
+ *
+ *
+ * Usage with Spring Boot:
+ *
*
- * @author Danny Rottstegge
+ * {@code
+ * @Bean
+ * public ComponentDialect componentDialect() {
+ * var dialect = new ComponentDialect();
+ * dialect.addParser(new StandardThymeleafComponentParser("templates/", ".html", "components"));
+ * return dialect;
+ * }
+ * }
+ *
+ *
+ * Components can also be registered manually:
+ *
+ *
+ * {@code
+ * var components = Set.of(new ThymeleafComponent("panel", "components/panel :: panel"));
+ * var dialect = new ComponentDialect(components);
+ * }
*
+ * @author Danny Rottstegge
+ * @see ThymeleafComponent
+ * @see IThymeleafComponentParser
*/
public class ComponentDialect extends AbstractProcessorDialect {
+ /** The dialect name. */
public static final String NAME = "Component Dialect";
+ /** The namespace prefix used in templates ({@code tc}). */
public static final String PREFIX = "tc";
+ /** The dialect precedence (lower values are processed first). */
public static final int PRECEDENCE = 1000;
private final Set components;
private final List parsers = new ArrayList<>();
/**
- * Constructor
+ * Creates a new dialect with no pre-registered components. Use
+ * {@link #addParser(IThymeleafComponentParser)} to register a parser for
+ * automatic component discovery.
*/
public ComponentDialect() {
this(null);
}
/**
- * Constructor, adding components
- *
+ * Creates a new dialect with manually registered components.
+ *
* @param components
- * Thymeleaf components
+ * set of components to register, or {@code null}
*/
public ComponentDialect(Set components) {
super(NAME, PREFIX, PRECEDENCE);
@@ -71,14 +101,12 @@ public Set getProcessors(String dialectPrefix) {
if (this.components != null) {
for (ThymeleafComponent comp : this.components) {
- processors.add(
- new ComponentNamedElementProcessor(dialectPrefix, comp.getName(), comp.getFragmentTemplate()));
+ processors.add(new ComponentNamedElementProcessor(dialectPrefix, comp.name(), comp.fragmentTemplate()));
}
}
for (ThymeleafComponent comp : parseComponents()) {
- processors
- .add(new ComponentNamedElementProcessor(dialectPrefix, comp.getName(), comp.getFragmentTemplate()));
+ processors.add(new ComponentNamedElementProcessor(dialectPrefix, comp.name(), comp.fragmentTemplate()));
}
return processors;
@@ -99,10 +127,12 @@ private Set parseComponents() {
}
/**
- * Add parser to the list of parsers
- *
+ * Adds a parser that will discover and register components automatically.
+ * Multiple parsers can be added to scan different directories.
+ *
* @param parser
- * Thymeleaf component parser
+ * the component parser to add
+ * @see de.morphbit.thymeleaf.parser.StandardThymeleafComponentParser
*/
public void addParser(IThymeleafComponentParser parser) {
this.parsers.add(parser);
diff --git a/src/main/java/de/morphbit/thymeleaf/model/ThymeleafComponent.java b/src/main/java/de/morphbit/thymeleaf/model/ThymeleafComponent.java
index 5c9a8d6..c0c8467 100644
--- a/src/main/java/de/morphbit/thymeleaf/model/ThymeleafComponent.java
+++ b/src/main/java/de/morphbit/thymeleaf/model/ThymeleafComponent.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.
@@ -16,60 +16,21 @@
package de.morphbit.thymeleaf.model;
-public class ThymeleafComponent {
-
- private String name;
- private String fragmentTemplate;
-
- public ThymeleafComponent(String name, String fragmentTemplate) {
- this.name = name;
- this.fragmentTemplate = fragmentTemplate;
- }
-
- /**
- * Returns the name of the component (e.g. panel)
- *
- * @return Component name
- */
- public String getName() {
- return name;
- }
-
- /**
- * Sets the name of the component (e.g. panel). This will be uses as the
- * selector in the html file like.
- *
- * @param name
- * Component name
- */
- public void setName(String name) {
- this.name = name;
- }
-
- /**
- * Returns the thymeleaf fragment template
- *
- * @return Fragment template
- */
- public String getFragmentTemplate() {
- return fragmentTemplate;
- }
-
- /**
- * Sets the thymeleaf fragment template (e.g components/panel :: panel). The
- * pattern is without the fragments parameters, so 'components/panel ::
- * panel(title)' will throw an error.
- *
- * @param fragmentTemplate
- * Fragment template
- */
- public void setFragmentTemplate(String fragmentTemplate) {
- this.fragmentTemplate = fragmentTemplate;
- }
-
- @Override
- public String toString() {
- return "Component [name=" + name + ", fragmentTemplate=" + fragmentTemplate + "]";
- }
-
+/**
+ * Represents a reusable Thymeleaf component that maps a custom tag name to a
+ * Thymeleaf fragment template.
+ *
+ *
+ * Example: A component with name {@code "panel"} and fragment template
+ * {@code "components/panel :: panel"} will be rendered when the tag
+ * {@code } is used in a template.
+ *
+ *
+ * @param name
+ * the component tag name used in templates (e.g. {@code "panel"})
+ * @param fragmentTemplate
+ * the Thymeleaf fragment reference without parameters (e.g.
+ * {@code "components/panel :: panel"})
+ */
+public record ThymeleafComponent(String name, String fragmentTemplate) {
}
diff --git a/src/main/java/de/morphbit/thymeleaf/parser/IThymeleafComponentParser.java b/src/main/java/de/morphbit/thymeleaf/parser/IThymeleafComponentParser.java
index 5a400a5..266adb2 100644
--- a/src/main/java/de/morphbit/thymeleaf/parser/IThymeleafComponentParser.java
+++ b/src/main/java/de/morphbit/thymeleaf/parser/IThymeleafComponentParser.java
@@ -19,12 +19,19 @@
import de.morphbit.thymeleaf.model.ThymeleafComponent;
import java.util.Set;
+/**
+ * Interface for component parsers that discover {@link ThymeleafComponent}s
+ * from template files. Implementations are registered via
+ * {@link de.morphbit.thymeleaf.dialect.ComponentDialect#addParser(IThymeleafComponentParser)}.
+ *
+ * @see StandardThymeleafComponentParser
+ */
public interface IThymeleafComponentParser {
/**
- * Parses thymeleaf components
- *
- * @return List of thymeleaf components
+ * Scans template files and returns all discovered components.
+ *
+ * @return set of discovered components
*/
Set parse();
}
diff --git a/src/main/java/de/morphbit/thymeleaf/parser/StandardThymeleafComponentParser.java b/src/main/java/de/morphbit/thymeleaf/parser/StandardThymeleafComponentParser.java
index 758df62..556a06b 100644
--- a/src/main/java/de/morphbit/thymeleaf/parser/StandardThymeleafComponentParser.java
+++ b/src/main/java/de/morphbit/thymeleaf/parser/StandardThymeleafComponentParser.java
@@ -24,6 +24,24 @@
import org.attoparser.dom.Element;
import org.thymeleaf.standard.StandardDialect;
+/**
+ * Default implementation of {@link IThymeleafComponentParser} that discovers
+ * components by scanning HTML files for {@code th:fragment} attributes.
+ *
+ *
+ * Each fragment found becomes a component that can be used with the {@code tc:}
+ * namespace. The component name defaults to the fragment name, unless a
+ * {@code tc:selector} attribute provides a custom name.
+ *
+ *
+ *
+ * Example: given a file {@code templates/components/panel.html} containing
+ * {@code
}, this parser registers a component
+ * accessible as {@code
}.
+ *
+ *
+ * @see ComponentDialect#addParser(IThymeleafComponentParser)
+ */
public class StandardThymeleafComponentParser extends AbstractElementParser implements IThymeleafComponentParser {
protected static final String NAME_ATTRIBUTE = "selector";
diff --git a/src/main/java/de/morphbit/thymeleaf/processor/ComponentNamedElementProcessor.java b/src/main/java/de/morphbit/thymeleaf/processor/ComponentNamedElementProcessor.java
index 4919765..fdf81a1 100644
--- a/src/main/java/de/morphbit/thymeleaf/processor/ComponentNamedElementProcessor.java
+++ b/src/main/java/de/morphbit/thymeleaf/processor/ComponentNamedElementProcessor.java
@@ -81,9 +81,10 @@ protected void doProcess(ITemplateContext context, IModel model, IElementModelSt
String constructorParams = attributes.get("tc:constructor");
IModel componentModel = model.cloneModel();
- componentModel.remove(0);
-
- if (componentModel.size() > 1) {
+ if (componentModel.size() > 0) {
+ componentModel.remove(0);
+ }
+ if (componentModel.size() > 0) {
componentModel.remove(componentModel.size() - 1);
}
diff --git a/src/test/java/de/morphbit/thymeleaf/dialect/EdgeCaseTest.java b/src/test/java/de/morphbit/thymeleaf/dialect/EdgeCaseTest.java
new file mode 100644
index 0000000..7799676
--- /dev/null
+++ b/src/test/java/de/morphbit/thymeleaf/dialect/EdgeCaseTest.java
@@ -0,0 +1,66 @@
+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.assertThrows;
+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;
+import org.thymeleaf.exceptions.TemplateInputException;
+
+public class EdgeCaseTest extends AbstractThymeleafComponentDialectTest {
+
+ @Test
+ public void itShouldRenderDeeplyNestedComponents() {
+ String html = processThymeleafFile("recursive_components.html", new Context());
+
+ assertNotNull(html);
+ assertFalse(html.contains("tc:outer"));
+ assertFalse(html.contains("tc:inner"));
+ assertFalse(html.contains("tc:content"));
+ assertTrue(html.contains("class=\"outer\""));
+ assertTrue(html.contains("class=\"inner\""));
+ assertTrue(html.contains("Deeply nested"));
+ }
+
+ @Test
+ public void itShouldRenderEmptyComponentWithoutContent() {
+ String html = processThymeleafFile("empty_component.html", new Context());
+
+ assertNotNull(html);
+ assertFalse(html.contains("tc:card"));
+ assertTrue(html.contains("class=\"card\""));
+ }
+
+ @Test
+ public void itShouldHandleEmptyNamedSlots() {
+ String html = processThymeleafFile("empty_named_slots.html", new Context());
+
+ assertNotNull(html);
+ assertFalse(html.contains("tc:slot"));
+ assertFalse(html.contains("tc:content"));
+ assertTrue(html.contains("class=\"layout\""));
+ assertTrue(html.contains("class=\"header\""));
+ assertTrue(html.contains("class=\"footer\""));
+ }
+
+ @Test
+ public void itShouldIgnoreUnmatchedSlotsAndRenderDefaultContent() {
+ String html = processThymeleafFile("unmatched_slot.html", new Context());
+
+ assertNotNull(html);
+ assertFalse(html.contains("tc:slot"));
+ // The unmatched slot content should not appear anywhere
+ assertFalse(html.contains("This slot does not exist"));
+ // Default body content should still render
+ assertTrue(html.contains("Default body content
"));
+ }
+
+ @Test
+ public void itShouldThrowExceptionForNonExistentFragment() {
+ assertThrows(TemplateInputException.class,
+ () -> processThymeleafFile("nonexistent_template.html", new Context()));
+ }
+}
diff --git a/src/test/java/de/morphbit/thymeleaf/model/ThymeleafComponentTest.java b/src/test/java/de/morphbit/thymeleaf/model/ThymeleafComponentTest.java
index 3f38d4a..5bd43af 100644
--- a/src/test/java/de/morphbit/thymeleaf/model/ThymeleafComponentTest.java
+++ b/src/test/java/de/morphbit/thymeleaf/model/ThymeleafComponentTest.java
@@ -1,6 +1,7 @@
package de.morphbit.thymeleaf.model;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -10,30 +11,30 @@ public class ThymeleafComponentTest {
@Test
public void itShouldStoreNameAndFragmentTemplate() {
- ThymeleafComponent component = new ThymeleafComponent("panel", "components/panel :: panel");
+ var component = new ThymeleafComponent("panel", "components/panel :: panel");
- assertEquals("panel", component.getName());
- assertEquals("components/panel :: panel", component.getFragmentTemplate());
- }
-
- @Test
- public void itShouldAllowSettingNameAndFragmentTemplate() {
- ThymeleafComponent component = new ThymeleafComponent("old", "old");
-
- component.setName("new-name");
- component.setFragmentTemplate("new/template :: fragment");
-
- assertEquals("new-name", component.getName());
- assertEquals("new/template :: fragment", component.getFragmentTemplate());
+ assertEquals("panel", component.name());
+ assertEquals("components/panel :: panel", component.fragmentTemplate());
}
@Test
public void itShouldHaveMeaningfulToString() {
- ThymeleafComponent component = new ThymeleafComponent("panel", "components/panel :: panel");
+ var component = new ThymeleafComponent("panel", "components/panel :: panel");
String toString = component.toString();
assertNotNull(toString);
assertTrue(toString.contains("panel"));
assertTrue(toString.contains("components/panel :: panel"));
}
+
+ @Test
+ public void itShouldImplementEqualsAndHashCode() {
+ var a = new ThymeleafComponent("panel", "components/panel :: panel");
+ var b = new ThymeleafComponent("panel", "components/panel :: panel");
+ var c = new ThymeleafComponent("other", "components/other :: other");
+
+ assertEquals(a, b);
+ assertEquals(a.hashCode(), b.hashCode());
+ assertNotEquals(a, c);
+ }
}
diff --git a/src/test/java/de/morphbit/thymeleaf/parser/StandardThymeleafComponentParserTest.java b/src/test/java/de/morphbit/thymeleaf/parser/StandardThymeleafComponentParserTest.java
index e54b800..43a4ac3 100644
--- a/src/test/java/de/morphbit/thymeleaf/parser/StandardThymeleafComponentParserTest.java
+++ b/src/test/java/de/morphbit/thymeleaf/parser/StandardThymeleafComponentParserTest.java
@@ -23,6 +23,6 @@ public void testParser() {
}
private boolean containsComponent(final Set components, final String name) {
- return components.stream().anyMatch(c -> c.getName().equalsIgnoreCase(name));
+ return components.stream().anyMatch(c -> c.name().equalsIgnoreCase(name));
}
}
diff --git a/src/test/resources/templates/components/wrapper_component.html b/src/test/resources/templates/components/wrapper_component.html
new file mode 100644
index 0000000..7cc3fd9
--- /dev/null
+++ b/src/test/resources/templates/components/wrapper_component.html
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/test/resources/templates/empty_component.html b/src/test/resources/templates/empty_component.html
new file mode 100644
index 0000000..3c94c57
--- /dev/null
+++ b/src/test/resources/templates/empty_component.html
@@ -0,0 +1,13 @@
+
+
+
+ Test
+
+
+
+
+
+
+
diff --git a/src/test/resources/templates/empty_named_slots.html b/src/test/resources/templates/empty_named_slots.html
new file mode 100644
index 0000000..cf842ea
--- /dev/null
+++ b/src/test/resources/templates/empty_named_slots.html
@@ -0,0 +1,16 @@
+
+
+
+ Test
+
+
+
+
+
+
+
+
+
+
diff --git a/src/test/resources/templates/recursive_components.html b/src/test/resources/templates/recursive_components.html
new file mode 100644
index 0000000..6e0d310
--- /dev/null
+++ b/src/test/resources/templates/recursive_components.html
@@ -0,0 +1,19 @@
+
+
+
+ Test
+
+
+
+
+
+
+ Deeply nested
+
+
+
+
+
+
diff --git a/src/test/resources/templates/unmatched_slot.html b/src/test/resources/templates/unmatched_slot.html
new file mode 100644
index 0000000..60a26f2
--- /dev/null
+++ b/src/test/resources/templates/unmatched_slot.html
@@ -0,0 +1,16 @@
+
+
+
+ Test
+
+
+
+
+ This slot does not exist in the component
+ Default body content
+
+
+
+