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

+
+ + +