@@ -0,0 +1,181 @@
|
||||
# Creating a New Page
|
||||
|
||||
This guide explains the full lifecycle of adding a new page to the app: the template file, the code-behind class, the handler, and the sidebar link.
|
||||
|
||||
## How pages work
|
||||
|
||||
Every page is a pair of files:
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `Templates/MyPage.htmx` | HTML markup with `$$SlotName$$` slots |
|
||||
| `Templates/MyPage.htmx.cs` | C# class that fills slots + declares the Minimal API handler |
|
||||
|
||||
The Roslyn source generator (`Htmx.SourceGenerator`) reads every `.htmx` file at build time and generates an abstract base class for it. You then write a concrete class in the companion `.htmx.cs` file that inherits from that base.
|
||||
|
||||
## How `$$SlotName$$` becomes code
|
||||
|
||||
Take this simple template:
|
||||
|
||||
```html
|
||||
<!-- Templates/MyPage.htmx -->
|
||||
<div class="p-6">
|
||||
<h1>$$Title$$</h1>
|
||||
<p>$$Body$$</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
The generator splits the file on `$$...$$` patterns and produces:
|
||||
|
||||
```csharp
|
||||
// auto-generated — do NOT edit
|
||||
public abstract partial class MyPageBase : IHtmxComponent
|
||||
{
|
||||
protected abstract void RenderTitle(HtmxRenderContext context);
|
||||
protected abstract void RenderBody(HtmxRenderContext context);
|
||||
|
||||
// static HTML segments stored as ReadOnlySpan<byte> for zero-allocation output
|
||||
private static ReadOnlySpan<byte> _part0 => new byte[] { ... };
|
||||
private static ReadOnlySpan<byte> _part1 => new byte[] { ... };
|
||||
private static ReadOnlySpan<byte> _part2 => new byte[] { ... };
|
||||
|
||||
public void Render(HtmxRenderContext context)
|
||||
{
|
||||
context.Writer.WriteUtf8(_part0); // <div class="p-6"><h1>
|
||||
RenderTitle(context.Next());
|
||||
context.Writer.WriteUtf8(_part1); // </h1><p>
|
||||
RenderBody(context.Next());
|
||||
context.Writer.WriteUtf8(_part2); // </p></div>
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Your job is to write the concrete class that implements each `RenderXxx` method.
|
||||
|
||||
## Step 1 — Create the `.htmx` template
|
||||
|
||||
Create `Htmx.ApiDemo/Templates/MyPage.htmx`:
|
||||
|
||||
```html
|
||||
<div class="p-6 space-y-4">
|
||||
<h1 class="text-2xl font-bold text-foreground">$$Heading$$</h1>
|
||||
<p class="text-muted-foreground">$$Description$$</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
Rules:
|
||||
- Slot names are **PascalCase** and surrounded by `$$` — e.g. `$$MySlot$$`
|
||||
- A slot can hold plain text, HTML, or another rendered component
|
||||
- The file must be saved in `Templates/` (or a subfolder) so the `.csproj` `AdditionalFiles` glob picks it up
|
||||
|
||||
## Step 2 — Create the `.htmx.cs` code-behind
|
||||
|
||||
Create `Htmx.ApiDemo/Templates/MyPage.htmx.cs`:
|
||||
|
||||
```csharp
|
||||
using Immediate.Apis.Shared;
|
||||
using Immediate.Handlers.Shared;
|
||||
|
||||
namespace Htmx.ApiDemo.Templates;
|
||||
|
||||
// Concrete template — inherits from the generated base
|
||||
public sealed class MyPage : MyPageBase
|
||||
{
|
||||
private byte[] _headingData = [];
|
||||
private byte[] _descriptionData = [];
|
||||
|
||||
// Use `init`-only setters to pre-encode strings to UTF-8 bytes once
|
||||
public required string Heading { init => _headingData = value.ToUtf8Bytes(); }
|
||||
public required string Description { init => _descriptionData = value.ToUtf8Bytes(); }
|
||||
|
||||
protected override void RenderHeading(HtmxRenderContext context)
|
||||
=> context.Writer.WriteUtf8(_headingData);
|
||||
|
||||
protected override void RenderDescription(HtmxRenderContext context)
|
||||
=> context.Writer.WriteUtf8(_descriptionData);
|
||||
}
|
||||
|
||||
// Minimal API handler — discovered and registered by the source generator
|
||||
[Handler]
|
||||
[MapGet("/my-page")]
|
||||
public static partial class GetMyPageHandler
|
||||
{
|
||||
public record Query; // add route/query parameters here if needed
|
||||
|
||||
private static ValueTask HandleAsync(
|
||||
Query query,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
CancellationToken token)
|
||||
{
|
||||
var ctx = httpContextAccessor.HttpContext
|
||||
?? throw new InvalidOperationException("HttpContext is not available.");
|
||||
|
||||
var page = new MyPage
|
||||
{
|
||||
Heading = "My New Page",
|
||||
Description = "This is a minimal example page."
|
||||
};
|
||||
|
||||
// WriteHtmxPage: full HTML shell for direct browser loads,
|
||||
// bare fragment for HTMX partial swaps (HX-Request header present)
|
||||
ctx.WriteHtmxPage(page, title: "My Page", appName: "HtmxApp", pageTitle: "My Page");
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Step 3 — Add a sidebar link (optional but typical)
|
||||
|
||||
Open `Templates/MainLayout.htmx` and add a nav entry inside the `<nav>` block. Existing entries look like this:
|
||||
|
||||
```html
|
||||
<a href="/my-page"
|
||||
hx-get="/my-page" hx-target="#main-view" hx-push-url="true"
|
||||
class="sidebar-link group flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium
|
||||
text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground">
|
||||
<!-- inline SVG icon here -->
|
||||
My Page
|
||||
</a>
|
||||
```
|
||||
|
||||
Key HTMX attributes:
|
||||
- `hx-get` — makes the navigation a partial swap instead of a full page reload
|
||||
- `hx-target="#main-view"` — replaces only the content area, keeping the sidebar in place
|
||||
- `hx-push-url="true"` — updates the browser URL bar so deep-links still work
|
||||
|
||||
## How `WriteHtmxPage` decides what to render
|
||||
|
||||
```
|
||||
Request has HX-Request header?
|
||||
YES → render bare fragment + set HX-Title response header (browser tab title updates)
|
||||
NO → wrap fragment in MainLayout (full HTML page with sidebar, navbar, etc.)
|
||||
```
|
||||
|
||||
The logic lives in `HtmxPageExtensions.WriteHtmxPage`. You never need to fork on this yourself.
|
||||
|
||||
## Slots that hold components
|
||||
|
||||
A slot does not have to render plain text. If you need to embed a reusable component, assign the component instance and call `Render` from the override:
|
||||
|
||||
```csharp
|
||||
public IHtmxComponent MyCard { get; }
|
||||
|
||||
public MyPage(IHtmxComponent myCard)
|
||||
{
|
||||
MyCard = myCard;
|
||||
}
|
||||
|
||||
protected override void RenderMyCard(HtmxRenderContext context)
|
||||
=> MyCard.Render(context);
|
||||
```
|
||||
|
||||
See [03-creating-a-component.md](03-creating-a-component.md) for the full component pattern.
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] `MyPage.htmx` created in `Templates/`
|
||||
- [ ] `MyPage.htmx.cs` created with a class inheriting `MyPageBase`
|
||||
- [ ] Each `$$Slot$$` has a matching `RenderSlot` override
|
||||
- [ ] `[Handler]` + `[MapGet(...)]` (or `MapPost` etc.) on the handler class
|
||||
- [ ] `ctx.WriteHtmxPage(...)` called from `HandleAsync`
|
||||
- [ ] Build once — if a slot is missing its override, the compiler will tell you
|
||||
Reference in New Issue
Block a user