Thymeleaf Template Engine
Halo uses Thymeleaf as its template engine, providing powerful server-side HTML rendering with natural template syntax that can be displayed correctly in browsers even without processing.
Basic Template Syntax
Template Declaration
Every Thymeleaf template should declare the Thymeleaf namespace:
<! DOCTYPE html >
< html lang = "en" xmlns:th = "http://www.thymeleaf.org" >
< head >
< meta charset = "UTF-8" />
< title th:text = "${site.title}" > Default Title </ title >
</ head >
< body >
<!-- Template content -->
</ body >
</ html >
Variable Expressions
Access model data using ${...} syntax:
<!-- Simple property -->
< h1 th:text = "${post.spec.title}" > Post Title </ h1 >
<!-- Nested properties -->
< span th:text = "${post.spec.author.name}" > Author Name </ span >
<!-- Safe navigation (null-safe) -->
< div th:text = "${post.spec.excerpt?.raw}" > Excerpt </ div >
Message Expressions
Access internationalization messages using #{...}:
<!-- Simple message -->
< span th:text = "#{post.readMore}" > Read More </ span >
<!-- Message with parameters -->
< span th:text = "#{post.publishedOn(${post.spec.publishTime})}" > Published on ... </ span >
Halo-Specific Features
Theme Asset URLs
Use the #theme.assets() function to generate correct URLs for theme assets:
<!-- CSS files -->
< link rel = "stylesheet" th:href = "@{${#theme.assets('/css/style.css')}}" />
<!-- JavaScript files -->
< script th:src = "@{${#theme.assets('/js/main.js')}}" ></ script >
<!-- Images -->
< img th:src = "@{${#theme.assets('/images/logo.png')}}" alt = "Logo" />
The #theme.assets() function automatically handles theme preview mode and generates the correct URL pattern: /themes/{themeName}/assets/{path}
Implementation Details
The asset URL generation is implemented in DefaultLinkExpressionFactory:
application/src/main/java/run/halo/app/theme/dialect/DefaultLinkExpressionFactory.java
public String assets ( String path) {
String assetsPath = ThemeLinkBuilder . THEME_ASSETS_PREFIX + path;
return linkBuilder . buildLink (context, assetsPath, null );
}
Route URLs
Generate URLs for site routes:
<!-- Homepage -->
< a th:href = "@{${#theme.route('/')}}" > Home </ a >
<!-- Post permalink -->
< a th:href = "@{${#theme.route('/posts/' + ${post.metadata.name})}}" > Read Post </ a >
<!-- Category archive -->
< a th:href = "@{${#theme.route('/categories/' + ${category.metadata.name})}}" > View Category </ a >
Using Finder APIs
Finder APIs provide access to content data in your templates. They are automatically injected into the template context.
PostFinder
Access posts and post archives:
application/src/main/java/run/halo/app/theme/finders/PostFinder.java
public interface PostFinder {
Mono < PostVo > getByName ( String postName );
Mono < ContentVo > content ( String postName );
Mono < NavigationPostVo > cursor ( String current );
Flux < ListedPostVo > listAll ();
Mono < ListResult < ListedPostVo >> list ( Integer page , Integer size );
Mono < ListResult < ListedPostVo >> listByCategory ( Integer page , Integer size , String categoryName );
Mono < ListResult < ListedPostVo >> listByTag ( Integer page , Integer size , String tag );
Mono < ListResult < PostArchiveVo >> archives ( Integer page , Integer size );
}
Example: Display Recent Posts
< div th:each = "post : ${posts.items}" class = "post-card" >
< h2 >
< a th:href = "@{${post.status.permalink}}"
th:text = "${post.spec.title}" > Post Title </ a >
</ h2 >
< div class = "post-meta" >
< time th:datetime = "${post.spec.publishTime}"
th:text = "${#temporals.format(post.spec.publishTime, 'yyyy-MM-dd')}" > 2024-01-01 </ time >
< span th:if = "${post.categories.size() > 0}" >
in < a th:href = "@{'/categories/' + ${post.categories[0].metadata.name}}"
th:text = "${post.categories[0].spec.displayName}" > Category </ a >
</ span >
</ div >
< div th:text = "${post.excerpt.raw}" class = "post-excerpt" > Excerpt </ div >
< a th:href = "@{${post.status.permalink}}" th:text = "#{post.readMore}" > Read More </ a >
</ div >
<!-- Pagination -->
< nav th:if = "${posts.hasNext() || posts.hasPrevious()}" >
< a th:if = "${posts.hasPrevious()}"
th:href = "@{${'?page=' + (posts.page - 1)}}" > Previous </ a >
< span th:text = "${posts.page + 1} + ' / ' + ${posts.totalPages}" > 1 / 10 </ span >
< a th:if = "${posts.hasNext()}"
th:href = "@{${'?page=' + (posts.page + 1)}}" > Next </ a >
</ nav >
CategoryFinder
Access categories and category hierarchies:
application/src/main/java/run/halo/app/theme/finders/CategoryFinder.java
public interface CategoryFinder {
Mono < CategoryVo > getByName ( String name );
Flux < CategoryVo > getByNames ( Collection < String > names );
Mono < ListResult < CategoryVo >> list ( Integer page , Integer size );
Flux < CategoryVo > listAll ();
Flux < CategoryTreeVo > listAsTree ();
Flux < CategoryTreeVo > listAsTree ( String name );
Flux < CategoryVo > getBreadcrumbs ( String name );
}
Example: Category Navigation
<!-- Category tree navigation -->
< ul class = "category-tree" >
< li th:each = "category : ${categoryFinder.listAsTree()}" >
< a th:href = "@{'/categories/' + ${category.metadata.name}}"
th:text = "${category.spec.displayName}" > Category </ a >
< span th:if = "${category.postCount > 0}"
th:text = "'(' + ${category.postCount} + ')'" > ( 0) </ span >
<!-- Nested categories -->
< ul th:if = "${category.children.size() > 0}" >
< li th:each = "child : ${category.children}" >
< a th:href = "@{'/categories/' + ${child.metadata.name}}"
th:text = "${child.spec.displayName}" > Subcategory </ a >
</ li >
</ ul >
</ li >
</ ul >
<!-- Breadcrumb navigation -->
< nav th:with = "breadcrumbs=${categoryFinder.getBreadcrumbs(category.metadata.name)}" >
< a th:href = "@{'/'}" > Home </ a >
< span th:each = "crumb, iterStat : ${breadcrumbs}" >
< span > / </ span >
< a th:href = "@{'/categories/' + ${crumb.metadata.name}}"
th:text = "${crumb.spec.displayName}" > Category </ a >
</ span >
</ nav >
Access navigation menus:
application/src/main/java/run/halo/app/theme/finders/MenuFinder.java
public interface MenuFinder {
Mono < MenuVo > getByName ( String name );
Mono < MenuVo > getPrimary ();
}
Example: Primary Navigation
< nav th:with = "menu=${menuFinder.getPrimary()}" >
< ul >
< li th:each = "item : ${menu.menuItems}" >
< a th:href = "@{${item.status.href}}"
th:text = "${item.status.displayName}"
th:target = "${item.spec.target}" > Menu Item </ a >
<!-- Nested menu items -->
< ul th:if = "${item.children.size() > 0}" >
< li th:each = "child : ${item.children}" >
< a th:href = "@{${child.status.href}}"
th:text = "${child.status.displayName}" > Submenu Item </ a >
</ li >
</ ul >
</ li >
</ ul >
</ nav >
Template Fragments
Create reusable template components using fragments:
Defining Fragments
templates/partials/header.html
<! DOCTYPE html >
< html xmlns:th = "http://www.thymeleaf.org" >
< head th:fragment = "head(title)" >
< meta charset = "UTF-8" />
< meta name = "viewport" content = "width=device-width, initial-scale=1.0" />
< title th:text = "${title} + ' - ' + ${site.title}" > Page Title - Site </ title >
< link rel = "stylesheet" th:href = "@{${#theme.assets('/css/style.css')}}" />
</ head >
< body >
< header th:fragment = "header" >
< div class = "container" >
< h1 th:text = "${site.title}" > Site Title </ h1 >
<!-- Navigation menu -->
</ div >
</ header >
</ body >
</ html >
Using Fragments
<! DOCTYPE html >
< html xmlns:th = "http://www.thymeleaf.org" >
< head th:replace = "~{partials/header :: head(${post.spec.title})}" ></ head >
< body >
< div th:replace = "~{partials/header :: header}" ></ div >
< main >
< article >
< h1 th:text = "${post.spec.title}" > Post Title </ h1 >
< div th:utext = "${content.content}" > Content </ div >
</ article >
</ main >
< div th:replace = "~{partials/footer :: footer}" ></ div >
</ body >
</ html >
Conditional Rendering
Simple Conditionals
<!-- Show/hide elements -->
< div th:if = "${post.spec.pinned}" class = "badge" > Pinned </ div >
< div th:unless = "${post.spec.pinned}" class = "normal" > Regular Post </ div >
<!-- Alternative: th:if with negation -->
< div th:if = "${!post.spec.pinned}" class = "normal" > Regular Post </ div >
Switch Statements
< div th:switch = "${post.status.phase}" >
< span th:case = "'PUBLISHED'" class = "badge-success" > Published </ span >
< span th:case = "'DRAFT'" class = "badge-warning" > Draft </ span >
< span th:case = "*" class = "badge-secondary" > Unknown </ span >
</ div >
Iteration
Basic Loops
< div th:each = "post : ${posts.items}" >
< h2 th:text = "${post.spec.title}" > Title </ h2 >
</ div >
Loop with Status
< div th:each = "post, iterStat : ${posts.items}"
th:class = "${iterStat.odd} ? 'odd' : 'even'" >
< span th:text = "${iterStat.index + 1}" > 1 </ span > .
< h2 th:text = "${post.spec.title}" > Title </ h2 >
</ div >
Status variables:
index - Current iteration index (zero-based)
count - Current iteration count (one-based)
size - Total number of elements
current - Current element
even/odd - Boolean flags
first/last - Boolean flags
Custom Template Processors
Halo provides extension points for custom template processing:
TemplateHeadProcessor
Inject content into the <head> tag:
api/src/main/java/run/halo/app/theme/dialect/TemplateHeadProcessor.java
@ FunctionalInterface
public interface TemplateHeadProcessor extends ExtensionPoint {
Mono < Void > process ( ITemplateContext context , IModel model ,
IElementModelStructureHandler structureHandler );
}
Plugins can implement TemplateHeadProcessor to inject styles, scripts, or meta tags into theme templates.
Extend the <halo:comment /> tag:
api/src/main/java/run/halo/app/theme/dialect/CommentWidget.java
public interface CommentWidget extends ExtensionPoint {
void render ( ITemplateContext context , IProcessableElementTag tag ,
IElementTagStructureHandler structureHandler );
}
Template Variables
Common Variables
These variables are available in most templates:
Site Information
User Information
Template Metadata
${site.title} <!-- Site title -->
${site.subtitle} <!-- Site subtitle -->
${site.description} <!-- Site description -->
${site.logo} <!-- Site logo URL -->
${site.favicon} <!-- Site favicon URL -->
Post Template Variables
${post} <!-- PostVo object -->
${post.metadata.name} <!-- Post unique name -->
${post.spec.title} <!-- Post title -->
${post.spec.slug} <!-- Post slug -->
${post.spec.excerpt} <!-- Post excerpt -->
${post.spec.cover} <!-- Cover image URL -->
${post.spec.publishTime} <!-- Publish timestamp -->
${post.spec.pinned} <!-- Is pinned -->
${post.spec.priority} <!-- Priority value -->
${post.status.permalink} <!-- Post URL -->
${post.categories} <!-- List of categories -->
${post.tags} <!-- List of tags -->
${post.contributors} <!-- List of contributors -->
${content} <!-- ContentVo object -->
${content.content} <!-- Rendered HTML content -->
${content.raw} <!-- Raw markdown content -->
Best Practices
Use Natural Templates
Write templates that display correctly even without processing: <!-- Good: Shows placeholder text -->
< h1 th:text = "${post.spec.title}" > Post Title Placeholder </ h1 >
<!-- Avoid: Empty without processing -->
< h1 th:text = "${post.spec.title}" ></ h1 >
Handle Null Values
Use safe navigation or th:if to prevent errors: <!-- Safe navigation -->
< span th:text = "${post.spec.excerpt?.raw}" > Excerpt </ span >
<!-- Conditional rendering -->
< div th:if = "${post.spec.cover}" >
< img th:src = "${post.spec.cover}" alt = "Cover" />
</ div >
Extract Common Fragments
Reuse code with template fragments to maintain consistency and reduce duplication.
Use Semantic HTML
Structure your templates with proper HTML5 semantic elements (<article>, <nav>, <aside>, etc.).
Optimize Performance
Minimize nested loops
Use th:remove to clean up template markup
Cache fragments when possible
Example: Complete Post Template
<! DOCTYPE html >
< html lang = "en" xmlns:th = "http://www.thymeleaf.org" >
< head >
< meta charset = "UTF-8" />
< meta name = "viewport" content = "width=device-width, initial-scale=1.0" />
< title th:text = "${post.spec.title} + ' - ' + ${site.title}" > Post Title - Site </ title >
< meta th:if = "${post.spec.excerpt}"
name = "description"
th:content = "${post.spec.excerpt.raw}" />
< link rel = "stylesheet" th:href = "@{${#theme.assets('/css/style.css')}}" />
</ head >
< body >
< header th:replace = "~{partials/header :: header}" ></ header >
< main class = "container" >
< article class = "post" >
<!-- Cover image -->
< div th:if = "${post.spec.cover}" class = "post-cover" >
< img th:src = "${post.spec.cover}"
th:alt = "${post.spec.title}" />
</ div >
<!-- Post header -->
< header class = "post-header" >
< h1 th:text = "${post.spec.title}" > Post Title </ h1 >
< div class = "post-meta" >
< time th:datetime = "${post.spec.publishTime}"
th:text = "${#temporals.format(post.spec.publishTime, 'yyyy-MM-dd HH:mm')}" > 2024-01-01 </ time >
< span th:if = "${post.categories.size() > 0}" >
< span th:text = "#{post.in}" > in </ span >
< span th:each = "category, iterStat : ${post.categories}" >
< a th:href = "@{'/categories/' + ${category.metadata.name}}"
th:text = "${category.spec.displayName}" > Category </ a >
< span th:if = "${!iterStat.last}" > , </ span >
</ span >
</ span >
</ div >
</ header >
<!-- Post content -->
< div class = "post-content" th:utext = "${content.content}" >
Post content goes here...
</ div >
<!-- Post tags -->
< footer th:if = "${post.tags.size() > 0}" class = "post-footer" >
< div class = "post-tags" >
< span th:text = "#{post.tags}" > Tags: </ span >
< a th:each = "tag : ${post.tags}"
th:href = "@{'/tags/' + ${tag.metadata.name}}"
th:text = "${tag.spec.displayName}"
class = "tag" > Tag </ a >
</ div >
</ footer >
</ article >
<!-- Comment section -->
< section class = "comments" >
< halo:comment />
</ section >
</ main >
< footer th:replace = "~{partials/footer :: footer}" ></ footer >
< script th:src = "@{${#theme.assets('/js/main.js')}}" ></ script >
</ body >
</ html >
Next Steps
Assets Learn how to manage CSS, JavaScript, and images
Configuration Configure your theme settings and options