Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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.1-SNAPSHOT</version>
<version>0.1.2-SNAPSHOT</version>
<packaging>jar</packaging>

<name>thymeleaf-component-dialect</name>
Expand Down
56 changes: 43 additions & 13 deletions src/main/java/de/morphbit/thymeleaf/dialect/ComponentDialect.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
* <p>
* Usage with Spring Boot:
* </p>
*
* @author Danny Rottstegge
* <pre>{@code
* @Bean
* public ComponentDialect componentDialect() {
* var dialect = new ComponentDialect();
* dialect.addParser(new StandardThymeleafComponentParser("templates/", ".html", "components"));
* return dialect;
* }
* }</pre>
*
* <p>
* Components can also be registered manually:
* </p>
*
* <pre>{@code
* var components = Set.of(new ThymeleafComponent("panel", "components/panel :: panel"));
* var dialect = new ComponentDialect(components);
* }</pre>
*
* @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<ThymeleafComponent> components;
private final List<IThymeleafComponentParser> 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<ThymeleafComponent> components) {
super(NAME, PREFIX, PRECEDENCE);
Expand All @@ -71,14 +101,12 @@ public Set<IProcessor> 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;
Expand All @@ -99,10 +127,12 @@ private Set<ThymeleafComponent> 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);
Expand Down
81 changes: 21 additions & 60 deletions src/main/java/de/morphbit/thymeleaf/model/ThymeleafComponent.java
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 @@ -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.
*
* <p>
* Example: A component with name {@code "panel"} and fragment template
* {@code "components/panel :: panel"} will be rendered when the tag
* {@code <tc:panel>} is used in a template.
* </p>
*
* @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) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<ThymeleafComponent> parse();
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
* <p>
* 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.
* </p>
*
* <p>
* Example: given a file {@code templates/components/panel.html} containing
* {@code <div th:fragment="panel(title)">}, this parser registers a component
* accessible as {@code <tc:panel>}.
* </p>
*
* @see ComponentDialect#addParser(IThymeleafComponentParser)
*/
public class StandardThymeleafComponentParser extends AbstractElementParser implements IThymeleafComponentParser {

protected static final String NAME_ATTRIBUTE = "selector";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
66 changes: 66 additions & 0 deletions src/test/java/de/morphbit/thymeleaf/dialect/EdgeCaseTest.java
Original file line number Diff line number Diff line change
@@ -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("<span>Deeply nested</span>"));
}

@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("<p>Default body content</p>"));
}

@Test
public void itShouldThrowExceptionForNonExistentFragment() {
assertThrows(TemplateInputException.class,
() -> processThymeleafFile("nonexistent_template.html", new Context()));
}
}
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,6 @@ public void testParser() {
}

private boolean containsComponent(final Set<ThymeleafComponent> components, final String name) {
return components.stream().anyMatch(c -> c.getName().equalsIgnoreCase(name));
return components.stream().anyMatch(c -> c.name().equalsIgnoreCase(name));
}
}
Loading
Loading