Forum Renderer Implementation and Customization
This guide defines the recommended integration model for forum rendering.
What This Solves
The forum renderer stack provides one opinionated pipeline for:
- category list rendering
- topic list rendering
- comment thread rendering
- inline token hydration (
[[key::value]]) with batch resolver lookup - per-item action rendering (
quote,edit,delete, custom) - topic/comment pagination controls with HTMX previous/next wiring
Design Rules
- Source types are data-only (
ForumCategoryData,ForumTopicData,ForumCommentData). - Final component assembly happens through fluent component interfaces (
withX(...)). - Component instances are created by suppliers (
MyTopicComponent::create). - Tag parsing/resolution is internal per render call; no renderer-held mutable intermediate state.
- Unresolved tags remain visible as literal tokens.
Quick Start
import io.mindspice.simplypages.components.forum.actions.DefaultForumActionProvider;
import io.mindspice.simplypages.components.forum.ForumCollapsibleComposer;
import io.mindspice.simplypages.components.forum.comments.DefaultForumCommentComponent;
import io.mindspice.simplypages.components.forum.comments.ForumCommentData;
import io.mindspice.simplypages.components.forum.comments.ForumCommentRenderer;
import io.mindspice.simplypages.components.forum.tags.ForumTagResolverRegistry;
import io.mindspice.simplypages.components.forum.tags.ForumTagResolvers;
import io.mindspice.simplypages.components.forum.topics.DefaultForumTopicComponent;
import io.mindspice.simplypages.components.forum.topics.ForumTopicData;
import io.mindspice.simplypages.components.forum.topics.ForumTopicRenderer;
import io.mindspice.simplypages.components.forum.topics.ForumTopicTitleLink;
import io.mindspice.simplypages.core.Component;
import io.mindspice.simplypages.core.HtmlTag;
import java.util.List;
import java.util.Map;
record TopicDto(String id, String title, String body, String author, String timestamp, Integer likes, Integer replies)
implements ForumTopicData {}
record CommentDto(String id, String topicId, String parentId, int depth, String body, String author, String avatarUrl, String timestamp, Integer likes, Integer replies)
implements ForumCommentData {}
record Viewer(String userId, boolean moderator) {}
ForumTagResolverRegistry tagRegistry = ForumTagResolverRegistry.withBuiltIns()
.register(ForumTagResolvers.of("product.card", tags -> tags.stream().collect(java.util.stream.Collectors.toMap(
t -> t,
t -> new HtmlTag("span").withAttribute("class", "product-chip").withInnerText("Product #" + t.value())
))));
DefaultForumActionProvider<TopicDto, Viewer> topicActions = DefaultForumActionProvider.<TopicDto, Viewer>create()
.showEditWhen(ctx -> ctx.context() != null && ctx.context().moderator());
ForumTopicRenderer<TopicDto, Viewer> topicRenderer = ForumTopicRenderer.<TopicDto, Viewer>builder()
.withResolverRegistry(tagRegistry)
.withActionProvider(topicActions)
.withBodyTextResolver((topic, viewerCtx) -> topic.body()) // replace with preview text when needed
.withTitleLinkResolver((topic, viewerCtx) -> ForumTopicTitleLink.htmx(
"/forum?view=comments&topic=" + topic.id(),
"/forum/topics/" + topic.id() + "/comments",
"#forum-main",
"outerHTML",
"/forum?view=comments&topic=" + topic.id()
))
.withTopicComponentSupplier(DefaultForumTopicComponent::create)
.build();
Viewer viewer = new Viewer("u-1", true);
Component topics = topicRenderer.render(List.of(
new TopicDto("topic-1", "Release", "Use [[quote::comment-42]] and [[product.card::abc]].", "alice", "2026-03-09", 3, 10)
), viewer);
ForumCommentRenderer<CommentDto, Viewer> commentRenderer = ForumCommentRenderer.<CommentDto, Viewer>builder()
.withResolverRegistry(tagRegistry)
.withCommentComponentSupplier(DefaultForumCommentComponent::create)
.build();
Component comments = commentRenderer.render(List.of(
new CommentDto("comment-42", "topic-1", null, 0, "See [[mention::sam]]", "bob", null, "2m ago", 1, null)
), viewer);
Comment Depth and Custom Components
The default comment card emits every configured signed int as data-depth, unchanged.
Its single forum-comment-depth-0..4 class clamps only presentation: negative values look
like zero and values above four look like four. Parent ID and depth are independent;
null/blank parents omit data-parent-id, while nonblank values remain escaped metadata.
Framework CSS indents from the logical start by 16px per visual level, or 8px at viewport widths up to 920px, capped at four levels and 8% of the containing inline size. Cards use the remaining width. Narrow hosts can stack the default owned layout while retaining the 150x150 avatar reservation and usable controls. No depth inline style is added, so host CSS can override the defaults.
Existing default-card consumers now see depth metadata/classes and indentation. Custom
suppliers still receive raw parent/depth once per selected item and own their markup;
metadata or the common forum-comment class alone does not enable this new styling.
Default rendering leaves configured state unchanged and dispatches body/actions once with
the same context. Compiled templates retain live default/subclass rendering. Complete
configuration before sharing; request contexts and child delegates retain separate sharing
requirements.
These are flat cards, not a hierarchy engine. Rendering preserves collection order and does not validate, resolve or sort parent references, even if absent, self-referential, cyclic or cross-topic. Pagination can put a child before its parent or on another page. Applications own hierarchy rules and data loading.
Pagination
Topic pagination:
Component pagedTopics = topicRenderer.render(
loadedTopics,
viewer,
new ForumTopicRenderer.TopicPagination("cat-1", requestedPage, topicsPerPage, totalTopics)
);
Comment pagination:
Component pagedComments = commentRenderer.render(
loadedComments,
viewer,
new ForumCommentRenderer.CommentPagination("topic-1", requestedPage, commentsPerPage, totalComments)
);
Collapsible composer boxes (default collapsed):
Component newTopicForm = buildTopicForm(); // application-owned form component
Component newCommentForm = buildCommentForm();
Component topicComposer = ForumCollapsibleComposer.create("New Topic", newTopicForm);
Component commentComposer = ForumCollapsibleComposer.create("New Comment", newCommentForm);
// Render topic composer above the topic list and comment composer below comment list.
// Expand explicitly when handling edit-in-progress states.
Component editTopicComposer = ForumCollapsibleComposer
.create("Edit Topic", newTopicForm)
.expandedByDefault();
Behavior:
- renderer limits output to requested page window
- renderer appends previous/next controls + page status
- controls enable/disable from computed bounds
- enabled controls emit
hx-getwith configured endpoint resolver - enabled controls tag
data-sp-scroll-top="target"so HTMX swaps return to the top of the rendered forum fragment
Pagination Data Ownership
The renderer does not load data. Your application owns:
- scoped filtering (
categoryId/topicId) - total count calculation from the scoped dataset
- requested page and page size parsing/validation
topicsPerPage and commentsPerPage are strict render limits per request.
Example:
requestedPage=2,topicsPerPage=10renders at most 10 items (index 10..19) from the scoped list.totalTopicsmust be the count of all scoped topics, not just the current page slice.
Important:
ForumTopicRenderer.TopicPagination.scopeIdis a routing key for pagination controls; topic filtering is application-owned.ForumCommentRenderer.CommentPagination.topicIdis both a routing key and an in-render topic filter.
How many posts/comments render per response:
topicsPerPageis the strict maximum number of topic cards rendered byForumTopicRendererfor one request.commentsPerPageis the strict maximum number of comment cards rendered byForumCommentRendererfor one request.- If totals are larger than page size, the remainder is available through pagination controls.
- If requested page is out of range, renderers clamp to the last valid page.
- Preview/body length is separate from pagination size. Use
withBodyTextResolver(...)when topic list pages should render snippets instead of full body text.
Optional topic scope filtering:
ForumTopicRenderer<TopicDto, Viewer> scopedRenderer = ForumTopicRenderer.<TopicDto, Viewer>builder()
.withTopicScopeExtractor(topic -> topic.id().split(":")[0]) // should match TopicPagination.scopeId
.build();
Tag Hydration
Pipeline:
- Fast check (
contains("[[")) to skip unnecessary parse work. - Parse body into text/tag segments.
- Batch collect unique tags by
TagType. - Resolve once per type through
ForumTagResolverRegistry. - Compose final body component in-order (markdown text + resolved tag components).
Important:
- no raw HTML token replacement
- unresolved tags render as visible literals
- malformed tokens are preserved as text
Actions
Use ForumActionProvider<SOURCE, CTX> for item actions.
- Context-driven permissions belong here.
- Final component interfaces receive precomputed action components only.
- Visibility in rendered output is not authorization; controllers must validate permissions.
- To set default HTMX response placement on generated buttons, use
DefaultForumActionProvider.withHxTarget(...)andwithHxSwap(...). - To preserve in-progress composer text when using quote actions, set
DefaultForumActionProvider.withQuoteHxInclude(...)to include the active composer textarea/input selector in quote requests.
Demo Pipeline Reference (/forum)
The site module includes a full in-memory demo pipeline wired with these endpoints:
GET /forumfull page render (no sidebar shell)GET /forum/topicstopic fragment refresh + paginationGET /forum/topics/{topicId}/commentsthread fragment refresh + paginationPOST /forum/viewertemporary session viewer identityPOST /forum/topics/create|{id}/quote|{id}/edit|{id}/update|{id}/deletePOST /forum/comments/create|{id}/quote|{id}/edit|{id}/update|{id}/delete
Recommended wiring pattern used in demo:
- staged UX:
view=categories|topics|commentskeeps one forum level visible at a time - drill-down path: category card -> topic list -> thread comments
- renderers target one fragment root (for example
#forum-main) withouterHTMLswaps - action endpoints return refreshed fragments, not raw status payloads
- ownership/moderation checks run in controller/service before update/delete
- the demo's reply-creation depth cap of five is application policy, not a framework input limit or visual cap
Testing Checklist
- Tag resolution and unresolved literal fallback
- Batch resolver invocation count per type
- Topic/comment pagination controls and HTMX attributes
- Action rendering with context-sensitive predicates
- Required ID validation failures for topics/comments
- Raw depth extremes, parent independence, flat order/filter/page splits and custom supplier dispatch
- Default direct/fresh-compiled rendering, unchanged configured state and same-context body/actions
- Numeric depth indentation, LTR/RTL, narrow-host avatar/body/control containment and native keyboard use