Compare commits

...

10 Commits

80 changed files with 4481 additions and 5053 deletions
+5
View File
@@ -0,0 +1,5 @@
**/obj/
**/bin/
**/.git/
**/.vs/
**/node_modules/
+5 -4
View File
@@ -1,4 +1,5 @@
bin
obj
node_modules
.env
**/bin/
**/obj/
**/node_modules/
.env
**/Publish/
+30 -58
View File
@@ -1,98 +1,70 @@
# ─────────────────────────────────────────────────────────────────────────────
# Stage 1 — npm install (Tailwind CLI)
# The .NET SDK stage needs node_modules present before running dotnet publish
# because the MSBuild Tailwind target calls `npx @tailwindcss/cli` during build.
#
# We use the official node:24-slim image here. This means the npm that ships
# with Node 24 (npm 10.x) is used as-is — we deliberately do NOT run
# `npm install -g npm@latest` anywhere. Running a global npm self-upgrade
# inside a Debian container is a known reliability hazard: npm replaces its
# own running binaries mid-flight, which can cause EBUSY / ENOENT failures
# that corrupt the install. The bundled npm is current enough.
# Uses node:24-alpine for a small image. npm ci is used for reproducible,
# fast installs from the lockfile.
# ─────────────────────────────────────────────────────────────────────────────
FROM node:24-slim AS npm-install
FROM node:24-alpine AS npm-install
WORKDIR /npm
COPY Htmx.ApiDemo/package.json .
# npm ci requires package-lock.json; if it doesn't exist yet, run
# `npm install` locally first to generate it, then commit it to the repo.
COPY Htmx.ApiDemo/package-lock.json* ./
# ci is preferred over install in CI/Docker contexts: respects package-lock,
# clean installs, and is faster.
COPY Htmx.ApiDemo/package.json Htmx.ApiDemo/package-lock.json* ./
RUN npm ci
# ─────────────────────────────────────────────────────────────────────────────
# Stage 2 — AOT publish
# Uses the full .NET 10 SDK image. Node/npx must also be present here so the
# Tailwind MSBuild target can run. We install Node 24 from NodeSource using
# the official setup script and then immediately install nodejs via apt — no
# subsequent `npm install -g npm` step, for the same reason as above.
# Uses the Alpine SDK image so the binary is linked against musl libc,
# making it compatible with the Alpine runtime image in Stage 3.
#
# Alpine packages are cached via BuildKit mount cache — after the first build
# `apk add` is near-instant because the package index and downloads are
# reused from the local cache rather than re-fetched from the network.
# ─────────────────────────────────────────────────────────────────────────────
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS publish
FROM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS publish
# Install Node.js 24 (required by the Tailwind MSBuild target at publish time).
# We download the NodeSource setup script to a file first so we can inspect it
# if needed, then run it. Using `| bash -` directly is convenient but hides
# the script from audit — the two-step form is safer in CI/CD contexts.
RUN apt-get update && \
apt-get install -y --no-install-recommends curl ca-certificates && \
curl -fsSL https://deb.nodesource.com/setup_24.x -o /tmp/nodesource_setup.sh && \
bash /tmp/nodesource_setup.sh && \
apt-get install -y --no-install-recommends nodejs && \
rm /tmp/nodesource_setup.sh && \
rm -rf /var/lib/apt/lists/*
# Intentionally no `npm install -g npm` — see Stage 1 note above.
# Install AOT linker tools and Node.js (for the Tailwind MSBuild target).
# --mount=type=cache keeps the apk package cache between builds on this machine.
RUN --mount=type=cache,target=/var/cache/apk \
apk add --no-cache clang lld musl-dev build-base nodejs npm
WORKDIR /src
# Copy solution and project files first so NuGet restore is cached separately
# Copy project files first NuGet restore layer is cached until these change.
COPY Htmx.slnx .
COPY Htmx.ApiDemo/Htmx.ApiDemo.csproj Htmx.ApiDemo/
COPY Htmx.SourceGenerator/Htmx.SourceGenerator.csproj Htmx.SourceGenerator/
RUN dotnet restore Htmx.ApiDemo/Htmx.ApiDemo.csproj
RUN dotnet restore Htmx.ApiDemo/Htmx.ApiDemo.csproj -r linux-musl-x64
# Bring in the pre-installed node_modules from Stage 1.
# These were installed with `npm ci` on Node 24 — no npm upgrade was performed.
# Bring in pre-installed node_modules from Stage 1.
COPY --from=npm-install /npm/node_modules Htmx.ApiDemo/node_modules
# Copy the rest of the source
COPY . .
# AOT publish — output goes to /publish
# AOT publish targeting musl so the binary runs on Alpine.
RUN dotnet publish Htmx.ApiDemo/Htmx.ApiDemo.csproj \
-c Release \
-r linux-musl-x64 \
--no-restore \
--self-contained true \
-o /publish
# ─────────────────────────────────────────────────────────────────────────────
# Stage 3 — Runtime image
# runtime-deps provides the native library dependencies (libc, libssl, libicu)
# that the AOT binary links against at runtime — no .NET runtime needed.
# Stage 3 — Runtime image (~12 MB base vs ~100 MB on Debian)
# runtime-deps:alpine provides only the native libs the AOT binary needs.
# No .NET runtime is included — the binary is fully self-contained.
# ─────────────────────────────────────────────────────────────────────────────
FROM mcr.microsoft.com/dotnet/runtime-deps:10.0 AS runtime
# Run as non-root for security hardening (recommended by Cloud Run docs)
RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser
FROM mcr.microsoft.com/dotnet/runtime-deps:10.0-alpine AS runtime
WORKDIR /app
COPY --from=publish /publish .
# Ensure the binary is executable
RUN chmod +x ./Htmx.ApiDemo
# Cloud Run injects PORT (default 8080).
# ASP.NET Core reads ASPNETCORE_HTTP_PORTS, not PORT directly, so we set it.
# The entrypoint script below maps $PORT → ASPNETCORE_HTTP_PORTS at startup.
COPY GCR/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# ASP.NET Core reads ASPNETCORE_HTTP_PORTS directly — no entrypoint script needed.
ENV ASPNETCORE_HTTP_PORTS=8080
# Transfer ownership so the app can write temp files if needed
RUN chown -R appuser:appgroup /app
USER appuser
# Use the built-in non-root user provided by the official .NET Alpine image.
USER $APP_UID
EXPOSE 8080
ENTRYPOINT ["/entrypoint.sh"]
ENTRYPOINT ["./Htmx.ApiDemo"]
+13 -11
View File
@@ -1,11 +1,12 @@
#Requires -Version 5.1
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
param(
[switch]$Yes
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$RootDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$EnvFile = Join-Path $RootDir ".env"
@@ -63,7 +64,8 @@ function Test-Login {
$dockerCfg = if ($env:DOCKER_CONFIG) { Join-Path $env:DOCKER_CONFIG "config.json" } else { Join-Path $HOME ".docker\config.json" }
$dockerOk = $false
if (Test-Path $dockerCfg) {
$dockerOk = (Select-String -Path $dockerCfg -Pattern "\"$($Cfg['GCP_REGION'])-docker.pkg.dev\"" -SimpleMatch -Quiet)
$dockerPattern = "`"$($Cfg['GCP_REGION'])-docker.pkg.dev`""
$dockerOk = (Select-String -Path $dockerCfg -Pattern $dockerPattern -SimpleMatch -Quiet)
}
return (-not [string]::IsNullOrWhiteSpace($activeAccount)) -and
@@ -75,7 +77,7 @@ function Test-Login {
function Test-ProjectSetup {
param([hashtable]$Cfg)
$billingEnabled = (gcloud billing projects describe $Cfg['GCP_PROJECT_ID'] --format="value(billingEnabled)" 2>$null)
$billingEnabled = (gcloud billing projects describe $Cfg['GCP_PROJECT_ID'] --format='value(billingEnabled)' 2>$null)
if ($billingEnabled -ne 'True') { return $false }
try {
@@ -85,7 +87,7 @@ function Test-ProjectSetup {
}
foreach ($api in @('run.googleapis.com', 'artifactregistry.googleapis.com', 'secretmanager.googleapis.com', 'cloudresourcemanager.googleapis.com')) {
$enabled = gcloud services list --enabled --project=$Cfg['GCP_PROJECT_ID'] --format="value(config.name)" 2>$null | Select-String -Pattern "^$([regex]::Escape($api))$"
$enabled = gcloud services list --enabled --project=$Cfg['GCP_PROJECT_ID'] --format='value(config.name)' 2>$null | Select-String -Pattern "^$([regex]::Escape($api))$"
if (-not $enabled) { return $false }
}
@@ -101,14 +103,14 @@ function Test-SecretsSetup {
return $false
}
$serviceAccount = "serviceAccount:$($Cfg['GCP_PROJECT_ID'])@appspot.gserviceaccount.com"
$binding = gcloud secrets get-iam-policy mongodb-connection-string `
# Check if any service account has secretAccessor access (not just @appspot)
$bindings = gcloud secrets get-iam-policy mongodb-connection-string `
--project=$Cfg['GCP_PROJECT_ID'] `
--flatten="bindings[].members" `
--filter="bindings.role=roles/secretmanager.secretAccessor AND bindings.members=$serviceAccount" `
--format="value(bindings.members)" 2>$null
--flatten='bindings[].members' `
--filter='bindings.role=roles/secretmanager.secretAccessor' `
--format='value(bindings.members)' 2>$null
return ($binding -match [regex]::Escape($serviceAccount))
return (-not [string]::IsNullOrWhiteSpace($bindings))
}
function Test-DeployDone {
+3 -3
View File
@@ -28,8 +28,8 @@ foreach ($line in Get-Content $EnvFile) {
}
}
$GCP_PROJECT_ID = $config['GCP_PROJECT_ID'] ?? ''
$GCP_REGION = $config['GCP_REGION'] ?? ''
$GCP_PROJECT_ID = if ($config['GCP_PROJECT_ID']) { $config['GCP_PROJECT_ID'] } else { '' }
$GCP_REGION = if ($config['GCP_REGION']) { $config['GCP_REGION'] } else { '' }
if (-not $GCP_PROJECT_ID) { Write-Error "GCP_PROJECT_ID is not set in .env"; exit 1 }
if (-not $GCP_REGION) { Write-Error "GCP_REGION is not set in .env"; exit 1 }
@@ -57,6 +57,6 @@ gcloud auth configure-docker "$GCP_REGION-docker.pkg.dev" --quiet
Write-Host ""
Write-Host ">>> Login complete. You are now authenticated as:"
gcloud auth list --filter=status:ACTIVE --format="value(account)"
gcloud auth list --filter=status:ACTIVE --format='value(account)'
Write-Host ""
Write-Host ">>> Next step: run GCR\scripts\02-setup-project.ps1"
+37 -92
View File
@@ -1,126 +1,71 @@
# =============================================================================
# 02-setup-project.ps1 (Windows)
# One-time GCP project setup:
# - Links a billing account to the project
# - Enables required APIs (Cloud Run, Artifact Registry, Secret Manager)
# - Creates an Artifact Registry Docker repository
# - Grants the current user the minimum required IAM roles
#
# Safe to re-run — most operations are idempotent.
# Linux users: run GCR/scripts/02-setup-project.sh instead.
# =============================================================================
#Requires -Version 5.1
#Requires -Version 5.1
param()
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$ErrorActionPreference = 'Continue'
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$EnvFile = Join-Path $ScriptDir "..\\.env"
# ── Load .env ─────────────────────────────────────────────────────────────────
if (-not (Test-Path $EnvFile)) {
Write-Error "ERROR: $EnvFile not found.`nCopy GCR\.env.example to GCR\.env and fill in your values first."
exit 1
}
if (-not (Test-Path $EnvFile)) { Write-Error "ERROR: $EnvFile not found."; exit 1 }
$config = @{}
foreach ($line in Get-Content $EnvFile) {
if ($line -match '^\s*$' -or $line -match '^\s*#') { continue }
if ($line -match '^([^=]+)=(.*)$') {
$config[$Matches[1].Trim()] = $Matches[2].Trim()
}
if ($line -match '^([^=]+)=(.*)$') { $config[$Matches[1].Trim()] = $Matches[2].Trim() }
}
$GCP_PROJECT_ID = $config['GCP_PROJECT_ID'] ?? ''
$GCP_REGION = $config['GCP_REGION'] ?? ''
$GCP_REPOSITORY = $config['GCP_REPOSITORY'] ?? ''
if (-not $GCP_PROJECT_ID) { Write-Error "GCP_PROJECT_ID is not set in .env"; exit 1 }
if (-not $GCP_REGION) { Write-Error "GCP_REGION is not set in .env"; exit 1 }
if (-not $GCP_REPOSITORY) { Write-Error "GCP_REPOSITORY is not set in .env"; exit 1 }
$GCP_PROJECT_ID = if ($config['GCP_PROJECT_ID']) { $config['GCP_PROJECT_ID'] } else { '' }
$GCP_REGION = if ($config['GCP_REGION']) { $config['GCP_REGION'] } else { '' }
$GCP_REPOSITORY = if ($config['GCP_REPOSITORY']) { $config['GCP_REPOSITORY'] } else { '' }
if (-not $GCP_PROJECT_ID) { Write-Error "GCP_PROJECT_ID not set"; exit 1 }
if (-not $GCP_REGION) { Write-Error "GCP_REGION not set"; exit 1 }
if (-not $GCP_REPOSITORY) { Write-Error "GCP_REPOSITORY not set"; exit 1 }
Write-Host ">>> Active project: $GCP_PROJECT_ID"
Write-Host ">>> Region: $GCP_REGION"
Write-Host ">>> AR repository: $GCP_REPOSITORY"
Write-Host ""
# ── Step 1: Link billing account ──────────────────────────────────────────────
Write-Host ">>> Checking billing status..."
$billingOutput = gcloud billing projects describe $GCP_PROJECT_ID --format="value(billingEnabled)" 2>$null
$billingEnabled = ($billingOutput -eq "True")
if ($billingEnabled) {
Write-Host " Billing is already enabled — skipping."
Write-Host ">>> Checking billing..."
$billing = gcloud billing projects describe $GCP_PROJECT_ID --format='value(billingEnabled)' 2>$null
if ($billing -eq 'True') {
Write-Host " Billing already enabled."
} else {
Write-Host ""
Write-Host " Billing is NOT enabled on this project."
Write-Host " Listing available billing accounts..."
Write-Host ""
gcloud billing accounts list --format="table(name,displayName,open)"
Write-Host ""
$BILLING_ACCOUNT_ID = Read-Host " Enter the BILLING_ACCOUNT_ID from the list above (format: XXXXXX-XXXXXX-XXXXXX)"
gcloud billing projects link $GCP_PROJECT_ID --billing-account=$BILLING_ACCOUNT_ID
Write-Host " Billing NOT enabled. Listing accounts..."
gcloud billing accounts list --format='table(name,displayName,open)' 2>$null
$BILLING_ACCOUNT_ID = Read-Host " Enter BILLING_ACCOUNT_ID"
gcloud billing projects link $GCP_PROJECT_ID --billing-account=$BILLING_ACCOUNT_ID 2>$null
if ($LASTEXITCODE -ne 0) { Write-Error "Failed to link billing."; exit 1 }
Write-Host " Billing linked."
}
# ── Step 2: Enable required APIs ─────────────────────────────────────────────
Write-Host ""
Write-Host ">>> Enabling required Google Cloud APIs (this may take a minute)..."
gcloud services enable `
run.googleapis.com `
artifactregistry.googleapis.com `
secretmanager.googleapis.com `
cloudresourcemanager.googleapis.com `
--project=$GCP_PROJECT_ID
Write-Host ">>> Enabling required APIs..."
gcloud services enable run.googleapis.com artifactregistry.googleapis.com secretmanager.googleapis.com cloudresourcemanager.googleapis.com --project=$GCP_PROJECT_ID 2>$null
if ($LASTEXITCODE -ne 0) { Write-Error "Failed to enable APIs."; exit 1 }
Write-Host " APIs enabled."
# ── Step 3: Create Artifact Registry Docker repository ───────────────────────
Write-Host ""
Write-Host ">>> Creating Artifact Registry repository: $GCP_REPOSITORY ..."
$repoExists = $false
try {
gcloud artifacts repositories describe $GCP_REPOSITORY `
--location=$GCP_REGION `
--project=$GCP_PROJECT_ID 2>$null | Out-Null
$repoExists = $true
} catch { }
if ($repoExists) {
Write-Host " Repository already exists — skipping."
Write-Host ">>> Checking Artifact Registry repository: $GCP_REPOSITORY ..."
gcloud artifacts repositories describe $GCP_REPOSITORY --location=$GCP_REGION --project=$GCP_PROJECT_ID 2>$null | Out-Null
if ($LASTEXITCODE -eq 0) {
Write-Host " Repository already exists."
} else {
gcloud artifacts repositories create $GCP_REPOSITORY `
--repository-format=docker `
--location=$GCP_REGION `
--description="Container images for Htmx app" `
--project=$GCP_PROJECT_ID
Write-Host " Creating repository..."
gcloud artifacts repositories create $GCP_REPOSITORY --repository-format=docker --location=$GCP_REGION --description="Container images for Htmx app" --project=$GCP_PROJECT_ID 2>$null
if ($LASTEXITCODE -ne 0) { Write-Error "Failed to create repository."; exit 1 }
Write-Host " Repository created."
}
# ── Step 4: Grant current user the minimum required IAM roles ─────────────────
$CURRENT_USER = (gcloud config get-value account).Trim()
$CURRENT_USER = (gcloud config get-value account 2>$null).Trim()
Write-Host ""
Write-Host ">>> Granting IAM roles to $CURRENT_USER ..."
foreach ($role in @(
"roles/run.developer",
"roles/artifactregistry.writer",
"roles/iam.serviceAccountUser",
"roles/secretmanager.admin",
"roles/secretmanager.secretAccessor",
"roles/secretmanager.secretVersionAdder"
)) {
Write-Host " Adding role: $role"
gcloud projects add-iam-policy-binding $GCP_PROJECT_ID `
--member="user:$CURRENT_USER" `
--role=$role `
--quiet
foreach ($role in @("roles/run.developer","roles/artifactregistry.writer","roles/iam.serviceAccountUser","roles/secretmanager.admin","roles/secretmanager.secretAccessor","roles/secretmanager.secretVersionAdder")) {
Write-Host " $role"
gcloud projects add-iam-policy-binding $GCP_PROJECT_ID --member="user:$CURRENT_USER" --role=$role --quiet 2>$null | Out-Null
}
Write-Host ""
Write-Host ">>> Project setup complete."
Write-Host ""
Write-Host ">>> Summary:"
Write-Host " Project ID: $GCP_PROJECT_ID"
Write-Host " Region: $GCP_REGION"
Write-Host " Artifact Registry: $GCP_REGION-docker.pkg.dev/$GCP_PROJECT_ID/$GCP_REPOSITORY"
Write-Host ""
Write-Host ">>> Next step: run GCR\scripts\03-create-secrets.ps1"
+35 -100
View File
@@ -1,122 +1,57 @@
# =============================================================================
# 03-create-secrets.ps1 (Windows)
# Creates and configures secrets in Google Cloud Secret Manager.
#
# Run this after 02-setup-project.ps1 to set up sensitive configuration
# values (e.g., MongoDB connection string).
#
# Linux users: run GCR/scripts/03-create-secrets.sh instead.
# =============================================================================
#Requires -Version 5.1
#Requires -Version 5.1
param()
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$ErrorActionPreference = 'Continue'
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$EnvFile = Join-Path $ScriptDir "..\\.env"
# ── Load .env ─────────────────────────────────────────────────────────────────
if (-not (Test-Path $EnvFile)) {
Write-Error "ERROR: $EnvFile not found.`nCopy GCR\.env.example to GCR\.env and fill in your values first."
exit 1
}
if (-not (Test-Path $EnvFile)) { Write-Error "ERROR: $EnvFile not found."; exit 1 }
$config = @{}
foreach ($line in Get-Content $EnvFile) {
if ($line -match '^\s*$' -or $line -match '^\s*#') { continue }
if ($line -match '^([^=]+)=(.*)$') {
$config[$Matches[1].Trim()] = $Matches[2].Trim()
}
if ($line -match '^([^=]+)=(.*)$') { $config[$Matches[1].Trim()] = $Matches[2].Trim() }
}
$GCP_PROJECT_ID = $config['GCP_PROJECT_ID'] ?? ''
if (-not $GCP_PROJECT_ID) { Write-Error "GCP_PROJECT_ID is not set in .env"; exit 1 }
$GCP_PROJECT_ID = if ($config['GCP_PROJECT_ID']) { $config['GCP_PROJECT_ID'] } else { '' }
if (-not $GCP_PROJECT_ID) { Write-Error "GCP_PROJECT_ID not set"; exit 1 }
Write-Host "================================================================"
Write-Host " Google Cloud Secret Manager setup"
Write-Host " Google Cloud Secret Manager setup - Project: $GCP_PROJECT_ID"
Write-Host "================================================================"
Write-Host " Project: $GCP_PROJECT_ID"
Write-Host ""
# ── Helper function to create or update a secret ──────────────────────────────
function New-OrUpdateSecret {
param(
[string]$SecretName,
[string]$SecretPrompt
)
Write-Host ">>> Setting up secret: $SecretName"
Write-Host " $SecretPrompt"
# Read secret without echo
$SecretValue = Read-Host " Enter value (will not be echoed)" -AsSecureString
$PlainValue = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto(
[System.Runtime.InteropServices.Marshal]::SecureStringToCoTaskMemUni($SecretValue)
)
# Write to temp file without trailing newline to avoid contaminating the secret
$TempFile = [System.IO.Path]::GetTempFileName()
try {
[System.IO.File]::WriteAllText($TempFile, $PlainValue, [System.Text.Encoding]::UTF8)
$secretExists = $false
try {
gcloud secrets describe $SecretName --project=$GCP_PROJECT_ID 2>$null | Out-Null
$secretExists = $true
} catch { }
if ($secretExists) {
Write-Host " Secret already exists — creating new version..."
gcloud secrets versions add $SecretName `
--data-file=$TempFile `
--project=$GCP_PROJECT_ID
} else {
Write-Host " Creating new secret..."
gcloud secrets create $SecretName `
--data-file=$TempFile `
--replication-policy="automatic" `
--project=$GCP_PROJECT_ID
}
} finally {
Remove-Item $TempFile -Force -ErrorAction SilentlyContinue
$SecretName = "mongodb-connection-string"
Write-Host ">>> Secret: $SecretName"
Write-Host " MongoDB connection URI (e.g. mongodb+srv://user:pass@cluster.mongodb.net)"
$val = Read-Host " Enter value" -AsSecureString
$plain = [System.Net.NetworkCredential]::new("", $val).Password
$tmp = [System.IO.Path]::GetTempFileName()
try {
[System.IO.File]::WriteAllText($tmp, $plain, [System.Text.Encoding]::UTF8)
gcloud secrets describe $SecretName --project=$GCP_PROJECT_ID 2>$null | Out-Null
if ($LASTEXITCODE -eq 0) {
gcloud secrets versions add $SecretName --data-file=$tmp --project=$GCP_PROJECT_ID 2>$null | Out-Null
} else {
gcloud secrets create $SecretName --data-file=$tmp --replication-policy=automatic --project=$GCP_PROJECT_ID 2>$null | Out-Null
}
Write-Host " ✓ Secret '$SecretName' ready."
Write-Host ""
if ($LASTEXITCODE -ne 0) { Write-Error "Failed to save secret."; exit 1 }
} finally {
Remove-Item $tmp -Force -ErrorAction SilentlyContinue
}
Write-Host " Secret saved."
Write-Host ""
# ── Step 1: Create MongoDB connection string secret ──────────────────────────
New-OrUpdateSecret `
"mongodb-connection-string" `
"MongoDB Atlas or self-hosted connection URI (e.g., mongodb+srv://user:pass@cluster.mongodb.net)"
# ── Step 2: Grant Cloud Run service account access to secrets ─────────────────
Write-Host ">>> Granting Cloud Run service account access to secrets..."
Write-Host ""
# Get the default Cloud Run service account for this project
$SERVICE_ACCOUNT = "$GCP_PROJECT_ID@appspot.gserviceaccount.com"
foreach ($SECRET_NAME in @("mongodb-connection-string")) {
Write-Host " Granting Secret Accessor role for '$SECRET_NAME' to $SERVICE_ACCOUNT"
gcloud secrets add-iam-policy-binding $SECRET_NAME `
--member="serviceAccount:$SERVICE_ACCOUNT" `
--role="roles/secretmanager.secretAccessor" `
--project=$GCP_PROJECT_ID `
--quiet
}
$PROJECT_NUMBER = (gcloud projects describe $GCP_PROJECT_ID --format='value(projectNumber)' 2>$null).Trim()
$APPENGINE_SA = "$GCP_PROJECT_ID@appspot.gserviceaccount.com"
$COMPUTE_SA = "$PROJECT_NUMBER-compute@developer.gserviceaccount.com"
gcloud iam service-accounts describe $APPENGINE_SA --project=$GCP_PROJECT_ID 2>$null | Out-Null
$SA = if ($LASTEXITCODE -eq 0) { $APPENGINE_SA } else { $COMPUTE_SA }
Write-Host " Granting secretAccessor on '$SecretName' to: $SA"
gcloud secrets add-iam-policy-binding $SecretName --member="serviceAccount:$SA" --role=roles/secretmanager.secretAccessor --project=$GCP_PROJECT_ID --quiet 2>$null | Out-Null
if ($LASTEXITCODE -ne 0) { Write-Error "Failed to grant access."; exit 1 }
Write-Host ""
Write-Host "================================================================"
Write-Host " Secret Manager setup complete!"
Write-Host "================================================================"
Write-Host ""
Write-Host ">>> Summary:"
Write-Host " Secrets created:"
Write-Host " • mongodb-connection-string"
Write-Host ""
Write-Host " Service account granted access:"
Write-Host "$SERVICE_ACCOUNT"
Write-Host ""
Write-Host ">>> Secrets setup complete."
Write-Host ">>> Next step: run GCR\scripts\04-deploy.ps1"
Write-Host " (The deploy script will automatically inject secrets into"
Write-Host " the running container.)"
+52 -151
View File
@@ -1,112 +1,54 @@
# =============================================================================
# 04-deploy.ps1 (Windows)
# Builds the Docker image, pushes it to Artifact Registry, and deploys it
# to Cloud Run — all in one command.
#
# Usage:
# .\GCR\scripts\04-deploy.ps1 # deploy with tag = git short SHA
# .\GCR\scripts\04-deploy.ps1 -Tag my-tag # deploy with a custom tag
#
# Prerequisites:
# 1. GCR\.env exists and is filled in (copy from GCR\.env.example)
# 2. 01-login.ps1 has been run (gcloud auth + Docker configured)
# 3. 02-setup-project.ps1 has been run (APIs enabled, repo created)
# 4. 03-create-secrets.ps1 has been run (MongoDB secret created)
# 5. Docker Desktop is running
#
# Linux users: run GCR/scripts/04-deploy.sh instead.
# =============================================================================
#Requires -Version 5.1
[CmdletBinding()]
param(
[string]$Tag = ""
)
#Requires -Version 5.1
param([string]$Tag)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$ErrorActionPreference = 'Continue'
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$RepoRoot = (Resolve-Path (Join-Path $ScriptDir "..\..")).Path
$EnvFile = Join-Path $ScriptDir "..\\.env"
# ── Load .env ─────────────────────────────────────────────────────────────────
if (-not (Test-Path $EnvFile)) {
Write-Error "ERROR: $EnvFile not found.`nCopy GCR\.env.example to GCR\.env and fill in your values first."
exit 1
}
if (-not (Test-Path $EnvFile)) { Write-Error "ERROR: $EnvFile not found."; exit 1 }
$config = @{}
foreach ($line in Get-Content $EnvFile) {
if ($line -match '^\s*$' -or $line -match '^\s*#') { continue }
if ($line -match '^([^=]+)=(.*)$') {
$config[$Matches[1].Trim()] = $Matches[2].Trim()
}
if ($line -match '^([^=]+)=(.*)$') { $config[$Matches[1].Trim()] = $Matches[2].Trim() }
}
$GCP_PROJECT_ID = $config['GCP_PROJECT_ID'] ?? ''
$GCP_REGION = $config['GCP_REGION'] ?? ''
$GCP_REPOSITORY = $config['GCP_REPOSITORY'] ?? ''
$SERVICE_NAME = $config['SERVICE_NAME'] ?? ''
$MONGODB_DATABASE_NAME = $config['MONGODB_DATABASE_NAME'] ?? 'HtmxAppDb'
$GCP_PROJECT_ID = if ($config['GCP_PROJECT_ID']) { $config['GCP_PROJECT_ID'] } else { '' }
$GCP_REGION = if ($config['GCP_REGION']) { $config['GCP_REGION'] } else { '' }
$GCP_REPOSITORY = if ($config['GCP_REPOSITORY']) { $config['GCP_REPOSITORY'] } else { '' }
$SERVICE_NAME = if ($config['SERVICE_NAME']) { $config['SERVICE_NAME'] } else { '' }
$MONGODB_DATABASE_NAME = if ($config['MONGODB_DATABASE_NAME']) { $config['MONGODB_DATABASE_NAME'] } else { 'HtmxAppDb' }
# Note: MONGODB_CONNECTION_STRING is stored in Secret Manager (mongodb-connection-string)
if (-not $GCP_PROJECT_ID) { Write-Error "GCP_PROJECT_ID not set"; exit 1 }
if (-not $GCP_REGION) { Write-Error "GCP_REGION not set"; exit 1 }
if (-not $GCP_REPOSITORY) { Write-Error "GCP_REPOSITORY not set"; exit 1 }
if (-not $SERVICE_NAME) { Write-Error "SERVICE_NAME not set"; exit 1 }
if (-not $GCP_PROJECT_ID) { Write-Error "GCP_PROJECT_ID is not set in .env"; exit 1 }
if (-not $GCP_REGION) { Write-Error "GCP_REGION is not set in .env"; exit 1 }
if (-not $GCP_REPOSITORY) { Write-Error "GCP_REPOSITORY is not set in .env"; exit 1 }
if (-not $SERVICE_NAME) { Write-Error "SERVICE_NAME is not set in .env"; exit 1 }
# Note: MONGODB_CONNECTION_STRING is stored in Secret Manager
function Test-SecretsReady {
$serviceAccount = "serviceAccount:$GCP_PROJECT_ID@appspot.gserviceaccount.com"
try {
gcloud secrets describe mongodb-connection-string --project=$GCP_PROJECT_ID 2>$null | Out-Null
} catch {
return $false
}
$binding = gcloud secrets get-iam-policy mongodb-connection-string `
--project=$GCP_PROJECT_ID `
--flatten="bindings[].members" `
--filter="bindings.role=roles/secretmanager.secretAccessor AND bindings.members=$serviceAccount" `
--format="value(bindings.members)" 2>$null
return ($binding -match [regex]::Escape($serviceAccount))
}
if (-not (Test-SecretsReady)) {
Write-Host ""
Write-Host ">>> Required secrets are not fully configured yet."
$runSecretSetup = Read-Host " Run GCR\scripts\03-create-secrets.ps1 now? [y/N]"
if ($runSecretSetup -match '^[Yy]$') {
gcloud secrets describe mongodb-connection-string --project=$GCP_PROJECT_ID 2>$null | Out-Null
if ($LASTEXITCODE -ne 0) {
Write-Host ">>> Required secrets not configured."
$ans = Read-Host " Run 03-create-secrets.ps1 now? [y/N]"
if ($ans -match '^[Yy]$') {
& (Join-Path $ScriptDir "03-create-secrets.ps1")
gcloud secrets describe mongodb-connection-string --project=$GCP_PROJECT_ID 2>$null | Out-Null
if ($LASTEXITCODE -ne 0) { Write-Error "Secret setup failed."; exit 1 }
} else {
Write-Host ""
Write-Error "Deployment requires secret setup first. Run: .\GCR\scripts\03-create-secrets.ps1"
exit 1
}
if (-not (Test-SecretsReady)) {
Write-Host ""
Write-Error "Secret setup check still failing after running 03-create-secrets.ps1."
exit 1
Write-Error "Run 03-create-secrets.ps1 first."; exit 1
}
}
$bindings = gcloud secrets get-iam-policy mongodb-connection-string --project=$GCP_PROJECT_ID --flatten='bindings[].members' --filter='bindings.role=roles/secretmanager.secretAccessor' --format='value(bindings.members)' 2>$null
if ([string]::IsNullOrWhiteSpace($bindings)) {
Write-Error "No service account has secretAccessor access. Run 03-create-secrets.ps1 first."
exit 1
}
# ── Determine image tag ────────────────────────────────────────────────────────
if (-not $Tag) {
# Default to git short SHA if inside a git repo; otherwise use timestamp
try {
$Tag = (git -C $RepoRoot rev-parse --short HEAD 2>$null).Trim()
} catch { }
if (-not $Tag) {
$Tag = (Get-Date -Format "yyyyMMddHHmmss")
}
try { $Tag = (git rev-parse --short HEAD 2>$null).Trim() } catch { }
if ([string]::IsNullOrWhiteSpace($Tag)) { $Tag = (Get-Date -Format "yyyyMMdd-HHmmss") }
}
$REGISTRY = "$GCP_REGION-docker.pkg.dev"
$IMAGE_URI = "$REGISTRY/$GCP_PROJECT_ID/$GCP_REPOSITORY/${SERVICE_NAME}:$Tag"
$IMAGE = "$GCP_REGION-docker.pkg.dev/$GCP_PROJECT_ID/$GCP_REPOSITORY/htmx-demo-app:$Tag"
$contextDir = Split-Path -Parent (Split-Path -Parent $ScriptDir)
Write-Host "================================================================"
Write-Host " Htmx -> Cloud Run deployment"
@@ -114,81 +56,40 @@ Write-Host "================================================================"
Write-Host " Project: $GCP_PROJECT_ID"
Write-Host " Region: $GCP_REGION"
Write-Host " Service: $SERVICE_NAME"
Write-Host " Image: $IMAGE_URI"
Write-Host " Image: $IMAGE"
Write-Host "================================================================"
Write-Host ""
# ── Step 1: Ensure package-lock.json exists (required for `npm ci`) ───────────
$LockFile = Join-Path $RepoRoot "Htmx.ApiDemo\package-lock.json"
if (-not (Test-Path $LockFile)) {
Write-Host ">>> package-lock.json not found. Generating it now..."
Write-Host " (This requires node + npm to be installed locally)"
Push-Location (Join-Path $RepoRoot "Htmx.ApiDemo")
npm install --package-lock-only
Pop-Location
Write-Host " package-lock.json generated. Commit it to the repository."
Write-Host ""
docker info 2>$null | Out-Null
if ($LASTEXITCODE -ne 0) {
Write-Error "Docker is not running. Start Docker Desktop first."
exit 1
}
# ── Step 2: Build the Docker image ────────────────────────────────────────────
Write-Host ">>> Building Docker image..."
Write-Host " Context: $RepoRoot"
Write-Host " Dockerfile: GCR\Dockerfile"
Write-Host ""
# Build from repo root so COPY instructions can reach both project directories.
# Docker on Windows accepts forward slashes in --file.
$DockerFile = Join-Path $RepoRoot "GCR\Dockerfile"
docker build --file $DockerFile --tag $IMAGE_URI $RepoRoot
$env:DOCKER_BUILDKIT = "1"
docker build -t $IMAGE -f "$contextDir\GCR\Dockerfile" $contextDir
if ($LASTEXITCODE -ne 0) { Write-Error "Docker build failed."; exit 1 }
Write-Host ">>> Image built: $IMAGE"
Write-Host ""
Write-Host ">>> Image built: $IMAGE_URI"
# ── Step 3: Push image to Artifact Registry ───────────────────────────────────
Write-Host ""
Write-Host ">>> Pushing image to Artifact Registry..."
docker push $IMAGE_URI
Write-Host ">>> Pushing to Artifact Registry..."
docker push $IMAGE
if ($LASTEXITCODE -ne 0) { Write-Error "Docker push failed."; exit 1 }
Write-Host ">>> Push complete."
# ── Step 4: Deploy to Cloud Run via docker-compose.yml ───────────────────────
Write-Host ""
Write-Host ">>> Deploying to Cloud Run..."
gcloud run services update $SERVICE_NAME --project=$GCP_PROJECT_ID --region=$GCP_REGION --image=$IMAGE --set-env-vars=MONGODB_DATABASE_NAME=$MONGODB_DATABASE_NAME --set-secrets=ConnectionStrings__DefaultConnection=mongodb-connection-string:latest --allow-unauthenticated 2>$null
if ($LASTEXITCODE -ne 0) {
Write-Host " Service not found, creating..."
gcloud run deploy $SERVICE_NAME --project=$GCP_PROJECT_ID --region=$GCP_REGION --image=$IMAGE --set-env-vars=MONGODB_DATABASE_NAME=$MONGODB_DATABASE_NAME --set-secrets=ConnectionStrings__DefaultConnection=mongodb-connection-string:latest --allow-unauthenticated 2>$null
if ($LASTEXITCODE -ne 0) { Write-Error "Deployment failed."; exit 1 }
}
Write-Host ">>> Deployment complete."
# Set env vars consumed by docker-compose.yml variable substitution
$env:IMAGE_URI = $IMAGE_URI
$env:MONGODB_DATABASE_NAME = $MONGODB_DATABASE_NAME
$ComposeFile = Join-Path $RepoRoot "GCR\docker-compose.yml"
gcloud run services replace $ComposeFile `
--region=$GCP_REGION `
--project=$GCP_PROJECT_ID
# ── Step 4b: Inject MongoDB connection string from Secret Manager ────────────
$SERVICE_URL = (gcloud run services describe $SERVICE_NAME --region=$GCP_REGION --project=$GCP_PROJECT_ID --format='value(status.url)' 2>$null).Trim()
Write-Host ""
Write-Host ">>> Injecting MongoDB connection string from Secret Manager..."
gcloud run services update $SERVICE_NAME `
--region=$GCP_REGION `
--project=$GCP_PROJECT_ID `
--set-secrets="ConnectionStrings__DefaultConnection=mongodb-connection-string:latest"
# ── Step 5: Make the service publicly accessible ──────────────────────────────
# Remove this block if you want the service to require authentication.
Write-Host ""
Write-Host ">>> Allowing public (unauthenticated) access to the service..."
gcloud run services add-iam-policy-binding $SERVICE_NAME `
--region=$GCP_REGION `
--project=$GCP_PROJECT_ID `
--member="allUsers" `
--role="roles/run.invoker"
# ── Print service URL ─────────────────────────────────────────────────────────
Write-Host ""
$SERVICE_URL = (gcloud run services describe $SERVICE_NAME `
--region=$GCP_REGION `
--project=$GCP_PROJECT_ID `
--format="value(status.url)").Trim()
Write-Host "================================================================"
Write-Host " Deployment complete!"
Write-Host " Service URL: $SERVICE_URL"
Write-Host " Done! Service URL: $SERVICE_URL"
Write-Host "================================================================"
+1 -4
View File
@@ -1,13 +1,10 @@
using System.Text.Json.Serialization;
using Htmx.ApiDemo.Templates;
using Microsoft.AspNetCore.Http;
namespace Htmx.ApiDemo;
[JsonSerializable(typeof(string))]
[JsonSerializable(typeof(PostLoginHandler.Command), TypeInfoPropertyName = "LoginCommand")]
[JsonSerializable(typeof(PostRegisterHandler.Command), TypeInfoPropertyName = "RegisterCommand")]
[JsonSerializable(typeof(PostLogoutHandler.Command), TypeInfoPropertyName = "LogoutCommand")]
internal partial class AppJsonSerializerContext : JsonSerializerContext
{
}
+1 -1
View File
@@ -10,7 +10,7 @@ namespace Htmx.ApiDemo.Data;
/// No EF Core, no LINQ-to-SQL, no RelationalModel fully NativeAOT safe.
/// IPasswordHasher is pure PBKDF2 crypto with no dynamic IL.
/// </summary>
public sealed class AuthService(
public sealed class AppAuthService(
MongoDbService mongo,
IPasswordHasher<AppUser> passwordHasher,
IHttpContextAccessor httpContextAccessor)
+23
View File
@@ -0,0 +1,23 @@
namespace Htmx.ApiDemo;
public static class HtmxExtensions
{
public static void HtmxAwareWriteToBody(
this IHtmxComponent component, HttpContext context, string title, string appName, string pageTitle)
{
// If not a HX-Request, render the component inside main layout
if (!context.Request.Headers.ContainsKey("HX-Request"))
{
var layout = new MainLayout(component, title: title, appName: appName, pageTitle: pageTitle,
userName: context.User.Identity?.IsAuthenticated == true ? context.User.Identity.Name : null);
context.Response.ContentType = "text/html; charset=utf-8";
var renderContext = new HtmxRenderContext(context.Response.BodyWriter);
layout.Render(renderContext);
return;
}
//Else only render the component
component.WriteToResponseBody(context);
}
}
+25
View File
@@ -0,0 +1,25 @@
using Htmx.ApiDemo.Data;
namespace Htmx.ApiDemo;
public static partial class RouteMap
{
public static void MapHtmxRoutes(this WebApplication app)
{
MapGetIndex(app);
MapGetGreet(app);
GetRegister(app);
PostRegister(app);
PostLogout(app);
GetLogin(app);
PostLogin(app);
GetUiDemo(app);
}
private static void PostLogout(WebApplication app)
=> app.MapPost("/logout", async (HttpContext context, AppAuthService authService) =>
{
await authService.SignOutAsync();
return Results.Redirect("/login");
});
}
+6 -5
View File
@@ -10,11 +10,15 @@
</PropertyGroup>
<PropertyGroup>
<EnableRequestDelegateGenerator>true</EnableRequestDelegateGenerator>
<PublishAot>true</PublishAot>
<InterceptorsPreviewNamespaces>$(InterceptorsPreviewNamespaces);Immediate.Apis.Generators</InterceptorsPreviewNamespaces>
</PropertyGroup>
<ItemGroup>
<TrimmerRootAssembly Include="MongoDB.Bson" />
<TrimmerRootAssembly Include="Htmx.ApiDemo" />
<TrimmerRootAssembly Include="Microsoft.Extensions.Primitives" />
</ItemGroup>
<ItemGroup>
<CompilerVisibleProperty Include="RootNamespace" />
<CompilerVisibleProperty Include="MSBuildProjectDirectory" />
@@ -22,14 +26,11 @@
<ItemGroup>
<Content Update="wwwroot\**" CopyToPublishDirectory="Always" />
<Content Remove="wwwroot\css\output.css" />
<AdditionalFiles Include="**/*.htmx" />
<None Remove="**/*.htmx" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Immediate.Apis" Version="4.2.0" />
<PackageReference Include="Immediate.Handlers" Version="3.5.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.7" />
<PackageReference Include="MongoDB.Driver" Version="3.4.0" />
<ProjectReference Include="..\Htmx.SourceGenerator\Htmx.SourceGenerator.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
-11
View File
@@ -1,11 +0,0 @@
@Htmx.ApiDemo_HostAddress = http://localhost:5120
GET {{Htmx.ApiDemo_HostAddress}}/todos/
Accept: application/json
###
GET {{Htmx.ApiDemo_HostAddress}}/todos/1
Accept: application/json
###
+1 -1
View File
@@ -19,6 +19,6 @@ Global
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {B66FEAA2-59A2-4489-9AEB-ED875EEE5D3E}
SolutionGuid = {61C4ACCC-FDDE-4F92-B2A9-A5744496122B}
EndGlobalSection
EndGlobal
-44
View File
@@ -1,44 +0,0 @@
using Microsoft.AspNetCore.Antiforgery;
namespace Htmx.ApiDemo;
/// <summary>
/// Renders a full page or just the body component depending on whether
/// the request was made by HTMX (HX-Request header present).
///
/// Full request → wraps body in MainLayout (complete HTML page)
/// HTMX request → renders body only + sets HX-Title so the browser
/// tab title still updates
/// </summary>
public static class HtmxPageExtensions
{
public static void WriteHtmxPage(
this HttpContext ctx,
IHtmxComponent body,
string title = "App",
string appName = "HtmxApp",
string pageTitle = "")
{
if (ctx.Request.Headers.ContainsKey("HX-Request"))
{
// Partial swap: tell HTMX to update the browser <title> tag
ctx.Response.Headers["HX-Title"] = title;
ctx.WriteHtmxBody(body);
}
else
{
// Resolve display name: prefer DisplayName claim, fall back to email/name
string? userName = ctx.User.Identity?.IsAuthenticated == true
? (ctx.User.FindFirst("DisplayName")?.Value
?? ctx.User.FindFirst(System.Security.Claims.ClaimTypes.Name)?.Value)
: null;
// Resolve antiforgery token for the logout form in the layout
var antiforgery = ctx.RequestServices.GetRequiredService<IAntiforgery>();
var afTokens = antiforgery.GetAndStoreTokens(ctx);
// Full page load: wrap in the shell layout
ctx.WriteHtmxBody(new Templates.MainLayout(body, title, appName, pageTitle, userName, afTokens.RequestToken));
}
}
}
+13 -26
View File
@@ -1,17 +1,26 @@
using Htmx.ApiDemo;
using Htmx.ApiDemo.Data;
using Immediate.Apis;
using Immediate.Apis.Shared;
using Htmx.ApiDemo.Templates;
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using MongoDB.Bson;
using MongoDB.Bson.Serialization;
using MongoDB.Bson.Serialization.Serializers;
using MongoDB.Driver;
// ── Explicit serializer registrations — force AOT to preserve constructors ─
BsonSerializer.RegisterSerializer(new ObjectIdSerializer());
BsonSerializer.RegisterSerializer(new StringSerializer());
BsonSerializer.RegisterSerializer(new DateTimeSerializer());
BsonSerializer.RegisterSerializer(new BooleanSerializer());
BsonSerializer.RegisterSerializer(new NullableSerializer<DateTime>(new DateTimeSerializer()));
// ── Explicit BsonClassMap — no AutoMap() reflection, fully AOT-safe ───────
BsonClassMap.RegisterClassMap<AppUser>(cm =>
{
cm.AutoMap();
cm.MapIdProperty(u => u.Id).SetSerializer(new ObjectIdSerializer());
cm.MapProperty(u => u.Email).SetElementName("email");
cm.MapProperty(u => u.NormalizedEmail).SetElementName("normalizedEmail");
@@ -52,13 +61,10 @@ builder.Services
});
builder.Services.AddScoped<IPasswordHasher<AppUser>, PasswordHasher<AppUser>>();
builder.Services.AddScoped<AuthService>();
builder.Services.AddScoped<AppAuthService>();
// ── App services ──────────────────────────────────────────────────────────
builder.Services.AddHttpContextAccessor();
builder.Services
.AddHtmxApiDemoBehaviors()
.AddHtmxApiDemoHandlers();
builder.Services.AddOpenApi();
builder.Services.AddAuthorization();
@@ -76,25 +82,6 @@ app.UseAuthentication();
app.UseAuthorization();
app.UseAntiforgery();
// ── Guard: redirect unauthenticated users to /login ───────────────────────
app.Use(async (context, next) =>
{
var path = context.Request.Path.Value ?? "";
bool isPublic = path.StartsWith("/login", StringComparison.OrdinalIgnoreCase)
|| path.StartsWith("/register", StringComparison.OrdinalIgnoreCase)
|| path.StartsWith("/logout", StringComparison.OrdinalIgnoreCase)
|| path.StartsWith("/css/", StringComparison.OrdinalIgnoreCase)
|| path.StartsWith("/js/", StringComparison.OrdinalIgnoreCase);
if (!isPublic && context.User.Identity?.IsAuthenticated != true)
{
context.Response.Redirect("/login");
return;
}
await next();
});
app.MapHtmxApiDemoEndpoints();
app.MapHtmxRoutes();
app.Run();
+5 -3
View File
@@ -1,6 +1,8 @@
<div id="Greeting-$$GreetingId$$" class="greeting">
<h1>Hello, $$User$$!</h1>
<p>Welcome to high-performance htmx rendering.</p>
<button hx-get="/greet/$$User$$/$$Count$$/$$GreetingId$$" hx-target="#Greeting-$$GreetingId$$" hx-swap="outerHTML">Click to increase count $$Count$$</button>
<p class="pb-2">Welcome to high-performance htmx rendering.</p>
$$Separator$$
<div class="m-3">
$$CountButton$$
</div>
</div>
+22 -30
View File
@@ -1,38 +1,30 @@
using Immediate.Apis.Shared;
using Immediate.Handlers.Shared;
using Htmx.ApiDemo.Templates.Components;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace Htmx.ApiDemo.Templates;
public sealed class Greeting : GreetingBase
{
private byte[] _userData = [];
private byte[] _countData = [];
private byte[] _greetingIdData = [];
public required string Username { init => _userData = value.ToUtf8Bytes(); }
public required int Count { init => _countData = $"{value}".ToUtf8Bytes(); }
public required Guid GreetingId { init => _greetingIdData = $"{value}".ToUtf8Bytes(); }
public required int Count { get; init; }
public required string Username { get; init; }
public required Guid GreetingId { get; init; }
protected override void RenderCount(HtmxRenderContext context) => context.Writer.WriteUtf8(_countData);
protected override void RenderGreetingId(HtmxRenderContext context) => context.Writer.WriteUtf8(_greetingIdData);
protected override void RenderUser(HtmxRenderContext context) => context.Writer.WriteUtf8(_userData);
}
[Handler]
[MapGet("/greet/{username}/{count?}/{id?}")]
public static partial class GetGreetingHandler
{
public record Query(string Username, int? Count, Guid? Id);
private static ValueTask HandleAsync(
Query query,
IHttpContextAccessor httpContextAccessor,
CancellationToken token)
protected override void RenderCountButton(HtmxRenderContext context)
{
var context = httpContextAccessor.HttpContext
?? throw new InvalidOperationException("HttpContext is not available.");
var template = new Greeting { Username = query.Username, Count = query.Count + 1 ?? 0, GreetingId = query.Id ?? Guid.NewGuid() };
context.WriteHtmxBody(template);
return ValueTask.CompletedTask;
var button = new Button(
$"Click to increase count {Count}",
"outline",
hxAttrs: $"hx-get=\"/greet/{Username}/{Count}/{GreetingId}\"" +
$" hx-target=\"#Greeting-{GreetingId}\" hx-swap=\"outerHTML\""
);
button.Render(context);
}
}
protected override void RenderUser(HtmxRenderContext context)
=> context.Writer.WriteUtf8(Username.ToUtf8Bytes());
protected override void RenderGreetingId(HtmxRenderContext context)
=> context.Writer.WriteUtf8(GreetingId.ToString().ToUtf8Bytes());
protected override void RenderSeparator(HtmxRenderContext context)
=> new Separator().Render(context);
}
@@ -0,0 +1,53 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Builder;
using Htmx.ApiDemo.Templates;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Net.Http.Headers;
namespace Htmx.ApiDemo;
public static partial class RouteMap
{
public static void MapGetIndex(WebApplication app)
=> app.MapGet("/", (IHttpContextAccessor contextAccessor) =>
{
var context = contextAccessor.HttpContext
?? throw new InvalidOperationException("HttpContext is not available.");
var isAuthenticate = context.User.Identity?.IsAuthenticated ?? false;
var JohnDoe = "John Doe";
var claimedName = context.User.Claims.FirstOrDefault(c => c.Type == "DisplayName")?.Value ?? JohnDoe;
string name =
isAuthenticate && claimedName is not null ? claimedName : JohnDoe;
var greet = new Greeting { Username = name, Count = 0, GreetingId = Guid.NewGuid() };
greet.HtmxAwareWriteToBody(
context: context,
title: "Home",
appName: "HtmxApp",
pageTitle: "Home"
);
});
private static void MapGetGreet(WebApplication app)
=> app.MapGet("/greet/{name}/{count}/{greetid}",
(
[FromRoute] string name,
[FromRoute] int count,
[FromRoute] string greetid,
[FromServices] IHttpContextAccessor contextAccessor
) =>
{
var context = contextAccessor.HttpContext
?? throw new InvalidOperationException("HttpContext is not available.");
var id = Guid.TryParse(greetid, out var parsedId) ? parsedId : Guid.NewGuid();
var greet = new Greeting { Username = name, Count = ++count, GreetingId = id };
greet.HtmxAwareWriteToBody(
context: context,
title: "Greet",
appName: "HtmxApp",
pageTitle: "Greet"
);
});
}
+2 -64
View File
@@ -1,7 +1,6 @@
using Htmx.ApiDemo.Data;
using Immediate.Apis.Shared;
using Immediate.Handlers.Shared;
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace Htmx.ApiDemo.Templates;
@@ -24,65 +23,4 @@ public sealed class Login : LoginBase
protected override void RenderErrorMessage(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_errorData);
protected override void RenderAntiforgeryToken(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_afTokenData);
}
[Handler]
[MapGet("/login")]
public static partial class GetLoginHandler
{
public record Query;
private static ValueTask HandleAsync(
Query _,
IHttpContextAccessor httpContextAccessor,
IAntiforgery antiforgery,
CancellationToken token)
{
var ctx = httpContextAccessor.HttpContext
?? throw new InvalidOperationException("HttpContext is not available.");
if (ctx.User.Identity?.IsAuthenticated == true)
{
ctx.Response.Redirect("/");
return ValueTask.CompletedTask;
}
var afTokens = antiforgery.GetAndStoreTokens(ctx);
ctx.WriteHtmxPage(new Login(afToken: afTokens.RequestToken), title: "Sign in", appName: "HtmxApp", pageTitle: "Sign in");
return ValueTask.CompletedTask;
}
}
[Handler]
[MapPost("/login")]
public static partial class PostLoginHandler
{
public record Command(
[property: FromForm] string Email,
[property: FromForm] string Password
);
private static async ValueTask HandleAsync(
[AsParameters] Command command,
IHttpContextAccessor httpContextAccessor,
IAntiforgery antiforgery,
AuthService authService,
CancellationToken token)
{
var ctx = httpContextAccessor.HttpContext
?? throw new InvalidOperationException("HttpContext is not available.");
var (success, error) = await authService.LoginAsync(command.Email, command.Password);
if (success)
{
ctx.Response.Redirect("/");
return;
}
var afTokens = antiforgery.GetAndStoreTokens(ctx);
ctx.WriteHtmxPage(new Login(error, afToken: afTokens.RequestToken), title: "Sign in", appName: "HtmxApp", pageTitle: "Sign in");
}
}
}
@@ -0,0 +1,65 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Antiforgery;
using Htmx.ApiDemo.Templates;
using Htmx.ApiDemo.Data;
using Microsoft.AspNetCore.Mvc;
namespace Htmx.ApiDemo;
public static partial class RouteMap
{
private static void GetLogin(WebApplication app)
=> app.MapGet("/login", (IHttpContextAccessor contextAccessor, IAntiforgery antiforgery) =>
{
var context = contextAccessor.HttpContext
?? throw new InvalidOperationException("HttpContext is not available.");
if (context.User.Identity?.IsAuthenticated == true)
{
context.Response.Redirect("/");
return;
}
var afToken = antiforgery.GetAndStoreTokens(context).RequestToken;
var loginComponent = new Login(afToken: afToken);
loginComponent.HtmxAwareWriteToBody(
context: context,
title: "Login",
appName: "HtmxApp",
pageTitle: "Welcome back"
);
});
private static void PostLogin(WebApplication app)
=> app.MapPost("/login", async ValueTask
(
[FromForm] string email,
[FromForm] string password,
[FromServices] IHttpContextAccessor httpContextAccessor,
[FromServices] IAntiforgery antiforgery,
[FromServices] AppAuthService authService
) =>
{
var context = httpContextAccessor.HttpContext
?? throw new InvalidOperationException("HttpContext is not available.");
var afToken = antiforgery.GetAndStoreTokens(context).RequestToken;
var (success, error) = await authService.LoginAsync(email, password);
if (success)
{
context.Response.Redirect("/");
return;
}
var loginComponent = new Login(error, afToken: afToken);
loginComponent.HtmxAwareWriteToBody(
context: context,
title: "Login",
appName: "HtmxApp",
pageTitle: "Welcome back"
);
});
}
-28
View File
@@ -1,28 +0,0 @@
using Htmx.ApiDemo.Data;
using Immediate.Apis.Shared;
using Immediate.Handlers.Shared;
using Microsoft.AspNetCore.Mvc;
namespace Htmx.ApiDemo.Templates;
[Handler]
[MapPost("/logout")]
public static partial class PostLogoutHandler
{
// Empty command — [AsParameters] ensures form content-type is accepted
// and antiforgery token in the form is validated by the middleware.
public record Command;
private static async ValueTask HandleAsync(
[AsParameters] Command _,
AuthService authService,
IHttpContextAccessor httpContextAccessor,
CancellationToken token)
{
await authService.SignOutAsync();
var ctx = httpContextAccessor.HttpContext
?? throw new InvalidOperationException("HttpContext is not available.");
ctx.Response.Redirect("/login");
}
}
+1 -1
View File
@@ -55,7 +55,7 @@
<!-- Sidebar footer -->
<div class="border-t border-border px-5 py-3 text-xs text-muted-foreground">
© 2026 $$AppName$$
© 2026 $$AppName$$ - Enciphered
</div>
</aside>
<!-- ── /Sidebar ── -->
+3 -26
View File
@@ -1,11 +1,10 @@
using Htmx.ApiDemo;
using Htmx.ApiDemo.Templates.Components;
using Immediate.Apis.Shared;
using Immediate.Handlers.Shared;
using Microsoft.AspNetCore.Http;
namespace Htmx.ApiDemo.Templates;
namespace Htmx.ApiDemo;
public sealed class MainLayout : MainLayoutBase
public sealed class MainLayout : Templates.MainLayoutBase
{
private byte[] _titleData = [];
private byte[] _appNameData = [];
@@ -66,26 +65,4 @@ public sealed class MainLayout : MainLayoutBase
protected override void RenderAppName(HtmxRenderContext context) => context.Writer.WriteUtf8(_appNameData);
protected override void RenderPageTitle(HtmxRenderContext context) => context.Writer.WriteUtf8(_pageTitleData);
protected override void RenderUserSection(HtmxRenderContext context) => context.Writer.WriteUtf8(_userSectionData);
}
[Handler]
[MapGet("/")]
public static partial class GetIndexHandler
{
public record Command;
private static ValueTask HandleAsync(
Command command,
IHttpContextAccessor httpContextAccessor,
CancellationToken token)
{
var context = httpContextAccessor.HttpContext
?? throw new InvalidOperationException("HttpContext is not available.");
var greet = new Greeting { Username = "Enciphered", Count = 0, GreetingId = Guid.NewGuid() };
context.WriteHtmxPage(greet, title: "Home", appName: "HtmxApp", pageTitle: "Home");
return ValueTask.CompletedTask;
}
}
+2 -75
View File
@@ -1,7 +1,6 @@
using Htmx.ApiDemo.Data;
using Immediate.Apis.Shared;
using Immediate.Handlers.Shared;
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace Htmx.ApiDemo.Templates;
@@ -24,76 +23,4 @@ public sealed class Register : RegisterBase
protected override void RenderErrorMessage(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_errorData);
protected override void RenderAntiforgeryToken(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_afTokenData);
}
[Handler]
[MapGet("/register")]
public static partial class GetRegisterHandler
{
public record Query;
private static ValueTask HandleAsync(
Query _,
IHttpContextAccessor httpContextAccessor,
IAntiforgery antiforgery,
CancellationToken token)
{
var ctx = httpContextAccessor.HttpContext
?? throw new InvalidOperationException("HttpContext is not available.");
if (ctx.User.Identity?.IsAuthenticated == true)
{
ctx.Response.Redirect("/");
return ValueTask.CompletedTask;
}
var afTokens = antiforgery.GetAndStoreTokens(ctx);
ctx.WriteHtmxPage(new Register(afToken: afTokens.RequestToken), title: "Register", appName: "HtmxApp", pageTitle: "Create account");
return ValueTask.CompletedTask;
}
}
[Handler]
[MapPost("/register")]
public static partial class PostRegisterHandler
{
public record Command(
[property: FromForm] string Email,
[property: FromForm] string Password,
[property: FromForm] string ConfirmPassword,
[property: FromForm] string? DisplayName
);
private static async ValueTask HandleAsync(
[AsParameters] Command command,
IHttpContextAccessor httpContextAccessor,
IAntiforgery antiforgery,
AuthService authService,
CancellationToken token)
{
var ctx = httpContextAccessor.HttpContext
?? throw new InvalidOperationException("HttpContext is not available.");
if (command.Password != command.ConfirmPassword)
{
var afTokens1 = antiforgery.GetAndStoreTokens(ctx);
ctx.WriteHtmxPage(new Register("Passwords do not match.", afToken: afTokens1.RequestToken),
title: "Register", appName: "HtmxApp", pageTitle: "Create account");
return;
}
var (success, error) = await authService.RegisterAsync(command.Email, command.Password, command.DisplayName);
if (success)
{
ctx.Response.Redirect("/");
return;
}
var afTokens2 = antiforgery.GetAndStoreTokens(ctx);
ctx.WriteHtmxPage(new Register(error, afToken: afTokens2.RequestToken),
title: "Register", appName: "HtmxApp", pageTitle: "Create account");
}
}
}
@@ -0,0 +1,72 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Builder;
using Htmx.ApiDemo.Templates;
using Microsoft.AspNetCore.Antiforgery;
using Htmx.ApiDemo.Data;
using Microsoft.AspNetCore.Mvc;
namespace Htmx.ApiDemo;
public static partial class RouteMap
{
private static void GetRegister(WebApplication app)
=> app.MapGet("/register", (IHttpContextAccessor contextAccessor, IAntiforgery antiforgery) =>
{
var context = contextAccessor.HttpContext
?? throw new InvalidOperationException("HttpContext is not available.");
if (context.User.Identity?.IsAuthenticated == true)
{
context.Response.Redirect("/");
return;
}
var afTokens = antiforgery.GetAndStoreTokens(context);
var registerComponent = new Register(afToken: afTokens.RequestToken);
registerComponent.HtmxAwareWriteToBody(
context: context,
title: "Register",
appName: "HtmxApp",
pageTitle: "Create account"
);
});
private static void PostRegister(WebApplication app)
=> app.MapPost("/register", async ValueTask
(
[FromForm] string email,
[FromForm] string password,
[FromForm] string confirmPassword,
[FromForm] string? displayName,
[FromServices] IHttpContextAccessor httpContextAccessor,
[FromServices] IAntiforgery antiforgery,
[FromServices] AppAuthService authService
) =>
{
var context = httpContextAccessor.HttpContext
?? throw new InvalidOperationException("HttpContext is not available.");
var afToken = antiforgery.GetAndStoreTokens(context).RequestToken;
if (password != confirmPassword)
{
var errorComponent = new Register("Passwords do not match.", afToken: afToken);
errorComponent.HtmxAwareWriteToBody(
context: context,
title: "Register",
appName: "HtmxApp",
pageTitle: "Create account"
);
}
var (success, error) = await authService.RegisterAsync(email, password, displayName);
if (success)
{
context.Response.Redirect("/");
return;
}
var registerComponent = new Register(error, afToken: afToken);
});
}
+2 -25
View File
@@ -1,6 +1,5 @@
using Htmx.ApiDemo.Templates.Components;
using Immediate.Apis.Shared;
using Immediate.Handlers.Shared;
using Microsoft.AspNetCore.Http;
namespace Htmx.ApiDemo.Templates;
@@ -363,26 +362,4 @@ public sealed class UiDemo : UiDemoBase
protected override void RenderDropdownDemo(HtmxRenderContext ctx) => DropdownDemo.Render(ctx);
protected override void RenderToastViewportDemo(HtmxRenderContext ctx) => ToastViewportDemo.Render(ctx);
}
[Handler]
[MapGet("/ui-demo")]
public static partial class GetUiDemoHandler
{
public record Query;
private static ValueTask HandleAsync(
Query query,
IHttpContextAccessor httpContextAccessor,
CancellationToken token)
{
var context = httpContextAccessor.HttpContext
?? throw new InvalidOperationException("HttpContext is not available.");
var page = new UiDemo();
context.WriteHtmxPage(page, title: "UI Demo", appName: "HtmxApp", pageTitle: "UI Components");
return ValueTask.CompletedTask;
}
}
}
@@ -0,0 +1,29 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Builder;
using Htmx.ApiDemo.Templates;
namespace Htmx.ApiDemo;
public static partial class RouteMap
{
private static void GetUiDemo(WebApplication app)
=> app.MapGet("/ui-demo", (IHttpContextAccessor contextAccessor) =>
{
var context = contextAccessor.HttpContext
?? throw new InvalidOperationException("HttpContext is not available.");
if (context.User.Identity?.IsAuthenticated == false)
{
context.Response.Redirect("/login");
return;
}
var uiDemoComponent = new UiDemo();
uiDemoComponent.HtmxAwareWriteToBody(
context: context,
title: "UI Demo",
appName: "HtmxApp",
pageTitle: "Htmx UI Demo"
);
});
}
+5 -1
View File
@@ -4,5 +4,9 @@
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
},
"ConnectionStrings": {
"DefaultConnection": "mongodb://localhost:27017/"
},
"MongoDbName": "HtmxDemoDb"
}
File diff suppressed because one or more lines are too long
+5 -3
View File
@@ -25,6 +25,8 @@ namespace Htmx.SourceGenerator
using System;
using System.Buffers;
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
namespace {opt.RootNamespace};
@@ -67,11 +69,11 @@ public static class HtmxGeneratedExtensions
writer.Advance(data.Length);
}}
public static void WriteHtmxBody(this HttpContext context, IHtmxComponent component)
public static void WriteToResponseBody(this IHtmxComponent component, HttpContext context)
{{
context.Response.ContentType = ""text/html; charset=utf-8"";
var writerContext = new HtmxRenderContext(context.Response.BodyWriter);
component.Render(writerContext);
var renderContext = new HtmxRenderContext(context.Response.BodyWriter);
component.Render(renderContext);
}}
}}";
spc.AddSource("HtmxInfrastructure.g.cs", SourceText.From(infrastructureSource, Encoding.UTF8));
+97
View File
@@ -0,0 +1,97 @@
# AOT Testing Guide
This directory contains scripts for building and testing the Htmx.ApiDemo application with Ahead-of-Time (AOT) compilation enabled. AOT compilation helps identify potential trimming issues that may occur at runtime.
## Directory Structure
```
Testing/
└── AOT/
├── build-aot.ps1 # Windows PowerShell script
├── build-aot.sh # Linux/POP_OS bash script
├── Publish/ # Output directory (created during build)
└── README.md # This file
```
## Prerequisites
- .NET SDK (version 10.0 or later)
- For Windows: PowerShell 5.1 or later
- For Linux/POP_OS: Bash shell
## Usage
### Windows (PowerShell)
**Build and Run:**
```powershell
.\build-aot.ps1
```
**Build Only:**
```powershell
.\build-aot.ps1 -BuildOnly
```
**Run Only (if already built):**
```powershell
.\build-aot.ps1 -RunOnly
```
### Linux/POP_OS (Bash)
Make the script executable first:
```bash
chmod +x build-aot.sh
```
**Build and Run:**
```bash
./build-aot.sh
```
**Build Only:**
```bash
./build-aot.sh --build-only
```
**Run Only (if already built):**
```bash
./build-aot.sh --run-only
```
## What These Scripts Do
1. **Clean**: Removes any previous publish directory
2. **Build**: Publishes the application with the following AOT settings:
- `PublishAot=true` - Enables AOT compilation
- `TrimMode=link` - Uses link-time trimming
- `PublishTrimmed=true` - Enables trimming
- `SelfContained=true` - Creates a self-contained executable
- Debug symbols are disabled for optimized output
3. **Run**: Launches the compiled application to test for any trimming-related issues
## Output
The compiled application and all dependencies are published to the `Testing/AOT/Publish` directory.
## Troubleshooting
### Build Failures
If the AOT build fails, check the error messages for:
- **Trimming issues**: Indicates code that cannot be safely trimmed
- **Reflection warnings**: APIs that use reflection may not work properly
- **Missing dependencies**: Required libraries that aren't properly configured
### Common Issues
1. **"Executable not found"**: The build may have failed silently. Check the build output for errors.
2. **Runtime crashes**: Trimming may have removed necessary code. Consider adding trimming configuration in your project file or using `[DynamicallyAccessedMembers]` attributes.
## Related Documentation
- [AOT Deployment](https://learn.microsoft.com/en-us/dotnet/core/deploying/native-aot/)
- [Trimming .NET Applications](https://learn.microsoft.com/en-us/dotnet/core/deploying/trimming/trim-self-contained)
- [Reflections and Trimming](https://learn.microsoft.com/en-us/dotnet/core/deploying/trimming/trim-self-contained#reflections)
+122
View File
@@ -0,0 +1,122 @@
# AOT Build and Test Script for Windows PowerShell
# This script builds the Htmx.ApiDemo application with AOT compilation enabled
# and runs the application to check for trimming issues
param(
[switch]$RunOnly,
[switch]$BuildOnly
)
# Set error handling
$ErrorActionPreference = "Stop"
# Define paths
$ProjectRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
$ApiDemoProject = Join-Path $ProjectRoot "Htmx.ApiDemo"
$PublishPath = Join-Path $PSScriptRoot "Publish"
Write-Host "========================================" -ForegroundColor Cyan
Write-Host "AOT Build and Test Script" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Write-Host ""
if (-not $RunOnly) {
Write-Host "Starting AOT Build Process..." -ForegroundColor Yellow
Write-Host "Project Path: $ApiDemoProject" -ForegroundColor Gray
Write-Host "Publish Path: $PublishPath" -ForegroundColor Gray
Write-Host ""
# Kill any running instance of the app before cleaning (it locks the publish folder)
$AppName = "Htmx.ApiDemo"
$Running = Get-Process -Name $AppName -ErrorAction SilentlyContinue
if ($Running) {
Write-Host "Stopping running instance of $AppName..." -ForegroundColor Yellow
$Running | Stop-Process -Force
Start-Sleep -Milliseconds 500
}
# Clean previous publish folder if it exists
if (Test-Path $PublishPath) {
Write-Host "Cleaning previous publish folder..." -ForegroundColor Yellow
Remove-Item -Path $PublishPath -Recurse -Force
}
# Create publish folder
New-Item -Path $PublishPath -ItemType Directory -Force | Out-Null
try {
# Run dotnet publish with AOT enabled
# PublishAot=true is already set in the .csproj
# A Runtime Identifier (RID) is required for AOT compilation
Write-Host "Publishing with AOT compilation enabled (win-x64)..." -ForegroundColor Yellow
Push-Location $ApiDemoProject
dotnet publish -c Release -r win-x64 -o $PublishPath `
-p:DebugSymbols=false `
-p:DebugType=none
if ($LASTEXITCODE -ne 0) {
Write-Host "Build failed!" -ForegroundColor Red
Pop-Location
exit 1
}
Pop-Location
Write-Host "AOT build completed successfully!" -ForegroundColor Green
Write-Host ""
# Copy appsettings.Development.json over appsettings.json in the publish folder
# so the AOT executable has the correct connection strings for local testing
$DevSettings = Join-Path $ApiDemoProject "appsettings.Development.json"
$PublishedSettings = Join-Path $PublishPath "appsettings.json"
if (Test-Path $DevSettings) {
Write-Host "Copying appsettings.Development.json -> appsettings.json in publish folder..." -ForegroundColor Yellow
Copy-Item -Path $DevSettings -Destination $PublishedSettings -Force
Write-Host "Done." -ForegroundColor Green
Write-Host ""
} else {
Write-Host "Warning: appsettings.Development.json not found, skipping copy." -ForegroundColor DarkYellow
}
}
catch {
Write-Host "Error during build: $_" -ForegroundColor Red
Pop-Location
exit 1
}
}
if (-not $BuildOnly) {
Write-Host "Starting Application..." -ForegroundColor Yellow
Write-Host ""
# Find the executable
$Executable = Get-ChildItem -Path $PublishPath -Filter "*.exe" -Recurse | Select-Object -First 1
if (-not $Executable) {
Write-Host "Executable not found in publish directory!" -ForegroundColor Red
exit 1
}
Write-Host "Running: $($Executable.FullName)" -ForegroundColor Yellow
Write-Host "Press Ctrl+C to stop the application" -ForegroundColor Gray
Write-Host ""
try {
# Use Start-Process with WorkingDirectory so appsettings.json is found,
# and the shell CWD is never changed (safe against Ctrl+C)
$proc = Start-Process -FilePath $Executable.FullName `
-WorkingDirectory $PublishPath `
-NoNewWindow `
-PassThru
$proc.WaitForExit()
}
catch {
Write-Host "Error running application: $_" -ForegroundColor Red
exit 1
}
}
Write-Host ""
Write-Host "========================================" -ForegroundColor Cyan
Write-Host "AOT Test Complete" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
+114
View File
@@ -0,0 +1,114 @@
#!/bin/bash
# AOT Build and Test Script for POP_OS/Linux
# This script builds the Htmx.ApiDemo application with AOT compilation enabled
# and runs the application to check for trimming issues
# Set error handling
set -e
# Initialize flags
RUN_ONLY=false
BUILD_ONLY=false
# Parse command line arguments
while [[ $# -gt 0 ]]; do
case $1 in
--run-only)
RUN_ONLY=true
shift
;;
--build-only)
BUILD_ONLY=true
shift
;;
*)
echo "Unknown option: $1"
echo "Usage: $0 [--run-only] [--build-only]"
exit 1
;;
esac
done
# Define paths
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
API_DEMO_PROJECT="$PROJECT_ROOT/Htmx.ApiDemo"
PUBLISH_PATH="$SCRIPT_DIR/Publish"
echo "========================================"
echo "AOT Build and Test Script"
echo "========================================"
echo ""
if [ "$RUN_ONLY" = false ]; then
echo "Starting AOT Build Process..."
echo "Project Path: $API_DEMO_PROJECT"
echo "Publish Path: $PUBLISH_PATH"
echo ""
# Clean previous publish folder if it exists
if [ -d "$PUBLISH_PATH" ]; then
echo "Cleaning previous publish folder..."
rm -rf "$PUBLISH_PATH"
fi
# Create publish folder
mkdir -p "$PUBLISH_PATH"
# Navigate to project directory
cd "$API_DEMO_PROJECT" || exit 1
# Run dotnet publish with AOT enabled
# PublishAot=true is already set in the .csproj
# A Runtime Identifier (RID) is required for AOT compilation
echo "Publishing with AOT compilation enabled (linux-x64)..."
if ! dotnet publish -c Release -r linux-x64 -o "$PUBLISH_PATH" \
-p:DebugSymbols=false \
-p:DebugType=none; then
echo "Build failed!"
exit 1
fi
echo "AOT build completed successfully!"
echo ""
# Copy appsettings.Development.json over appsettings.json in the publish folder
# so the AOT executable has the correct connection strings for local testing
DEV_SETTINGS="$API_DEMO_PROJECT/appsettings.Development.json"
PUBLISHED_SETTINGS="$PUBLISH_PATH/appsettings.json"
if [ -f "$DEV_SETTINGS" ]; then
echo "Copying appsettings.Development.json -> appsettings.json in publish folder..."
cp -f "$DEV_SETTINGS" "$PUBLISHED_SETTINGS"
echo "Done."
echo ""
else
echo "Warning: appsettings.Development.json not found, skipping copy."
fi
fi
if [ "$BUILD_ONLY" = false ]; then
echo "Starting Application..."
echo ""
# Find the executable
EXECUTABLE=$(find "$PUBLISH_PATH" -type f -perm /u+x ! -name "*.so" ! -name "*.a" ! -name "*.o" | head -n 1)
if [ -z "$EXECUTABLE" ]; then
echo "Executable not found in publish directory!"
exit 1
fi
echo "Running: $EXECUTABLE"
echo "Press Ctrl+C to stop the application"
echo ""
# cd into publish dir so appsettings.json is found relative to the executable
cd "$PUBLISH_PATH" || exit 1
"$EXECUTABLE"
fi
echo ""
echo "========================================"
echo "AOT Test Complete"
echo "========================================"
+193
View File
@@ -0,0 +1,193 @@
using Microsoft.Playwright;
const string defaultSelector = "button:has-text('Click to')";
var targetUrl = PromptForTargetUrl();
var options = StressOptions.Parse(args, defaultSelector, targetUrl);
Console.WriteLine($"URL: {options.TargetUrl}");
Console.WriteLine($"Instances: {options.InstanceCount}");
Console.WriteLine($"Interval: {options.IntervalMs}ms");
Console.WriteLine($"Selector: {options.ButtonSelector}");
Console.WriteLine($"Headless: {options.Headless}");
Console.WriteLine("Press Ctrl+C to stop.");
using var cts = new CancellationTokenSource();
Console.CancelKeyPress += (_, eventArgs) =>
{
eventArgs.Cancel = true;
cts.Cancel();
};
var workers = Enumerable.Range(1, options.InstanceCount)
.Select(instanceId => RunWorkerAsync(instanceId, options, cts.Token))
.ToArray();
await Task.WhenAll(workers);
static string PromptForTargetUrl()
{
while (true)
{
Console.Write("Enter target URL: ");
var input = Console.ReadLine();
if (!string.IsNullOrWhiteSpace(input)
&& Uri.TryCreate(input, UriKind.Absolute, out var uri)
&& (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
{
return uri.ToString();
}
Console.WriteLine("Invalid URL. Please enter a full http/https URL.");
}
}
static async Task RunWorkerAsync(int instanceId, StressOptions options, CancellationToken cancellationToken)
{
try
{
using var playwright = await Playwright.CreateAsync();
await using var browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
{
Headless = options.Headless
});
var page = await browser.NewPageAsync();
await page.GotoAsync(options.TargetUrl, new PageGotoOptions
{
WaitUntil = WaitUntilState.DOMContentLoaded,
Timeout = 30000
});
Console.WriteLine($"[{instanceId}] connected");
var clickCount = 0;
while (!cancellationToken.IsCancellationRequested)
{
try
{
// HTMX can replace the button after each request, so resolve it fresh every loop.
var button = page.Locator(options.ButtonSelector).First;
await button.WaitForAsync(new LocatorWaitForOptions
{
State = WaitForSelectorState.Visible,
Timeout = 5000
});
await button.ClickAsync(new LocatorClickOptions
{
Timeout = 5000,
Force = true
});
clickCount += 1;
if (clickCount % 25 == 0)
{
Console.WriteLine($"[{instanceId}] clicks sent: {clickCount}");
}
}
catch (TimeoutException)
{
Console.WriteLine($"[{instanceId}] click timeout");
}
catch (PlaywrightException ex)
{
Console.WriteLine($"[{instanceId}] click error: {ex.Message}");
}
try
{
await Task.Delay(options.IntervalMs, cancellationToken);
}
catch (OperationCanceledException)
{
break;
}
}
await page.CloseAsync();
Console.WriteLine($"[{instanceId}] stopped");
}
catch (OperationCanceledException)
{
Console.WriteLine($"[{instanceId}] canceled");
}
catch (Exception ex)
{
Console.WriteLine($"[{instanceId}] fatal: {ex.Message}");
if (ex.Message.Contains("Executable doesn't exist", StringComparison.OrdinalIgnoreCase))
{
Console.WriteLine("Install browser binaries first:");
Console.WriteLine("./bin/Debug/net10.0/.playwright/node/linux-x64/node ./bin/Debug/net10.0/.playwright/package/cli.js install chromium");
}
}
}
internal sealed record StressOptions(
string TargetUrl,
int InstanceCount,
int IntervalMs,
string ButtonSelector,
bool Headless)
{
public static StressOptions Parse(string[] args, string defaultSelector, string targetUrl)
{
var instanceCount = ParsePositiveInt(Environment.GetEnvironmentVariable("INSTANCE_COUNT"), 20);
var intervalMs = ParsePositiveInt(Environment.GetEnvironmentVariable("CLICK_INTERVAL_MS"), 200);
var buttonSelector = Environment.GetEnvironmentVariable("BUTTON_SELECTOR") ?? defaultSelector;
var headless = ParseBool(Environment.GetEnvironmentVariable("HEADLESS"), true);
foreach (var arg in args)
{
var split = arg.Split('=', 2, StringSplitOptions.TrimEntries);
if (split.Length != 2)
{
continue;
}
var key = split[0].TrimStart('-', '/').ToLowerInvariant();
var value = split[1];
switch (key)
{
case "instances":
case "instancecount":
instanceCount = ParsePositiveInt(value, instanceCount);
break;
case "interval":
case "intervalms":
intervalMs = ParsePositiveInt(value, intervalMs);
break;
case "selector":
case "buttonselector":
buttonSelector = value;
break;
case "headless":
headless = ParseBool(value, headless);
break;
}
}
return new StressOptions(targetUrl, instanceCount, intervalMs, buttonSelector, headless);
}
private static int ParsePositiveInt(string? rawValue, int fallback)
{
if (!string.IsNullOrWhiteSpace(rawValue) && int.TryParse(rawValue, out var parsed) && parsed > 0)
{
return parsed;
}
return fallback;
}
private static bool ParseBool(string? rawValue, bool fallback)
{
if (!string.IsNullOrWhiteSpace(rawValue) && bool.TryParse(rawValue, out var parsed))
{
return parsed;
}
return fallback;
}
}
+48
View File
@@ -0,0 +1,48 @@
# stress-test-01
C# .NET console app that launches 20 Playwright Chromium instances, navigates to the deployed URL, and clicks a button every 200ms.
## Defaults
- URL: prompted from user input every run
- Instances: `20`
- Interval: `200` ms
- Button selector: `button:visible` (first match)
- Headless: `true`
## Run
```bash
cd Testing/stress-test-01
dotnet restore
dotnet build
./bin/Debug/net10.0/.playwright/node/linux-x64/node ./bin/Debug/net10.0/.playwright/package/cli.js install chromium
dotnet run
```
PowerShell alternative:
```bash
pwsh bin/Debug/net10.0/playwright.ps1 install chromium
```
The app will prompt:
```text
Enter target URL:
```
## Optional overrides
Use either environment variables or CLI args:
- `INSTANCE_COUNT` or `--instances=<value>`
- `CLICK_INTERVAL_MS` or `--intervalms=<value>`
- `BUTTON_SELECTOR` or `--selector=<value>`
- `HEADLESS` or `--headless=<true|false>`
Example:
```bash
INSTANCE_COUNT=20 CLICK_INTERVAL_MS=200 dotnet run
```
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Playwright" Version="1.54.0" />
</ItemGroup>
</Project>
+155 -41
View File
@@ -1,32 +1,141 @@
# Getting Started
This guide gets the solution running locally and explains what happens during startup.
This guide walks you through everything you need to get the project running locally — from installing tools to understanding why certain architectural decisions were made.
---
## What is in this solution
- `Htmx.ApiDemo`: ASP.NET Core app (Minimal API + generated HTMX endpoints)
- `Htmx.SourceGenerator`: Roslyn source generator that discovers `.htmx` files and generates endpoint mapping code
- `Htmx.slnx`: solution file at the repository root
| Project | Purpose |
|---|---|
| `Htmx.ApiDemo` | ASP.NET Core web app using Minimal APIs and server-rendered HTMX templates |
| `Htmx.SourceGenerator` | Roslyn source generator that reads `.htmx` template files and generates endpoint mapping code at build time |
The solution file is `Htmx.slnx` at the repository root.
---
## Prerequisites
- .NET SDK 10.x (target framework is `net10.0`)
- Node.js + npm (used for Tailwind CSS compilation during build)
- MongoDB running locally on `mongodb://localhost:27017`
Install the following before cloning the repo.
### .NET SDK
The project targets `net10.0`. Download the .NET 10 SDK from [dot.net](https://dotnet.microsoft.com/download).
Verify with:
```bash
dotnet --version
```
### Node.js and npm
Tailwind CSS is compiled during the build using the Tailwind CLI via `npx`. Node.js must be installed.
Download from [nodejs.org](https://nodejs.org). Verify with:
```bash
node -v
npm -v
```
### MongoDB
The app stores data in MongoDB. You need a local instance running on `mongodb://localhost:27017`.
**Windows:**
Download and install [MongoDB Community Server](https://www.mongodb.com/try/download/community). During installation, choose to run MongoDB as a Windows Service so it starts automatically.
**Linux:**
Follow the official guide for your distro at [docs.mongodb.com/manual/administration/install-on-linux](https://www.mongodb.com/docs/manual/administration/install-on-linux/). For Ubuntu/Debian:
```bash
sudo systemctl start mongod
sudo systemctl enable mongod # start on boot
```
**MongoDB Compass (optional but recommended):**
Compass is a GUI for browsing and querying your MongoDB data. Download it from [mongodb.com/products/compass](https://www.mongodb.com/products/compass). Connect it to `mongodb://localhost:27017` to inspect collections while developing.
---
## VS Code setup
### Required extensions
- **C# Dev Kit** — provides IntelliSense, debugging, and project support for .NET
Search for `ms-dotnettools.csdevkit` in the Extensions panel.
- **C# (OmniSharp / Roslyn)** — included with C# Dev Kit but can also be installed standalone as `ms-dotnettools.csharp`.
### Recommended settings
Add the following to your workspace or user `settings.json`. This teaches VS Code to treat `.htmx` files as HTML (for syntax highlighting and formatting) and nests generated companion files under their parent in the Explorer sidebar so the file tree stays clean.
```jsonc
{
"files.associations": {
"*.htmx": "html"
},
"explorer.fileNesting.enabled": true,
"explorer.fileNesting.expand": false,
"explorer.fileNesting.patterns": {
"*.razor": "$(capture).razor.cs, $(capture).razor.css, $(capture).razor.js",
"*.htmx": "${capture}.htmx.cs, ${capture}.htmx.routing.cs, ${capture}.g.cs, ${capture}.css"
}
}
```
Without `files.associations`, `.htmx` files open as plain text with no highlighting. Without file nesting, every generated `.htmx.cs` and `.htmx.routing.cs` file appears as a separate top-level entry in the Explorer, making it hard to navigate.
### Optional extension
- **Tailwind CSS Fold** — collapses long Tailwind class strings in the editor so markup is easier to read. Search for `stivo.tailwind-fold`. This is purely a cosmetic convenience and has no effect on the build.
---
## Understanding AOT
AOT (Ahead-of-Time compilation) means the app is compiled to native machine code before it runs, rather than relying on the .NET JIT at runtime. This project has AOT enabled (`<PublishAot>true</PublishAot>` in the `.csproj`).
### Why AOT matters here
AOT produces smaller, faster deployments with no JIT warmup time. For a web app handling many requests, startup time and binary size are real concerns — especially in containerized or serverless environments.
### What AOT prevents you from doing
AOT is a significant constraint. It eliminates entire categories of patterns that are common in standard .NET development:
- **No Entity Framework Core** — EF Core relies heavily on runtime reflection and expression compilation. It is not AOT-compatible. This project uses the MongoDB driver directly instead.
- **No runtime reflection** — `Type.GetProperties()`, `Activator.CreateInstance()`, dynamic proxies, and similar patterns do not work (or produce warnings/errors) under AOT. If a pattern depends on inspecting types at runtime, it will not survive.
- **Many NuGet packages are incompatible** — Any package that uses reflection internally (serializers, mappers, validators, ORMs, DI containers with convention scanning, etc.) may break. Check a package's AOT compatibility before adding it.
- **Source generator-based serialization required** — Rather than `JsonSerializer.Serialize(myObject)` discovering properties at runtime, you must register types with a `JsonSerializerContext` subclass (see `AppJsonSerializerContext.cs`). The serializer then uses generated code instead of reflection.
- **Route handler code generation** — ASP.NET Core's Minimal API generator produces code for request binding and response writing. Some third-party packages produce code that conflicts with this. If adding a package causes build errors in generated files, AOT incompatibility is the likely cause.
The practical rule: before reaching for a package or pattern you know from standard ASP.NET Core, check whether it is AOT-compatible. The project will compile normally in Debug mode even with AOT-incompatible code — AOT issues typically only surface during `dotnet publish`.
---
## First-time setup
From the repository root:
Clone the repo, then install the npm dependencies that the build needs:
```bash
cd Htmx.ApiDemo
npm install
```
Why this is required:
This installs the Tailwind CSS CLI package. The build runs `npx @tailwindcss/cli` as an MSBuild step, so if this is skipped the build will fail with a missing command error.
- The app build runs Tailwind via `npx @tailwindcss/cli ...` in a custom MSBuild target.
- Without `npm install`, build fails because the Tailwind CLI package is missing.
---
## Run the app
@@ -36,57 +145,62 @@ From the repository root:
dotnet run --project Htmx.ApiDemo/Htmx.ApiDemo.csproj
```
Default local URL:
The app listens on `http://localhost:5120` by default (configured in `Htmx.ApiDemo/Properties/launchSettings.json`).
- `http://localhost:5120`
This comes from the launch profile in `Htmx.ApiDemo/Properties/launchSettings.json`.
---
## Verify it works
1. Open `http://localhost:5120`
2. If you are not authenticated, middleware redirects to `/login`
3. Create an account at `/register`
4. Sign in and navigate the app
1. Open `http://localhost:5120` in your browser.
2. If you are not signed in, the middleware redirects you to `/login` — this is expected.
3. Go to `/register` and create an account.
4. Sign in and explore the app.
## What startup config does
---
`Htmx.ApiDemo/Program.cs` configures:
## What happens at startup
- MongoDB DI and index initialization (`EnsureIndexesAsync`)
- Cookie authentication + authorization
`Program.cs` wires up the following in order:
- MongoDB service registration and index initialization (`EnsureIndexesAsync`)
- Cookie-based authentication and authorization
- Antiforgery middleware
- AOT-friendly JSON resolver chain using `AppJsonSerializerContext`
- Endpoint registration via generated mapping call:
- `app.MapHtmxApiDemoEndpoints();`
- AOT-compatible JSON serialization via `AppJsonSerializerContext`
- All generated HTMX endpoints via `app.MapHtmxApiDemoEndpoints()`
## Build behavior worth knowing
---
- Tailwind CSS is compiled before build into `Htmx.ApiDemo/wwwroot/css/output.css`
- `.htmx` files are treated as generator inputs (`<AdditionalFiles Include="**/*.htmx" />`)
- AOT is enabled (`<PublishAot>true</PublishAot>`), so reflection-heavy patterns can break publish/runtime
## Build details
## Optional: publish as AOT
- **Tailwind** is compiled into `wwwroot/css/output.css` as a pre-build step.
- **`.htmx` files** are passed to the source generator as `<AdditionalFiles>`. The generator reads them and produces the abstract base classes and routing code.
- **AOT** is active on publish. Run a publish build early and often to catch incompatibilities before they accumulate.
### Publish (AOT)
```bash
dotnet publish Htmx.ApiDemo/Htmx.ApiDemo.csproj -c Release
```
Use this early to catch AOT issues while developing features.
---
## Troubleshooting
### Build fails on Tailwind command
### Build fails Tailwind command not found
- Run `npm install` inside `Htmx.ApiDemo`
- Confirm `node -v` and `npm -v` are available
Run `npm install` inside the `Htmx.ApiDemo` directory. Confirm `node` and `npm` are on your `PATH`.
### Mongo connection errors
### MongoDB connection errors at startup
- Confirm MongoDB is running on `localhost:27017`
- Confirm `ConnectionStrings:DefaultConnection` in `Htmx.ApiDemo/appsettings.json`
- Confirm the MongoDB service is running (`mongod`).
- Check that `ConnectionStrings:DefaultConnection` in `appsettings.json` points to `mongodb://localhost:27017`.
### App keeps redirecting to login
### App always redirects to `/login`
- This is expected for unauthenticated routes
- Register at `/register` or sign in at `/login`
This is intentional. Unauthenticated requests are redirected by the auth middleware. Register at `/register` first.
### AOT warnings or errors on publish
- Look at the warning message — it usually names the type or method causing the issue.
- Remove or replace the offending package or pattern with an AOT-compatible alternative.
- Run `dotnet publish` regularly during development so issues do not pile up.
+136 -118
View File
@@ -1,58 +1,32 @@
# 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.
Think of a page as a **form letter** — the template is the letter with blanks left for personalisation, and your C# class is the person who fills those blanks in before the letter is sent. The build system generates all the plumbing between the two; you just write the template and the class.
## How pages work
---
Every page is a pair of files:
## What you want to achieve
| File | Purpose |
By the end of this guide you will have a new page at a URL like `/dashboard` that:
- Shows your own custom HTML
- Loads instantly as a full page when you visit the URL directly
- Swaps in as a smooth partial update when navigated to from the sidebar
- Accepts data you pass to it from C#
---
## The two files every page needs
| File | What it is |
|---|---|
| `Templates/MyPage.htmx` | HTML markup with `$$SlotName$$` slots |
| `Templates/MyPage.htmx.cs` | C# class that fills slots + declares the Minimal API handler |
| `Templates/MyPage.htmx` | The letter template — HTML with `$$Slot$$` blanks |
| `Templates/MyPage.htmx.cs` | The person filling in the blanks — C# class + route 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.
The build system (`Htmx.SourceGenerator`) reads your `.htmx` file and generates an abstract C# class with one `RenderXxx()` method per `$$Slot$$`. Your job is to inherit that class and implement each method.
## 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
## Step 1 — Write the template
Create `Htmx.ApiDemo/Templates/MyPage.htmx`:
@@ -63,119 +37,163 @@ Create `Htmx.ApiDemo/Templates/MyPage.htmx`:
</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
Rules for slots:
- Names are **PascalCase** surrounded by `$$` — e.g. `$$MySlot$$`
- A slot can contain plain text, HTML, or a rendered component
- The file must live inside `Templates/` so the build picks it up automatically
## Step 2 — Create the `.htmx.cs` code-behind
After saving this file and building, the generator emits `MyPageBase` — a class you will never edit but will inherit from.
---
## Step 2 — Write the 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 = [];
private readonly byte[] _headingData;
private readonly 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)
public MyPage(string heading, string description)
{
var ctx = httpContextAccessor.HttpContext
?? throw new InvalidOperationException("HttpContext is not available.");
// Convert strings to UTF-8 bytes once in the constructor.
// The Render methods then just write those bytes — no allocations at request time.
_headingData = heading.ToUtf8Bytes();
_descriptionData = description.ToUtf8Bytes();
}
var page = new MyPage
{
Heading = "My New Page",
Description = "This is a minimal example page."
};
protected override void RenderHeading(HtmxRenderContext ctx)
=> ctx.Writer.WriteUtf8(_headingData);
// 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;
protected override void RenderDescription(HtmxRenderContext ctx)
=> ctx.Writer.WriteUtf8(_descriptionData);
}
```
The pattern here is deliberate: do all string work (formatting, encoding) in the constructor, so that `Render` is nothing but memory writes. This keeps request handling fast.
---
## Step 3 — Write the route handler
Route handlers live in the same `.htmx.cs` file. They are plain static methods registered with Minimal API — no special framework, no base class, no attributes from removed packages:
```csharp
namespace Htmx.ApiDemo.Templates;
public static class MyPageEndpoints
{
public static void Map(IEndpointRouteBuilder app)
{
app.MapGet("/my-page", Handle);
}
private static IResult Handle(HttpContext ctx)
{
var page = new MyPage(
heading: "My New Page",
description: "This is a minimal example."
);
ctx.WriteHtmxPage(page, title: "My Page");
return Results.Empty;
}
}
```
## Step 3 — Add a sidebar link (optional but typical)
Then register it in `Program.cs` alongside the other endpoint registrations:
Open `Templates/MainLayout.htmx` and add a nav entry inside the `<nav>` block. Existing entries look like this:
```csharp
MyPageEndpoints.Map(app);
```
---
## Step 4 — Add a sidebar link (optional but typical)
Open `Templates/MainLayout.htmx` and add a nav entry inside the `<nav>` block:
```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
The three HTMX attributes do the heavy lifting:
## How `WriteHtmxPage` decides what to render
| Attribute | What it does |
|---|---|
| `hx-get="/my-page"` | Fetches the page as a partial instead of a full reload |
| `hx-target="#main-view"` | Drops the response into the content area, leaving the sidebar untouched |
| `hx-push-url="true"` | Updates the browser URL bar so bookmarks and back-button still work |
---
## How the app knows whether to send a full page or just the fragment
When `WriteHtmxPage` is called it checks for the `HX-Request` header that HTMX sends on every HTMX-triggered request:
```
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.)
Direct browser visit (no HX-Request header)
→ full HTML: <html><head>...</head><body><sidebar/><main>YOUR PAGE</main></body></html>
HTMX sidebar click (HX-Request: true)
→ just your fragment: <div class="p-6 space-y-4">...</div>
→ plus an HX-Title header so the browser tab title still updates
```
The logic lives in `HtmxPageExtensions.WriteHtmxPage`. You never need to fork on this yourself.
You never need to branch on this yourself. `WriteHtmxPage` handles it.
## 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:
## Embedding a component inside a page
Slots are not limited to text. If you want to place a reusable component inside a slot, store it as a field and call `Render` from the override:
```csharp
public IHtmxComponent MyCard { get; }
public MyPage(IHtmxComponent myCard)
public sealed class MyPage : MyPageBase
{
MyCard = myCard;
}
private readonly byte[] _headingData;
private readonly IHtmxComponent _statusBadge;
protected override void RenderMyCard(HtmxRenderContext context)
=> MyCard.Render(context);
public MyPage(string heading, string status)
{
_headingData = heading.ToUtf8Bytes();
_statusBadge = new Badge(status, variant: "secondary");
}
protected override void RenderHeading(HtmxRenderContext ctx)
=> ctx.Writer.WriteUtf8(_headingData);
protected override void RenderStatusBadge(HtmxRenderContext ctx)
=> _statusBadge.Render(ctx.Next()); // ctx.Next() tracks nesting depth
}
```
See [03-creating-a-component.md](03-creating-a-component.md) for the full component pattern.
In the template:
```html
<div class="flex items-center gap-3">
<h1>$$Heading$$</h1>
$$StatusBadge$$
</div>
```
See [03-creating-a-component.md](03-creating-a-component.md) for how to build your own components.
---
## 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
- [ ] `MyPage.htmx` saved in `Templates/` with `$$PascalCase$$` slots
- [ ] `MyPage.htmx.cs` has a class inheriting `MyPageBase` with all `RenderXxx` overrides
- [ ] Route handler registered in `Program.cs`
- [ ] Builds cleanly — the compiler will error if any slot override is missing
- [ ] Sidebar link added to `MainLayout.htmx` if the page needs to be in the nav
+103 -54
View File
@@ -1,16 +1,31 @@
# Creating a New Component
Components are the reusable building blocks of the UI. They follow the same `.htmx` + `.htmx.cs` pair pattern as pages, but they live in `Templates/Components/`, implement `IHtmxComponent`, and are never responsible for HTTP routing.
A component is a **reusable stamp**. You design the stamp once (the `.htmx` template + `.htmx.cs` class), and then press it anywhere you need that piece of UI — on multiple pages, inside other components, even multiple times on the same page.
## The three component patterns
Components are identical in structure to pages, with two key differences:
All existing components fall into one of three shapes. Pick the one that fits what you are building.
1. They live in `Templates/Components/` instead of `Templates/`
2. They are never responsible for HTTP routing — they just render HTML
---
### Pattern A — Simple slot component
## What you want to achieve
Use this when every piece of output is a plain string set from outside.
By the end of this guide you will be able to build any reusable UI piece — a styled label, a card, a form field, or a wrapper that holds other components — and drop it anywhere on a page.
---
## The three patterns
All components fit one of three shapes. Pick the one that matches what you are building.
---
### Pattern A — A simple label or display element
Use this when the component just renders a styled string. It is the simplest case.
**Goal:** a coloured status badge you can reuse in tables, cards, and headers.
```html
<!-- Templates/Components/Badge.htmx -->
@@ -26,13 +41,11 @@ public sealed class Badge : BadgeBase
private readonly byte[] _labelData;
private readonly byte[] _classesData;
// Compute the final class string once in the constructor,
// encode to UTF-8 bytes, never allocate again during render
public Badge(string label, string variant = "default")
{
_labelData = label.ToUtf8Bytes();
var variantClass = variant switch
var variantClasses = variant switch
{
"secondary" => "bg-secondary text-secondary-foreground",
"destructive" => "bg-destructive text-destructive-foreground",
@@ -40,7 +53,7 @@ public sealed class Badge : BadgeBase
_ => "bg-primary text-primary-foreground",
};
_classesData = $"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold {variantClass}"
_classesData = $"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold {variantClasses}"
.ToUtf8Bytes();
}
@@ -49,15 +62,19 @@ public sealed class Badge : BadgeBase
}
```
The key principle: **all computation happens in the constructor**. By the time `RenderLabel` is called during a request, it is just writing pre-computed bytes — no string formatting, no allocations.
---
### Pattern B — Conditionally built sections
### Pattern B — A container with optional sections
Use this when parts of the template are optional (e.g. a card header that only renders when a title is provided). Build the HTML string in the constructor and store as bytes; leave the byte array empty `[]` when not needed — `WriteUtf8` on an empty span is a no-op.
Use this when parts of the component are optional — for example a card that shows a header only when a title is provided.
**Goal:** a card that always shows its body, but optionally shows a header and a footer.
```html
<!-- Templates/Components/Card.htmx -->
<div class="rounded-lg border border-border bg-card text-card-foreground shadow-sm $$ExtraClasses$$">
<div class="rounded-lg border border-border bg-card shadow-sm $$ExtraClasses$$">
$$Header$$
<div class="p-6 pt-0">$$Content$$</div>
$$Footer$$
@@ -85,7 +102,8 @@ public sealed class Card : CardBase
_extraClassesData = extraClasses.ToUtf8Bytes();
_contentData = content.ToUtf8Bytes();
// Header is only rendered when a title or description is supplied
// Build header HTML in the constructor. If there's no title/description,
// store an empty array — writing empty bytes is a no-op.
_headerData = (string.IsNullOrEmpty(title) && string.IsNullOrEmpty(description))
? []
: BuildHeader(title, description);
@@ -116,88 +134,119 @@ public sealed class Card : CardBase
---
### Pattern C — Component slots (embedding other components)
### Pattern C — A wrapper that holds other components
Use this when a slot should itself be rendered by another `IHtmxComponent`. Store the sub-component as a property and call `component.Render(context)` from the override.
Use this when a slot should be filled by another component rather than a string.
**Goal:** a tooltip wrapper — the trigger is any component, and the tooltip text floats above it on hover.
```html
<!-- Templates/Components/MyWrapper.htmx -->
<div class="wrapper p-4">
$$Inner$$
</div>
<!-- Templates/Components/Tooltip.htmx -->
<span class="relative inline-flex items-center group">
$$Trigger$$
<span class="absolute bottom-full mb-1.5 ... opacity-0 group-hover:opacity-100">$$Text$$</span>
</span>
```
```csharp
// Templates/Components/MyWrapper.htmx.cs
// Templates/Components/Tooltip.htmx.cs
namespace Htmx.ApiDemo.Templates.Components;
public sealed class MyWrapper : MyWrapperBase
public sealed class Tooltip : TooltipBase
{
private readonly IHtmxComponent _inner;
private readonly IHtmxComponent _trigger;
private readonly byte[] _textData;
public MyWrapper(IHtmxComponent inner)
public Tooltip(string text, IHtmxComponent trigger)
{
_inner = inner;
_textData = text.ToUtf8Bytes();
_trigger = trigger;
}
// Pass context.Next() so the recursion depth counter increments;
// the runtime throws if nesting exceeds 512 levels
protected override void RenderInner(HtmxRenderContext ctx)
=> _inner.Render(ctx.Next());
protected override void RenderText(HtmxRenderContext ctx)
=> ctx.Writer.WriteUtf8(_textData);
// ctx.Next() increments the nesting depth counter.
// The runtime throws if nesting exceeds 512 levels — this is the guard against infinite loops.
protected override void RenderTrigger(HtmxRenderContext ctx)
=> _trigger.Render(ctx.Next());
}
```
The depth guard (`context.Next()`) is automatically enforced by the infrastructure generated in `HtmxInfrastructure.g.cs`. You do not need to check it yourself.
---
## Embedding a component in a page
## Using a component inside a page
Once a component implements `IHtmxComponent`, use it from a page's code-behind by assigning an instance to an `IHtmxComponent` property and delegating `Render`:
Once you have a component, use it from a page's code-behind. The page stores the component as a field and delegates `Render` from its slot override:
```csharp
// inside MyPage.htmx.cs
public IHtmxComponent MyBadge { get; }
public MyPage(...)
// MyPage.htmx.cs
public sealed class MyPage : MyPageBase
{
MyBadge = new Badge("New", variant: "secondary");
}
private readonly byte[] _headingData;
private readonly IHtmxComponent _statusBadge;
protected override void RenderMyBadge(HtmxRenderContext ctx)
=> MyBadge.Render(ctx.Next());
public MyPage(string heading, string status)
{
_headingData = heading.ToUtf8Bytes();
_statusBadge = new Badge(status, variant: "secondary");
}
protected override void RenderHeading(HtmxRenderContext ctx)
=> ctx.Writer.WriteUtf8(_headingData);
protected override void RenderStatusBadge(HtmxRenderContext ctx)
=> _statusBadge.Render(ctx.Next());
}
```
The corresponding slot in `MyPage.htmx`:
Template:
```html
<div class="flex gap-2">
<span>Status:</span>
$$MyBadge$$
<!-- MyPage.htmx -->
<div class="flex items-center gap-3">
<h1>$$Heading$$</h1>
$$StatusBadge$$
</div>
```
---
## File naming and namespace rules
## A note on HTML safety
| File location | Generated namespace |
`WriteUtf8` writes raw bytes directly to the HTTP response. It does **not** HTML-encode anything.
- Strings you write in the constructor that come from your own code are fine — you control them.
- Any value that comes from user input (a form field, a database value, a query parameter) **must be HTML-encoded before calling `ToUtf8Bytes()`**:
```csharp
// Safe — encodes characters like < > " &
_nameData = System.Web.HttpUtility.HtmlEncode(userInput).ToUtf8Bytes();
```
Skipping this step is a cross-site scripting (XSS) vulnerability.
---
## File location and namespace
| File location | C# namespace |
|---|---|
| `Templates/Components/MyComp.htmx` | `Htmx.ApiDemo.Templates.Components` |
| `Templates/MyPage.htmx` | `Htmx.ApiDemo.Templates` |
The source generator derives the namespace from the folder path relative to the project root. Keep components in `Templates/Components/` so they land in the right namespace and stay separate from page templates.
The source generator derives the namespace from the folder path relative to the project root. Always keep components in `Templates/Components/`.
---
## HTML user content safety
## Checklist
The `WriteUtf8` method writes raw bytes directly to the response. **It does not HTML-encode.**
- Static strings you write in the constructor are trusted — you control them.
- Any value that comes from user input (e.g. a form field, a database string) **must be HTML-encoded before calling `ToUtf8Bytes()`**.
```csharp
- [ ] `.htmx` template created in `Templates/Components/` with `$$PascalCase$$` slots
- [ ] `.htmx.cs` class inherits the generated `XxxBase` class
- [ ] All `RenderXxx` overrides implemented
- [ ] Computation (string building, class selection) done in the constructor
- [ ] User-provided strings HTML-encoded before `ToUtf8Bytes()`
- [ ] Sub-component `Render` calls use `ctx.Next()` not bare `ctx`
// Safe — user-supplied string is encoded first
_displayNameData = System.Web.HttpUtility.HtmlEncode(userDisplayName).ToUtf8Bytes();
```
+96 -53
View File
@@ -1,23 +1,29 @@
# Data Models and AOT Safety
# Data Models and AOT
This guide explains how to define MongoDB document models, register them for AOT-safe serialization, and avoid the common patterns that break Native AOT compilation.
Think of your data model as a **passport**. When a document leaves MongoDB and enters your C# code (or vice versa), it needs to be checked against an explicit, pre-declared format. In a regular .NET app, the runtime reads the passport on the fly using reflection. Under AOT, that border crossing has to be pre-approved at build time — every field declared up front, no surprises allowed.
## Why AOT matters
This guide covers how to define models, register them safely for AOT, and avoid the patterns that quietly break in production.
The project is compiled with `<PublishAot>true</PublishAot>`. AOT (Ahead-of-Time) compilation strips out the JIT and eliminates reflection-based code paths at runtime. Any code that relies on `Type.GetProperties()`, `Activator.CreateInstance()`, `Expression.Compile()`, or similar reflection primitives will either:
---
- Produce a build warning during `dotnet publish`, or
- Throw a `MissingMethodException` / `InvalidOperationException` at runtime
## What you want to achieve
The two main risks in this project are MongoDB BSON serialization and System.Text.Json serialization. Both require explicit registration rather than auto-discovery.
By the end of this guide you will know how to:
- Define a MongoDB document class
- Register it so it survives AOT compilation
- Add a new collection to the app
- Create an index on startup
- Spot and fix the most common AOT mistakes
---
## Defining a document model
A document class is a plain C# class annotated with BSON attribute hints. Keep it simple:
A document class is a plain C# class. Keep it simple — just properties, no logic.
```csharp
// Data/AppUser.cs
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
@@ -45,19 +51,20 @@ public sealed class AppUser
}
```
Rules:
- Always annotate the primary key with `[BsonId]`
- Always annotate every persisted property with `[BsonElement("fieldName")]` — this makes the MongoDB field name explicit and independent of C# naming conventions
- Use `SetIgnoreExtraElements(true)` in the class map (see below) so old documents with extra fields do not crash deserialization
Two rules:
- `[BsonId]` marks the primary key — always required
- `[BsonElement("fieldName")]` names the MongoDB field explicitly — always use this, otherwise renaming a C# property breaks existing documents
---
## Registering the class map (AOT-safe)
## Registering the class map
MongoDB's default `AutoMap()` uses reflection to discover properties at runtime. This is not AOT-safe. Instead, register an explicit `BsonClassMap` in `Program.cs` **before** `WebApplication.CreateSlimBuilder`:
MongoDB's default behaviour is to scan your class at runtime using reflection and figure out the fields automatically. That does not work under AOT.
Instead, you declare the mapping explicitly in `Program.cs` before the app builder is created:
```csharp
// Program.cs — must appear before builder construction
// Program.cs — at the very top, before WebApplication.CreateSlimBuilder
BsonClassMap.RegisterClassMap<AppUser>(cm =>
{
@@ -67,97 +74,133 @@ BsonClassMap.RegisterClassMap<AppUser>(cm =>
cm.MapProperty(u => u.PasswordHash).SetElementName("passwordHash");
cm.MapProperty(u => u.DisplayName).SetElementName("displayName");
cm.MapProperty(u => u.CreatedAtUtc).SetElementName("createdAtUtc");
cm.SetIgnoreExtraElements(true); // old documents with extra fields won't crash
});
```
Every property you want stored must be listed here. If it is not in the class map, it will not be read or written.
`SetIgnoreExtraElements(true)` is important: as your model evolves, old documents in the database may have fields that no longer exist in your class. Without this, deserializing them throws an exception.
---
## Adding a new model — step by step
### 1. Create the class in `Data/`
```csharp
// Data/Post.cs
public sealed class Post
{
[BsonId]
public ObjectId Id { get; set; } = ObjectId.GenerateNewId();
[BsonElement("title")]
public string Title { get; set; } = "";
[BsonElement("authorId")]
public ObjectId AuthorId { get; set; }
[BsonElement("createdAtUtc")]
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
}
```
### 2. Register the class map in `Program.cs`
```csharp
BsonClassMap.RegisterClassMap<Post>(cm =>
{
cm.MapIdProperty(p => p.Id).SetSerializer(new ObjectIdSerializer());
cm.MapProperty(p => p.Title).SetElementName("title");
cm.MapProperty(p => p.AuthorId).SetElementName("authorId");
cm.MapProperty(p => p.CreatedAtUtc).SetElementName("createdAtUtc");
cm.SetIgnoreExtraElements(true);
});
```
This replaces AutoMap entirely. Every property you want persisted must be listed here.
### Adding a new model
1. Create the model class in `Data/` with `[BsonId]` and `[BsonElement]` attributes
2. Add a `BsonClassMap.RegisterClassMap<YourModel>(...)` block in `Program.cs` before the builder
3. Wire the collection into `MongoDbService`
---
## MongoDbService pattern
`MongoDbService` is the single place that owns typed MongoDB collections. Add new collections here:
### 3. Add the collection to `MongoDbService`
```csharp
// Data/MongoDbService.cs
public sealed class MongoDbService
{
private readonly IMongoCollection<AppUser> _users;
// add more collections here
private readonly IMongoCollection<Post> _posts; // add this
public MongoDbService(IMongoClient client, IConfiguration configuration)
{
var db = client.GetDatabase(configuration["MongoDbName"] ?? "HtmxAppDb");
_users = db.GetCollection<AppUser>("users");
// _posts = db.GetCollection<Post>("posts");
_posts = db.GetCollection<Post>("posts"); // ← add this
}
}
```
All queries use the strongly-typed `Builders<T>` API:
---
## Querying data
Use the `Builders<T>` API — not LINQ, not EF-style expressions. The `Builders<T>` API generates BSON queries at compile time using source generators built into the MongoDB driver, so no reflection is needed at runtime.
```csharp
// Exact-match lookup — no LINQ translation, no reflection at runtime
// Good — AOT-safe
var filter = Builders<AppUser>.Filter.Eq(u => u.NormalizedEmail, normalizedEmail);
return await _users.Find(filter).FirstOrDefaultAsync(ct);
```
var user = await _users.Find(filter).FirstOrDefaultAsync(ct);
The `Builders<T>` API compiles query expressions to BSON at build time via source generators in the MongoDB driver — it does not require runtime reflection.
// Bad — uses LINQ translation that requires runtime reflection
var user = await _users.AsQueryable().FirstOrDefaultAsync(u => u.NormalizedEmail == normalizedEmail, ct);
```
---
## Index management
Indexes are created via `EnsureIndexesAsync`, called once at startup from `Program.cs`:
Indexes are declared in `EnsureIndexesAsync` inside `MongoDbService` and called once at startup from `Program.cs`:
```csharp
using (var scope = app.Services.CreateScope())
await scope.ServiceProvider.GetRequiredService<MongoDbService>().EnsureIndexesAsync();
```
Add new indexes to `EnsureIndexesAsync`:
Add new indexes here:
```csharp
public async Task EnsureIndexesAsync(CancellationToken ct = default)
{
var indexKeys = Builders<AppUser>.IndexKeys.Ascending(u => u.NormalizedEmail);
var indexOptions = new CreateIndexOptions { Unique = true, Name = "uq_normalizedEmail" };
var model = new CreateIndexModel<AppUser>(indexKeys, indexOptions);
await _users.Indexes.CreateOneAsync(model, cancellationToken: ct);
// Unique index on email for fast login lookups
var emailIndex = new CreateIndexModel<AppUser>(
Builders<AppUser>.IndexKeys.Ascending(u => u.NormalizedEmail),
new CreateIndexOptions { Unique = true, Name = "uq_normalizedEmail" });
// add more index creation calls here — CreateOneAsync is idempotent
await _users.Indexes.CreateOneAsync(emailIndex, cancellationToken: ct);
// Add more indexes here — CreateOneAsync is idempotent (safe to call every startup)
}
```
The call is idempotent: if the index already exists with the same definition, MongoDB silently succeeds.
---
## AOT anti-patterns to avoid
| Pattern | Why it breaks AOT | Safe alternative |
These patterns compile and run fine in `dotnet run` (Debug mode with the JIT), but fail silently or throw at runtime in a published AOT binary.
| Pattern | Why it breaks | What to do instead |
|---|---|---|
| `BsonClassMap.RegisterClassMap<T>()` without explicit mapping | Uses AutoMap reflection | Explicit `cm.MapProperty(...)` for every field |
| `collection.AsQueryable().Where(...)` | EF-style LINQ translation uses reflection | `Builders<T>.Filter.Eq(...)` |
| `JsonSerializer.Deserialize<T>(json)` without a type resolver | Reflects on T at runtime | Register T in `AppJsonSerializerContext` |
| `Activator.CreateInstance(type)` | Requires reflection metadata | Use `new T()` directly |
| `typeof(T).GetProperties()` | Stripped by trimmer | Not needed with explicit class maps |
| `BsonClassMap.RegisterClassMap<T>()` without explicit property mapping | Uses AutoMap reflection internally | List every property with `cm.MapProperty(...)` |
| `collection.AsQueryable().Where(...)` | LINQ translation requires runtime reflection | Use `Builders<T>.Filter.Eq(...)` etc. |
| `JsonSerializer.Deserialize<T>(json)` without registering T | Reflects on T at runtime | Add T to `AppJsonSerializerContext` (see guide 05) |
| `Activator.CreateInstance(type)` | Requires reflection metadata stripped by AOT trimmer | Use `new T()` directly |
| Packages that internally use AutoMapper, EF Core, or convention-scanning DI | Reflection-heavy at runtime | Find AOT-compatible alternatives |
---
## Checking for AOT warnings
## Checking your work
Run a Release publish and watch the output:
Run a Release publish regularly — do not wait until you are done with a feature:
```bash
dotnet publish Htmx.ApiDemo/Htmx.ApiDemo.csproj -c Release
```
Any trim/AOT warning in the output is a potential runtime failure. Fix each warning before it reaches production. The most common suppressable case is third-party libraries that are not fully AOT-annotated — suppress only after manually verifying the code path is not exercised at runtime.
AOT warnings in the build output are potential runtime failures. Each warning names the exact type or method causing the issue. Fix them as they appear rather than letting them accumulate.
+177 -146
View File
@@ -1,191 +1,221 @@
# Form Submission and AppJsonSerializerContext
# Form Submission
This guide explains how to wire a form POST endpoint, why `AppJsonSerializerContext` must be kept up to date, and what happens if you forget.
Think of a form submission as **sending a letter with a security seal**. The form is your letter, the fields are the contents, and the antiforgery token is the wax seal that proves the letter genuinely came from your site and not from a malicious third party trying to impersonate the user.
## How form POST endpoints work
This guide walks through wiring up a form POST from start to finish.
Form submissions use standard HTML `method="post"` forms. The server-side handler receives the posted fields as a strongly-typed `record` using `[FromForm]` bindings:
---
## What you want to achieve
By the end of this guide you will have a working form that:
- Posts data to a typed C# handler
- Is protected against CSRF attacks with an antiforgery token
- Registers its types with `AppJsonSerializerContext` so AOT compilation does not break it
---
## How a form POST works
Forms use standard HTML `method="post"`. The browser serialises the form fields and sends them as a URL-encoded body. On the server, each field is read from that body and bound to a strongly-typed C# `record`.
```csharp
[Handler]
[MapPost("/login")]
public static partial class PostLoginHandler
// A handler for POST /contact
public static class PostContactHandler
{
// The Command record maps exactly to your form field names
public record Command(
[property: FromForm] string Email,
[property: FromForm] string Password
[property: FromForm] string Name,
[property: FromForm] string Message
);
private static async ValueTask HandleAsync(
[AsParameters] Command command,
IHttpContextAccessor httpContextAccessor,
IAntiforgery antiforgery,
AuthService authService,
CancellationToken token)
public static void Map(IEndpointRouteBuilder app)
{
// ...
app.MapPost("/contact", Handle);
}
private static IResult Handle(
[AsParameters] Command command,
HttpContext ctx)
{
// command.Name and command.Message are already populated
ctx.Response.Redirect("/");
return Results.Empty;
}
}
```
Key parts:
- `[property: FromForm]` on each record property tells the Minimal API binder to read the value from the form body, not from the route or query string
- `[AsParameters]` on the `Command` argument tells Minimal API to bind the record's properties individually rather than deserializing the whole body as JSON
- The handler is discovered and registered by the `Immediate.Apis` source generator — no `app.MapPost(...)` call is needed
Key points:
- `[property: FromForm]` on each record property tells the binder to read that value from the form body, not from the URL or route
- `[AsParameters]` on the `Command` argument tells Minimal API to bind each property individually instead of trying to deserialize the whole body as a single JSON object
- The handler is a plain static method — no special base class, no framework magic
Register it in `Program.cs`:
```csharp
PostContactHandler.Map(app);
```
---
## Antiforgery tokens
All mutating form POST endpoints must be protected against CSRF. The middleware chain includes `app.UseAntiforgery()` (added in `Program.cs`), which validates the `__RequestVerificationToken` field automatically for any `POST`/`PUT`/`DELETE` form submission.
Every mutating form (POST, PUT, DELETE) must include an antiforgery token. This is the wax seal that proves the form was generated by your server — not forged by a third-party page trying to submit on the user's behalf (a CSRF attack).
To include the token in a form rendered server-side:
The middleware `app.UseAntiforgery()` in `Program.cs` validates this token automatically on every mutating request. If the token is missing or wrong, the request is rejected before your handler even runs.
To include the token in a form, inject `IAntiforgery` into the GET handler that renders the page, then pass the token to the template:
```csharp
// Inside a page/component constructor, inject IAntiforgery:
var afTokens = antiforgery.GetAndStoreTokens(ctx);
var tokenField = $"""<input type="hidden" name="__RequestVerificationToken" value="{System.Web.HttpUtility.HtmlAttributeEncode(afTokens.RequestToken)}" />""";
```
The `MainLayout` constructor and all auth page constructors already do this. Any new form page must follow the same pattern.
---
## Why AppJsonSerializerContext is required
The project uses `WebApplication.CreateSlimBuilder`, which enables the Minimal API **Request Delegate Generator**. This generator produces the endpoint binding code at compile time instead of using runtime reflection.
For form-body binding to work under AOT, the JSON serializer's type resolver chain must know about every request/response type it will encounter. This is configured in `Program.cs`:
```csharp
builder.Services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonSerializerContext.Default);
});
```
`AppJsonSerializerContext` is a source-generated `JsonSerializerContext`. It is declared in `AppJsonSerializerContext.cs`:
```csharp
[JsonSerializable(typeof(string))]
[JsonSerializable(typeof(PostLoginHandler.Command), TypeInfoPropertyName = "LoginCommand")]
[JsonSerializable(typeof(PostRegisterHandler.Command), TypeInfoPropertyName = "RegisterCommand")]
[JsonSerializable(typeof(PostLogoutHandler.Command), TypeInfoPropertyName = "LogoutCommand")]
internal partial class AppJsonSerializerContext : JsonSerializerContext
// GET handler that renders the form page
private static IResult HandleGet(
HttpContext ctx,
IAntiforgery antiforgery)
{
var tokens = antiforgery.GetAndStoreTokens(ctx);
var page = new ContactPage(antiforgeryToken: tokens.RequestToken ?? "");
ctx.WriteHtmxPage(page, title: "Contact");
return Results.Empty;
}
```
The `[JsonSerializable]` attribute tells the source generator to emit the serialization metadata for that type at compile time. Without this, the runtime falls back to reflection — which is stripped under AOT and will throw at runtime.
---
## What breaks if you forget to register a type
If you add a new form POST endpoint with a new `Command` record and do not register the record in `AppJsonSerializerContext`:
- A `dotnet build` will succeed and may even run fine in Development with the JIT
- A `dotnet publish -c Release` (AOT) will either emit a trim warning or silently produce a binary that throws `NotSupportedException: Serialization and deserialization of ... is not supported` at runtime when the endpoint is first hit
This is one of the most common mistakes when adding new endpoints.
---
## Step-by-step: adding a new form POST
### 1. Define the command record
In the template or component, render the token as a hidden field:
```csharp
[Handler]
[MapPost("/contact")]
public static partial class PostContactHandler
// ContactPage.htmx.cs
public ContactPage(string antiforgeryToken)
{
// HTML-encode the token value — it is user-visible in the source
_tokenFieldData = $"""
<input type="hidden"
name="__RequestVerificationToken"
value="{System.Web.HttpUtility.HtmlAttributeEncode(antiforgeryToken)}" />
""".ToUtf8Bytes();
}
```
---
## AppJsonSerializerContext — the AOT requirement
The project uses `WebApplication.CreateSlimBuilder`, which generates endpoint binding code at compile time instead of using runtime reflection. For this to work, the serializer must know about every type it might encounter at compile time too.
Every `Command` record you create must be registered in `AppJsonSerializerContext.cs`:
```csharp
// AppJsonSerializerContext.cs
[JsonSerializable(typeof(string))]
[JsonSerializable(typeof(PostLoginHandler.Command), TypeInfoPropertyName = "LoginCommand")]
[JsonSerializable(typeof(PostRegisterHandler.Command), TypeInfoPropertyName = "RegisterCommand")]
[JsonSerializable(typeof(PostContactHandler.Command), TypeInfoPropertyName = "ContactCommand")] // ← add this
internal partial class AppJsonSerializerContext : JsonSerializerContext { }
```
`TypeInfoPropertyName` gives the generated property a readable name and prevents collisions if two commands happen to share the same type name.
### What happens if you forget
- `dotnet run` (Debug, JIT) — works fine, JIT fills in the gaps at runtime
- `dotnet publish -c Release` (AOT) — either emits a trim warning during build, or throws `NotSupportedException` at runtime the first time the endpoint is hit
This is the single most common mistake when adding a new form endpoint. Add the `[JsonSerializable]` entry at the same time as you write the `Command` record.
---
## Complete example — a contact form
### 1. The template
```html
<!-- Templates/ContactPage.htmx -->
<form method="post" action="/contact" class="space-y-4 max-w-md">
$$Token$$
<div>
<label for="name">Name</label>
<input id="name" name="Name" type="text" required />
</div>
<div>
<label for="message">Message</label>
<textarea id="message" name="Message" required></textarea>
</div>
<button type="submit">Send</button>
</form>
```
### 2. The page class
```csharp
// Templates/ContactPage.htmx.cs
namespace Htmx.ApiDemo.Templates;
public sealed class ContactPage : ContactPageBase
{
private readonly byte[] _tokenData;
public ContactPage(string antiforgeryToken)
{
_tokenData = string.IsNullOrEmpty(antiforgeryToken)
? []
: $"""<input type="hidden" name="__RequestVerificationToken" value="{System.Web.HttpUtility.HtmlAttributeEncode(antiforgeryToken)}" />"""
.ToUtf8Bytes();
}
protected override void RenderToken(HtmxRenderContext ctx)
=> ctx.Writer.WriteUtf8(_tokenData);
}
```
### 3. The GET handler (renders the form)
```csharp
public static class GetContactHandler
{
public static void Map(IEndpointRouteBuilder app)
=> app.MapGet("/contact", Handle);
private static IResult Handle(HttpContext ctx, IAntiforgery antiforgery)
{
var tokens = antiforgery.GetAndStoreTokens(ctx);
ctx.WriteHtmxPage(new ContactPage(tokens.RequestToken ?? ""), title: "Contact");
return Results.Empty;
}
}
```
### 4. The POST handler (processes the form)
```csharp
public static class PostContactHandler
{
public record Command(
[property: FromForm] string Name,
[property: FromForm] string Message
);
private static ValueTask HandleAsync(
[AsParameters] Command command,
IHttpContextAccessor httpContextAccessor,
CancellationToken token)
public static void Map(IEndpointRouteBuilder app)
=> app.MapPost("/contact", Handle);
private static IResult Handle([AsParameters] Command command, HttpContext ctx)
{
// handle the form submission
var ctx = httpContextAccessor.HttpContext!;
// process command.Name and command.Message here
ctx.Response.Redirect("/");
return ValueTask.CompletedTask;
return Results.Empty;
}
}
```
### 2. Register the command in AppJsonSerializerContext
Open `Htmx.ApiDemo/AppJsonSerializerContext.cs` and add a `[JsonSerializable]` entry:
### 5. Register both in `Program.cs`
```csharp
[JsonSerializable(typeof(string))]
[JsonSerializable(typeof(PostLoginHandler.Command), TypeInfoPropertyName = "LoginCommand")]
[JsonSerializable(typeof(PostRegisterHandler.Command), TypeInfoPropertyName = "RegisterCommand")]
[JsonSerializable(typeof(PostLogoutHandler.Command), TypeInfoPropertyName = "LogoutCommand")]
[JsonSerializable(typeof(PostContactHandler.Command), TypeInfoPropertyName = "ContactCommand")] // ← add this
internal partial class AppJsonSerializerContext : JsonSerializerContext
{
}
GetContactHandler.Map(app);
PostContactHandler.Map(app);
```
`TypeInfoPropertyName` is optional but recommended — it gives the generated type-info property a readable name and avoids collisions when two commands have the same type name in different namespaces.
### 3. Include the antiforgery token in the form template
```html
<!-- Templates/Contact.htmx -->
<form method="post" action="/contact" class="space-y-4">
$$AntiforgeryToken$$
<input name="name" type="text" placeholder="Your name" />
<input name="message" type="text" placeholder="Message" />
<button type="submit">Send</button>
</form>
```
### 6. Register the Command in `AppJsonSerializerContext.cs`
```csharp
// Templates/Contact.htmx.cs
public sealed class Contact : ContactBase
{
private readonly byte[] _afTokenData;
public Contact(string? afToken = null)
{
_afTokenData = string.IsNullOrEmpty(afToken)
? []
: $"""<input type="hidden" name="__RequestVerificationToken" value="{System.Web.HttpUtility.HtmlAttributeEncode(afToken)}" />""".ToUtf8Bytes();
}
protected override void RenderAntiforgeryToken(HtmxRenderContext ctx)
=> ctx.Writer.WriteUtf8(_afTokenData);
}
```
### 4. Inject and pass the token from the GET handler
```csharp
[Handler]
[MapGet("/contact")]
public static partial class GetContactHandler
{
public record Query;
private static ValueTask HandleAsync(
Query _,
IHttpContextAccessor httpContextAccessor,
IAntiforgery antiforgery,
CancellationToken token)
{
var ctx = httpContextAccessor.HttpContext!;
var afTokens = antiforgery.GetAndStoreTokens(ctx);
ctx.WriteHtmxPage(new Contact(afToken: afTokens.RequestToken), title: "Contact");
return ValueTask.CompletedTask;
}
}
[JsonSerializable(typeof(PostContactHandler.Command), TypeInfoPropertyName = "ContactCommand")]
```
---
@@ -193,8 +223,9 @@ public static partial class GetContactHandler
## Checklist
- [ ] `Command` record properties use `[property: FromForm]`
- [ ] Handler uses `[AsParameters]` on the command
- [ ] `Command` type is registered in `AppJsonSerializerContext` with `[JsonSerializable]`
- [ ] Handler method uses `[AsParameters]` on the `Command` parameter
- [ ] `Command` type added to `AppJsonSerializerContext` with `[JsonSerializable]`
- [ ] Form template includes the antiforgery hidden input
- [ ] GET handler resolves `IAntiforgery`, calls `GetAndStoreTokens`, and passes the token to the template
- [ ] Tested with `dotnet publish -c Release` (not just `dotnet run`) before considering it done
- [ ] GET handler calls `antiforgery.GetAndStoreTokens(ctx)` and passes the token to the page
- [ ] Both GET and POST handlers registered in `Program.cs`
- [ ] Tested with `dotnet publish -c Release` before merging
+90 -31
View File
@@ -1,35 +1,94 @@
# Component Reference
All components live in `Htmx.ApiDemo/Templates/Components/`. Each is a `.htmx` + `.htmx.cs` pair implementing `IHtmxComponent`.
All components live in `Htmx.ApiDemo/Templates/Components/`. Each is a `.htmx` template and a `.htmx.cs` class pair. You create one with `new ComponentName(...)` and drop it into any page slot.
| Component | JS? | File |
If you have not read [03-creating-a-component.md](03-creating-a-component.md) yet, start there — it explains how components work under the hood.
For known API limitations and improvement tracking, also see:
- [Issues/Components/00-API-Limitations-and-Workarounds.md](Issues/Components/00-API-Limitations-and-Workarounds.md)
- [Issues/Components/00-API-Design-Guidelines.md](Issues/Components/00-API-Design-Guidelines.md)
- [Issues/README.md](Issues/README.md)
---
## How to read the component docs
Each component doc is structured the same way:
1. **What it does** — a one-line description
2. **Quick example** — the shortest possible usage that produces a result
3. **All the options** — every parameter explained
4. **Real-world examples** — usage in context (inside a form, inside a page, with HTMX attributes)
5. **How it works** — CSS mechanics and any JavaScript, for when you need to customise it
---
## Display components
These render static visual elements. No JavaScript required.
| Component | What it is | Doc |
|---|---|---|
| Accordion | Yes | [Components/Accordion.md](Components/Accordion.md) |
| Alert | No | [Components/Alert.md](Components/Alert.md) |
| Avatar | No | [Components/Avatar.md](Components/Avatar.md) |
| Badge | No | [Components/Badge.md](Components/Badge.md) |
| Breadcrumb | No | [Components/Breadcrumb.md](Components/Breadcrumb.md) |
| Button | No | [Components/Button.md](Components/Button.md) |
| Calendar | Yes | [Components/Calendar.md](Components/Calendar.md) |
| CalendarRange | Yes | [Components/CalendarRange.md](Components/CalendarRange.md) |
| Card | No | [Components/Card.md](Components/Card.md) |
| Checkbox | No | [Components/Checkbox.md](Components/Checkbox.md) |
| Dialog | Yes | [Components/Dialog.md](Components/Dialog.md) |
| DropdownMenu | Yes | [Components/DropdownMenu.md](Components/DropdownMenu.md) |
| FileInput | No | [Components/FileInput.md](Components/FileInput.md) |
| Input | No | [Components/Input.md](Components/Input.md) |
| Pagination | No | [Components/Pagination.md](Components/Pagination.md) |
| Progress | No | [Components/Progress.md](Components/Progress.md) |
| RadioGroup | No | [Components/RadioGroup.md](Components/RadioGroup.md) |
| Select | No | [Components/Select.md](Components/Select.md) |
| Separator | No | [Components/Separator.md](Components/Separator.md) |
| Skeleton | No | [Components/Skeleton.md](Components/Skeleton.md) |
| Slider | No | [Components/Slider.md](Components/Slider.md) |
| Switch | Yes | [Components/Switch.md](Components/Switch.md) |
| Table | No | [Components/Table.md](Components/Table.md) |
| Tabs | Yes | [Components/Tabs.md](Components/Tabs.md) |
| Textarea | No | [Components/Textarea.md](Components/Textarea.md) |
| TimePicker | Yes | [Components/TimePicker.md](Components/TimePicker.md) |
| Toast | Yes | [Components/Toast.md](Components/Toast.md) |
| ToastViewport | Paired with Toast | [Components/ToastViewport.md](Components/ToastViewport.md) |
| Tooltip | No (pure CSS) | [Components/Tooltip.md](Components/Tooltip.md) |
| Alert | Coloured callout box for messages and errors | [Alert.md](Components/Alert.md) |
| Avatar | Circular user icon — image or initials fallback | [Avatar.md](Components/Avatar.md) |
| Badge | Small coloured label pill | [Badge.md](Components/Badge.md) |
| Breadcrumb | Navigation trail showing current location | [Breadcrumb.md](Components/Breadcrumb.md) |
| Card | Bordered container with optional header and footer | [Card.md](Components/Card.md) |
| Progress | Horizontal fill bar showing a percentage | [Progress.md](Components/Progress.md) |
| Separator | Horizontal or vertical divider line | [Separator.md](Components/Separator.md) |
| Skeleton | Animated loading placeholder | [Skeleton.md](Components/Skeleton.md) |
| Table | Styled data table with headers and rows | [Table.md](Components/Table.md) |
| Tooltip | Hover hint above any element (pure CSS) | [Tooltip.md](Components/Tooltip.md) |
---
## Form components
These are used inside `<form>` elements and submit their values with the form.
| Component | What it is | Doc |
|---|---|---|
| Button | Styled button — six visual variants | [Button.md](Components/Button.md) |
| Checkbox | Single on/off tick box | [Checkbox.md](Components/Checkbox.md) |
| FileInput | File upload field | [FileInput.md](Components/FileInput.md) |
| Input | Single-line text field | [Input.md](Components/Input.md) |
| RadioGroup | Group of mutually exclusive options | [RadioGroup.md](Components/RadioGroup.md) |
| Select | Dropdown list | [Select.md](Components/Select.md) |
| Slider | Range input for numeric values | [Slider.md](Components/Slider.md) |
| Switch | On/off toggle | [Switch.md](Components/Switch.md) |
| Textarea | Multi-line text field | [Textarea.md](Components/Textarea.md) |
---
## Interactive components (require JavaScript)
These components initialise client-side behaviour on `DOMContentLoaded` and `htmx:afterSwap`. The JS lives in `wwwroot/js/components.js`.
| Component | What it is | Doc |
|---|---|---|
| Accordion | Expand/collapse panel list | [Accordion.md](Components/Accordion.md) |
| Calendar | Single-date picker | [Calendar.md](Components/Calendar.md) |
| CalendarRange | Date range picker | [CalendarRange.md](Components/CalendarRange.md) |
| Dialog | Modal overlay using native `<dialog>` | [Dialog.md](Components/Dialog.md) |
| DropdownMenu | Button that opens a floating action list | [DropdownMenu.md](Components/DropdownMenu.md) |
| Tabs | Tabbed panel switcher | [Tabs.md](Components/Tabs.md) |
| TimePicker | Hour/minute selector | [TimePicker.md](Components/TimePicker.md) |
---
## Notification components
| Component | What it is | Doc |
|---|---|---|
| ToastViewport | Fixed container that holds toast notifications — place once in `MainLayout` | [ToastViewport.md](Components/ToastViewport.md) |
| Toast | Transient notification triggered from JavaScript via `window.showToast(...)` | [Toast.md](Components/Toast.md) |
---
## Navigation components
| Component | What it is | Doc |
|---|---|---|
| Breadcrumb | Location trail | [Breadcrumb.md](Components/Breadcrumb.md) |
| Pagination | Numbered page navigation row | [Pagination.md](Components/Pagination.md) |
+90 -108
View File
@@ -1,72 +1,10 @@
# Accordion
An expand/collapse panel list. Items are collapsed by default; one item can be pre-expanded at server render time. Client-side toggle is handled by `components.js`.
An expand/collapse panel list — like a FAQ section or a step-by-step guide where the user reveals each answer one at a time.
---
## HTML structure
```
div.accordion-root[id] ← outer wrapper
div.accordion-item ← one per item, border-b separator
h3
button.accordion-trigger ← clickable header; aria-expanded tracks state
{title text}
svg.accordion-chevron ← rotates 180° when open
div.accordion-panel ← collapsible area; height/opacity driven by JS
div.pb-4
{content}
```
---
## CSS mechanics
| Class / property | Effect |
|---|---|
| `overflow-hidden` on panel | Prevents content leaking outside the panel during animation |
| `transition-all duration-200` on panel | Smooth height and opacity animation |
| `height: 0; opacity: 0` (collapsed) | Starting state set server-side for closed items |
| `height: auto; opacity: 1` (open) | Starting state for the pre-expanded item |
| `accordion-chevron` + JS `rotate(180deg)` | Chevron rotates down when expanded |
---
## JavaScript (`initAccordion` in `components.js`)
Runs on `DOMContentLoaded` and on `htmx:afterSwap` so HTMX-swapped accordions are correctly initialized.
**Per-instance initialization:**
1. Guard `root._accInitialised` prevents double-binding after re-renders
2. For each `.accordion-trigger`, attach a `click` listener:
- Read current state from `aria-expanded`
- If currently open → set `panel.style.height = "0"`, `opacity = "0"`, `aria-expanded = "false"`
- If currently closed → set `panel.style.height = scrollHeight + "px"`, `opacity = "1"`, `aria-expanded = "true"`
3. Rotate `.accordion-chevron` via `transform: rotate(180deg)` when open
---
## Constructor signature
```csharp
public Accordion(
string id,
IEnumerable<(string Title, string Content)> items,
int openIndex = -1)
```
| Parameter | Description |
|---|---|
| `id` | Unique element id for the root `div` |
| `items` | List of `(Title, Content)` tuples |
| `openIndex` | Zero-based index of the pre-expanded item; `-1` = all closed |
---
## Usage examples
### All closed
## Quick example
```csharp
new Accordion(
@@ -75,79 +13,123 @@ new Accordion(
{
("What is this?", "A fast HTMX app framework."),
("Is it AOT-safe?", "Yes, fully."),
("Do I need Node?", "Only to run the Tailwind build step."),
("Do I need Node?", "Only for the Tailwind build step."),
})
```
### One pre-expanded
That's it. Drop this into a page slot and you have a working FAQ section.
---
## All the options
```csharp
public Accordion(
string id,
IEnumerable<(string Title, string Content)> items,
int openIndex = -1)
```
| Parameter | What it does |
|---|---|
| `id` | A unique identifier for this accordion on the page. If you have two accordions on the same page, they need different ids. |
| `items` | The list of panels. Each item is a pair: the header text (`Title`) and the body content (`Content`). |
| `openIndex` | Which panel should start open. `0` = first panel, `1` = second, `-1` = all closed (default). |
---
## Real-world examples
### FAQ page with the first item pre-opened
```csharp
new Accordion(
id: "setup-steps",
id: "faq",
items: new[]
{
("Step 1 — Install", "Run <code>npm install</code> in the project folder."),
("Step 2 — Configure", "Edit <code>appsettings.json</code> with your connection string."),
("Step 3 — Run", "Use <code>dotnet run</code> to start the server."),
("How do I reset my password?", "Go to Settings → Security → Reset Password."),
("How do I cancel my account?", "Contact support from the Help page."),
("Where are my invoices?", "Under Billing in your account dashboard."),
},
openIndex: 0) // first answer visible on load
```
### Step-by-step guide with HTML content inside items
Item `Content` is rendered as raw HTML, so you can use markup inside it:
```csharp
new Accordion(
id: "setup-guide",
items: new[]
{
("Step 1 — Install dependencies",
"Run <code>npm install</code> inside the <code>Htmx.ApiDemo</code> folder."),
("Step 2 — Start MongoDB",
"<p>Start the MongoDB service, then confirm it is running on <code>localhost:27017</code>.</p>"),
("Step 3 — Run the app",
"Run <code>dotnet run --project Htmx.ApiDemo</code> and open <code>http://localhost:5120</code>."),
},
openIndex: 0)
```
### HTML content in items
> **Important:** `Title` and `Content` are inserted as raw HTML. If either value comes from user input or a database, HTML-encode it first:
> ```csharp
> System.Web.HttpUtility.HtmlEncode(userTitle)
> ```
```csharp
new Accordion(
id: "code-examples",
items: new[]
{
("C# snippet", "<pre><code>var x = 1 + 1;</code></pre>"),
("Tip", "<p>Use <strong>AOT-safe</strong> serialization patterns.</p>"),
})
```
### Inside a page
---
## Tips and tricks
- `openIndex` only controls the initial server-rendered state. After the page loads, the user can open/close any item freely.
- Item `Title` and `Content` strings are inserted as raw HTML — HTML-encode any user-supplied values before passing them in.
- Multiple items can be opened simultaneously by the user — there is no "only one open at a time" constraint in the JS.
- If you need to identify which accordion is which after a click, listen to the parent element and inspect `event.target.closest('.accordion-item')`.
- The `id` must be unique on the page if you place more than one accordion.
---
## Complete page example
**`Templates/FaqPage.htmx`**
```html
<!-- Templates/FaqPage.htmx -->
<div class="max-w-2xl mx-auto py-10">
<h1 class="text-2xl font-bold mb-2">Frequently Asked Questions</h1>
<p class="text-muted-foreground mb-8">Everything you need to know about BeepBoop.</p>
<h1 class="text-2xl font-bold mb-6">Frequently Asked Questions</h1>
$$FaqAccordion$$
</div>
```
**`Templates/FaqPage.htmx.cs`**
```csharp
namespace Htmx.ApiDemo.Templates;
// Templates/FaqPage.htmx.cs
public sealed class FaqPage : FaqPageBase
{
private readonly IHtmxComponent _faq;
private readonly IHtmxComponent _faqAccordion;
public FaqPage()
{
_faq = new Components.Accordion(
_faqAccordion = new Components.Accordion(
id: "faq",
items: new[]
{
("What is BeepBoop?",
"A fast, AOT-safe HTMX web framework built on .NET 10."),
("Do I need Node.js?",
"Only to run the Tailwind CSS build step during development."),
("Is MongoDB required?",
"No — swap in any data store you prefer."),
("What is BeepBoop?", "A fast, AOT-safe HTMX web framework built on .NET 10."),
("Do I need Node.js?", "Only to run the Tailwind CSS build step."),
("Is MongoDB required?", "No — swap in any data store you prefer."),
});
}
protected override void RenderFaqAccordion(HtmxRenderContext ctx)
=> _faqAccordion.Render(ctx.Next());
}
```
---
## How it works
The server renders all panels into the HTML. Closed panels are given `height: 0; opacity: 0` inline styles so they are invisible immediately — no layout flash. The JavaScript in `components.js` (`initAccordion`) then attaches click listeners.
When a panel is opened, JS reads the panel's `scrollHeight` (its natural height) and animates the inline `height` from `0` to that value alongside the opacity, giving a smooth slide-down. The chevron icon rotates 180° to point down when open.
If the page content is updated by HTMX, `htmx:afterSwap` re-runs the initialisation so newly swapped-in accordions also get click behaviour.
Users can open multiple panels simultaneously — there is no "only one open at a time" constraint.
---
## Tips
- The `id` must be unique if you place more than one accordion on a page.
- `openIndex` only controls the initial server-rendered state — the user can freely open or close any panel after that.
- To listen for accordion interactions from another script, add a `click` listener to the parent container and check `event.target.closest('.accordion-trigger')`.
("How do I deploy?",
"Run <code>dotnet publish -c Release</code> for a native AOT binary."),
});
+41 -59
View File
@@ -1,35 +1,20 @@
# Alert
A contextual callout box for informational or error messages. Two variants: `default` (neutral) and `destructive` (red). An optional inline SVG icon is positioned automatically.
A coloured callout box that draws the user's attention — like a sticky note placed on top of the page. Use it to show errors, warnings, or helpful information.
---
## HTML structure
## Quick example
```
div[role=alert].{variant classes}
{icon SVG} ← positioned absolute top-left via Tailwind arbitrary selectors
div
h5.font-medium ← title (always rendered)
div.text-sm ← description (omitted when empty)
```csharp
new Alert(
title: "Heads up",
description: "Your session expires in 5 minutes.")
```
---
## CSS mechanics
| Class / selector | Effect |
|---|---|
| `[&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4` | Positions any direct SVG child at top-left |
| `[&>svg~*]:pl-7` | Adds left padding to all siblings after the SVG so text is not covered by the icon |
| `[&>svg+div]:translate-y-[-3px]` | Vertically aligns the text div with the icon center |
| `border-destructive/50 text-destructive` | Red destructive variant |
The arbitrary selector approach (`[&>svg]:*`) means you can pass any SVG and it will be positioned correctly without extra wrapper divs.
---
## Constructor signature
## All the options
```csharp
public Alert(
@@ -39,66 +24,63 @@ public Alert(
string icon = "")
```
| Parameter | Description |
| Parameter | What it does |
|---|---|
| `title` | Required heading text |
| `description` | Optional body text below the title |
| `variant` | `"default"` or `"destructive"` |
| `icon` | Raw SVG string; omit for a text-only alert |
| `title` | The bold heading line — always shown |
| `description` | A second line of detail below the title — optional |
| `variant` | `"default"` (neutral, grey border) or `"destructive"` (red) |
| `icon` | A raw SVG string placed to the left of the text — omit for no icon |
---
## Usage examples
## Real-world examples
### Informational (no icon)
### Login error
```csharp
new Alert(
title: "Heads up",
description: "Your session expires in 5 minutes.")
```
### Destructive
```csharp
new Alert(
title: "Error",
description: "Invalid email or password.",
title: "Sign in failed",
description: "The email or password you entered is incorrect.",
variant: "destructive")
```
### With an icon
### Success confirmation
```csharp
new Alert(title: "Changes saved successfully.")
```
### Info notice with a link in the description
`description` is raw HTML, so you can embed links:
```csharp
new Alert(
title: "New message",
description: "You have 3 unread messages.",
variant: "default",
icon: """
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M22 16.92v3a2 2 0 01-2.18 2 19.79 19.79 0 01-8.63-3.07
A19.5 19.5 0 013.07 9.77a19.79 19.79 0 01-3.07-8.67
A2 2 0 012 .18L5 0a2 2 0 012 1.72 ..."/>
</svg>
""")
title: "Maintenance scheduled",
description: "The system will be offline on Saturday. <a href='/status' class='underline'>View status page</a>.")
```
### Title-only
### Conditional error slot on a form page
A common pattern is to render the alert only when there is something to show. In the page constructor:
```csharp
new Alert(title: "Changes saved successfully.", variant: "default")
// Store empty bytes when there's no error — WriteUtf8 on empty bytes is a no-op
_errorAlertData = string.IsNullOrEmpty(errorMessage)
? []
: new Alert(title: "Error", description: errorMessage, variant: "destructive")
.ToRenderedBytes();
```
---
## Tips and tricks
## How it works
- The icon SVG should be `h-4 w-4` — larger sizes will push text out of alignment.
- For the `destructive` variant the icon automatically inherits `text-destructive` color via the variant class.
- The `description` slot is a raw HTML string — you can include `<a>` links or `<code>` spans.
- Use `Alert` inside a page's optional error slot rather than always rendering it — pass an empty byte array (`[]`) when there is no error so the slot renders nothing.
The alert is a `<div role="alert">` — this tells screen readers to announce its content immediately when it appears on the page.
If you pass an `icon`, it is placed as a direct child SVG. Tailwind's arbitrary selector `[&>svg]:absolute` positions it at the top-left corner automatically, and `[&>svg~*]:pl-7` shifts all the text to the right so nothing overlaps. You do not need any wrapper divs around your SVG.
The icon should be `class="h-4 w-4"` — larger icons will misalign the text.
---
+39 -48
View File
@@ -1,32 +1,22 @@
# Avatar
A circular user avatar. Shows an image when a `src` URL is provided; falls back to a text/initials span otherwise.
A circular user icon. Shows a profile photo when a URL is provided, or falls back to text (typically initials) on a neutral background.
---
## HTML structure
## Quick example
```
span.relative.flex.{size classes}.shrink-0.overflow-hidden.rounded-full
img[src, alt, class] ← when src is provided
span.flex.items-center... ← fallback when no src
{fallback text}
```csharp
// Initials only
new Avatar(fallback: "JD")
// With a profile photo
new Avatar(fallback: "Jane Doe", src: "/avatars/jane.jpg")
```
---
## CSS mechanics
| Class | Effect |
|---|---|
| `rounded-full overflow-hidden` | Clips content to a circle |
| `aspect-square h-full w-full object-cover` | Image fills the circle without distortion |
| `bg-muted text-muted-foreground` | Neutral background for the initials fallback |
| Size `h-8 w-8` / `h-10 w-10` / `h-14 w-14` / `h-20 w-20` | sm / default / lg / xl |
---
## Constructor signature
## All the options
```csharp
public Avatar(
@@ -35,56 +25,57 @@ public Avatar(
string size = "default")
```
| Parameter | Description |
| Parameter | What it does |
|---|---|
| `fallback` | Text shown when no `src` is given; also used as `alt` text on the image |
| `src` | Optional image URL |
| `size` | `"sm"` / `"default"` / `"lg"` / `"xl"` |
| `fallback` | The text shown when no image is available — typically initials like `"JD"`. Also used as the `alt` attribute on the image for screen readers. |
| `src` | URL of the profile photo. If omitted, the fallback is shown. |
| `size` | How big the circle is: `"sm"` (32px), `"default"` (40px), `"lg"` (56px), `"xl"` (80px) |
---
## Usage examples
## Real-world examples
### Initials avatar
### Initials in different sizes
```csharp
new Avatar(fallback: "JD")
new Avatar(fallback: "JD", size: "lg")
new Avatar(fallback: "SM", size: "sm") // 32×32 — good for compact lists
new Avatar(fallback: "JD", size: "default") // 40×40 — standard nav bar
new Avatar(fallback: "LG", size: "lg") // 56×56 — profile card header
new Avatar(fallback: "XL", size: "xl") // 80×80 — full profile page
```
### Image avatar with fallback
### Profile page header
```csharp
new Avatar(fallback: "Jane Doe", src: "/avatars/jane.jpg", size: "default")
new Avatar(
fallback: user.DisplayName ?? "?",
src: user.AvatarUrl,
size: "xl")
```
### Sizes
The `fallback` is always required — even with a `src` — because it becomes the `alt` text on the `<img>` tag.
```csharp
new Avatar(fallback: "SM", size: "sm") // 32×32
new Avatar(fallback: "DF", size: "default") // 40×40
new Avatar(fallback: "LG", size: "lg") // 56×56
new Avatar(fallback: "XL", size: "xl") // 80×80
```
### Overlapping avatar stack (e.g. "3 team members")
### Inside a user card
Wrap multiple avatars in a flex container with negative spacing:
```csharp
var avatar = new Avatar(fallback: user.Initials, src: user.AvatarUrl, size: "lg");
// In a page's RenderUserCard override:
protected override void RenderUserAvatar(HtmxRenderContext ctx)
=> avatar.Render(ctx.Next());
```html
<div class="flex -space-x-2">
$$Avatar1$$
$$Avatar2$$
$$Avatar3$$
</div>
```
---
## Tips and tricks
## How it works
- Compute initials before constructing the Avatar — the component does not extract them from a full name. See `MainLayout`'s `GetInitials` helper for a reference implementation.
- Always provide `fallback` even when you also provide `src` — it serves as the `alt` attribute for accessibility.
- The Avatar does not handle image load errors. If you need a graceful image fallback on 404, add an `onerror="this.style.display='none'"` attribute by embedding it in the `src` or use `hxAttrs` in a subclassed version.
- For a group of overlapping avatars (avatar stack), wrap several Avatars in a flex container with negative margin: `<div class="flex -space-x-2">`.
The avatar is a `<span>` clipped to a circle with `rounded-full overflow-hidden`. When a `src` is given, an `<img>` fills the circle using `object-cover` so the photo does not stretch. When there is no `src`, a `<span>` with a muted background shows the fallback text centred inside the circle.
The component does not handle broken image URLs. If you need a fallback when an image 404s, add an `onerror` attribute in the surrounding HTML.
The Avatar does not extract initials from full names — do that yourself before constructing it. `"Jane Doe"``"JD"` is two lines of C# and is better kept in your own code.
---
+56 -62
View File
@@ -1,108 +1,102 @@
# Badge
A small inline label pill. Used to indicate status, category, or count. Four variants cover most use-cases.
A small coloured pill label — the kind you see next to a status field that says "Active", "Pending", or "Error".
---
## HTML structure
## Quick example
```
span.{base classes + variant classes}
{text}
```csharp
new Badge("Active")
new Badge("Pending", variant: "secondary")
new Badge("Error", variant: "destructive")
new Badge("Draft", variant: "outline")
```
---
## CSS mechanics
| Class | Effect |
|---|---|
| `inline-flex items-center rounded-full` | Pill shape that sits inline with text |
| `px-2.5 py-0.5 text-xs font-semibold` | Compact size and bold label |
| `transition-colors` | Smooth color changes on hover |
| `focus:ring-2 focus:ring-ring focus:ring-offset-2` | Keyboard focus outline |
**Variants:**
| Variant | Classes |
|---|---|
| `default` | `bg-primary text-primary-foreground hover:bg-primary/80` |
| `secondary` | `bg-secondary text-secondary-foreground hover:bg-secondary/80` |
| `destructive` | `bg-destructive text-destructive-foreground hover:bg-destructive/80` |
| `outline` | `text-foreground border border-input hover:bg-accent` |
---
## Constructor signature
## All the options
```csharp
public Badge(string text, string variant = "default")
```
| Parameter | Description |
| Parameter | What it does |
|---|---|
| `text` | Label displayed inside the badge |
| `variant` | `"default"` / `"secondary"` / `"destructive"` / `"outline"` |
| `text` | The label inside the pill |
| `variant` | The colour scheme: `"default"` (primary colour), `"secondary"` (muted), `"destructive"` (red), `"outline"` (border only) |
---
## Usage examples
## Real-world examples
### Basic badges
### Status column in a user table
When you need a Badge inside a table cell (which takes a raw HTML string), render it to a string first:
```csharp
new Badge("New")
new Badge("Beta", variant: "secondary")
new Badge("Error", variant: "destructive")
new Badge("Pending", variant: "outline")
```
### Status indicator in a table cell
```csharp
// Render to bytes and embed in table cell HTML
var writer = new System.Buffers.ArrayBufferWriter<byte>();
new Badge("Active", variant: "default").Render(new HtmxRenderContext(writer));
var badgeHtml = System.Text.Encoding.UTF8.GetString(writer.WrittenSpan);
static string RenderBadge(string text, string variant)
{
var writer = new System.Buffers.ArrayBufferWriter<byte>();
new Badge(text, variant).Render(new HtmxRenderContext(writer));
return System.Text.Encoding.UTF8.GetString(writer.WrittenSpan);
}
new Table(
headers: new[] { "Name", "Status" },
rows: users.Select(u => new[] { u.DisplayName ?? "", badgeHtml }))
headers: new[] { "Name", "Role", "Status" },
rows: users.Select(u => new[]
{
u.DisplayName ?? "",
u.Role,
RenderBadge(u.IsActive ? "Active" : "Suspended",
u.IsActive ? "default" : "destructive"),
}))
```
### Embedding in a page slot
### Dynamic variant based on data
```csharp
var badge = order.Status switch
{
"complete" => new Badge("Complete"),
"pending" => new Badge("Pending", variant: "secondary"),
"cancelled" => new Badge("Cancelled", variant: "destructive"),
_ => new Badge(order.Status, variant: "outline"),
};
```
### Inside a page slot
```html
<!-- MyPage.htmx -->
<div class="flex items-center gap-2">
<span class="text-sm">Status:</span>
<span>Status:</span>
$$StatusBadge$$
</div>
```
```csharp
// MyPage.htmx.cs
public IHtmxComponent StatusBadge { get; }
public MyPage(string status)
public sealed class MyPage : MyPageBase
{
StatusBadge = status == "active"
? new Badge("Active")
: new Badge("Inactive", variant: "secondary");
}
private readonly IHtmxComponent _statusBadge;
protected override void RenderStatusBadge(HtmxRenderContext ctx)
=> StatusBadge.Render(ctx.Next());
public MyPage(string status)
{
_statusBadge = new Badge(status == "active" ? "Active" : "Inactive",
status == "active" ? "default" : "secondary");
}
protected override void RenderStatusBadge(HtmxRenderContext ctx)
=> _statusBadge.Render(ctx.Next());
}
```
---
## Tips and tricks
## How it works
- Badge does not have a click handler — wrap it in an `<a>` or a `Button` if you need interactivity.
- All four variants respond to focus, so a Badge embedded inside a focusable element will show a ring.
- For a count badge (e.g. `"3 new"`) just include the count in the text string.
- To render a Badge inside raw HTML strings (e.g. inside a `Table` cell or `Card` content), render it eagerly to a string in the constructor rather than relying on slot rendering.
Badge is a `<span>` with `rounded-full` giving it the pill shape. The four variants are just different combinations of background and text colour classes. Badge is a purely server-rendered display element — it has no JavaScript and no click behaviour. If you need a clickable badge, wrap it in an `<a>` tag or use a `Button` component with a `link` variant.
---
+64 -70
View File
@@ -1,66 +1,56 @@
# Breadcrumb
A navigation trail showing the user's location in the app hierarchy. Items are separated by chevron icons. The last item is always rendered as plain text (current page); earlier items are links.
A "you are here" trail — a row of links showing how the user got to the current page. Like breadcrumbs leading back through a forest.
---
## HTML structure
```
nav[aria-label=Breadcrumb]
ol.flex.flex-wrap.items-center.gap-1.5.text-sm.text-muted-foreground
li.inline-flex.items-center.gap-1.5 ← one per item
a | span ← a = link, span = non-linked or current
span[role=presentation, aria-hidden] ← chevron separator (omitted after last item)
svg (3.5×3.5, chevron-right)
```
---
## CSS mechanics
| Class | Effect |
|---|---|
| `text-muted-foreground` | Dimmed color for all non-current items |
| `font-normal text-foreground` | Full-opacity color applied to the last (current) item |
| `hover:text-foreground transition-colors` | Link hover state |
| `flex-wrap` | Items wrap on narrow screens |
---
## Constructor signature
```csharp
public Breadcrumb(IEnumerable<(string Label, string Href)> items)
```
| Parameter | Description |
|---|---|
| `items` | Ordered list of `(Label, Href)` tuples. The last item is always the current page. |
Rules:
- The **last** item is always non-linked and rendered in full `text-foreground` color, regardless of its `Href` value.
- Any **non-last** item with an empty `Href` is rendered as a plain `<span>` rather than a link.
---
## Usage examples
### Simple three-level breadcrumb
## Quick example
```csharp
new Breadcrumb(new[]
{
("Home", "/"),
("Settings", "/settings"),
("Profile", ""), // current page — href is ignored for the last item
("Profile", ""), // current page
})
```
### Dynamic breadcrumb from a data path
The last item is always the current page. Its link is ignored — the component automatically renders it as plain text with full colour instead of a dimmed link.
---
## All the options
```csharp
public Breadcrumb(IEnumerable<(string Label, string Href)> items)
```
| Parameter | What it does |
|---|---|
| `items` | An ordered list of `(Label, Href)` pairs from root to current page. |
Two rules:
- The **last item** is always rendered as plain text (current page). Its `Href` is ignored.
- Any **non-last item** with an empty `Href` renders as a plain `<span>` — useful for non-navigable category labels.
---
## Real-world examples
### Three-level app navigation
```csharp
new Breadcrumb(new[]
{
("Home", "/"),
("Reports", "/reports"),
("Monthly", ""), // current — href not needed
})
```
### Built dynamically from a category tree
```csharp
// Build items from a category tree
var crumbs = categoryPath
.Select((cat, i) => (cat.Name, i < categoryPath.Count - 1 ? cat.Url : ""))
.ToArray();
@@ -68,41 +58,45 @@ var crumbs = categoryPath
new Breadcrumb(crumbs)
```
### Embedded in a page
### Inside a page
```html
<!-- MyPage.htmx -->
<div class="mb-6">
$$Breadcrumb$$
</div>
<!-- ArticlePage.htmx -->
<div class="mb-6">$$Breadcrumb$$</div>
<h1 class="text-3xl font-bold">$$ArticleTitle$$</h1>
```
```csharp
// MyPage.htmx.cs
public IHtmxComponent Breadcrumb { get; }
public MyPage()
// ArticlePage.htmx.cs
public sealed class ArticlePage : ArticlePageBase
{
Breadcrumb = new Breadcrumb(new[]
{
("Home", "/"),
("Reports", "/reports"),
("Monthly", ""),
});
}
private readonly IHtmxComponent _breadcrumb;
private readonly byte[] _titleData;
protected override void RenderBreadcrumb(HtmxRenderContext ctx)
=> Breadcrumb.Render(ctx.Next());
public ArticlePage(string articleTitle, string categoryName, string categoryUrl)
{
_titleData = articleTitle.ToUtf8Bytes();
_breadcrumb = new Breadcrumb(new[]
{
("Home", "/"),
(categoryName, categoryUrl),
(articleTitle, ""),
});
}
protected override void RenderBreadcrumb(HtmxRenderContext ctx)
=> _breadcrumb.Render(ctx.Next());
protected override void RenderArticleTitle(HtmxRenderContext ctx)
=> ctx.Writer.WriteUtf8(_titleData);
}
```
---
## Tips and tricks
## How it works
- Always make the last item the current page — its href is ignored anyway, and it gets the visual "active" treatment automatically.
- If you have a non-navigable segment (e.g. a category separator with no URL), pass an empty `Href` for that item and it will render as a plain span.
- For very deep hierarchies, consider truncating the middle items and replacing them with a `…` span — build the items list conditionally before passing to the constructor.
- The chevron separator is `aria-hidden` so screen readers announce only the labels in sequence.
Each item renders as a `<li>` inside an `<ol>` inside a `<nav aria-label="Breadcrumb">`. All items except the last are rendered as `<a>` links; the last is a `<span>`. Between items the component inserts a small SVG chevron that is marked `aria-hidden` so screen readers skip it and only announce the text labels.
---
+71 -73
View File
@@ -1,52 +1,20 @@
# Button
A styled `<button>` element. Supports six visual variants and four sizes. HTMX attributes can be injected directly via the `hxAttrs` parameter.
A styled clickable button. Use it for form submissions, navigation actions, or triggering HTMX requests.
---
## HTML structure
## Quick example
```
button[type=$$Type$$, class=$$Classes$$, $$HxAttrs$$]
$$Label$$
```csharp
new Button("Save changes", type: "submit")
new Button("Cancel", variant: "outline")
new Button("Delete", variant: "destructive")
```
---
## CSS mechanics
**Base classes** (always applied):
```
inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium
ring-offset-background transition-colors
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
disabled:pointer-events-none disabled:opacity-50
```
**Variant** appended:
| Variant | Added classes |
|---|---|
| `default` | `bg-primary text-primary-foreground hover:bg-primary/90` |
| `destructive` | `bg-destructive text-destructive-foreground hover:bg-destructive/90` |
| `outline` | `border border-input bg-transparent hover:bg-accent hover:text-accent-foreground` |
| `secondary` | `bg-secondary text-secondary-foreground hover:bg-secondary/80` |
| `ghost` | `hover:bg-accent hover:text-accent-foreground` |
| `link` | `text-primary underline-offset-4 hover:underline` |
**Size** appended:
| Size | Added classes |
|---|---|
| `default` | `h-10 px-4 py-2 text-sm` |
| `sm` | `h-9 rounded-md px-3 text-xs` |
| `lg` | `h-11 rounded-md px-8 text-base` |
| `icon` | `h-10 w-10` |
---
## Constructor signature
## All the options
```csharp
public Button(
@@ -57,67 +25,97 @@ public Button(
string hxAttrs = "")
```
| Parameter | Description |
| Parameter | What it does |
|---|---|
| `label` | Button text (raw HTML — can include inline SVG) |
| `variant` | Visual style; see table above |
| `size` | Physical size; see table above |
| `type` | HTML button type: `"button"` / `"submit"` / `"reset"` |
| `hxAttrs` | Verbatim string appended as extra HTML attributes (HTMX, data-*, etc.) |
| `label` | The button text. Can include raw HTML — useful for icons. |
| `variant` | Visual style. See table below. |
| `size` | How big the button is. See table below. |
| `type` | HTML button type. Use `"submit"` for form submit buttons. Defaults to `"button"`. |
| `hxAttrs` | Extra HTML attributes added verbatim — use this for HTMX, `disabled`, `data-*`, etc. |
**Variants:**
| Variant | Looks like |
|---|---|
| `default` | Filled with the primary colour — use for the main action on a page |
| `destructive` | Red — use for irreversible actions like delete |
| `outline` | Transparent with a border — use for secondary actions |
| `secondary` | Muted fill — use for tertiary actions |
| `ghost` | Invisible until hovered — use for toolbar buttons and icon actions |
| `link` | Looks like a hyperlink with an underline on hover |
**Sizes:**
| Size | Dimensions |
|---|---|
| `sm` | Compact (36px tall) — good for dense UI |
| `default` | Standard (40px tall) |
| `lg` | Large (44px tall) — good for prominent CTAs |
| `icon` | Square (40×40) — for icon-only buttons |
---
## Usage examples
## Real-world examples
### Standard actions
### Form submit button
```csharp
new Button("Save changes", type: "submit")
new Button("Cancel", variant: "outline")
new Button("Delete", variant: "destructive")
new Button("Learn more", variant: "link")
new Button("Sign in", type: "submit")
```
### Sizes
### Confirm and cancel in a dialog footer
```csharp
new Button("Small", size: "sm")
new Button("Default", size: "default")
new Button("Large", size: "lg")
new Button("⚙", size: "icon") // icon-only square button
var footer = """
{cancelButton}
{deleteButton}
""";
// Pre-render each to HTML string and embed:
string Render(IHtmxComponent c)
{
var w = new System.Buffers.ArrayBufferWriter<byte>();
c.Render(new HtmxRenderContext(w));
return System.Text.Encoding.UTF8.GetString(w.WrittenSpan);
}
new Card(
content: "<p>Are you sure you want to delete this item?</p>",
footer: Render(new Button("Cancel", variant: "outline"))
+ Render(new Button("Delete", variant: "destructive", type: "submit")))
```
### HTMX trigger
### HTMX load more button
```csharp
new Button(
"Load more",
hxAttrs: """hx-get="/items?page=2" hx-target="#item-list" hx-swap="beforeend"""")
variant: "outline",
hxAttrs: """hx-get="/items?page=2" hx-target="#item-list" hx-swap="beforeend"""")
```
### Submit button inside a form
```csharp
new Button("Sign in", variant: "default", type: "submit", size: "default")
```
### Ghost button with inline SVG icon
### Icon-only ghost button (e.g. a refresh icon in a toolbar)
```csharp
new Button(
label: """
<svg class="h-4 w-4" .../>
<span>Refresh</span>
""",
variant: "ghost")
label: "<svg class='h-4 w-4' ...fill or stroke SVG here/>",
variant: "ghost",
size: "icon")
```
### Disabled appearance (via HTML)
### Disabled state
The Button component does not have a `disabled` constructor parameter. Set it via `hxAttrs` if needed:
Button does not have a `disabled` parameter. Pass it through `hxAttrs`:
```csharp
new Button("Processing...", variant: "default", hxAttrs: "disabled aria-disabled='true'")
new Button("Processing...", hxAttrs: "disabled aria-disabled='true'")
```
---
## How it works
Button is a `<button>` element — straightforward HTML with Tailwind classes. The `hxAttrs` string is appended verbatim inside the opening `<button>` tag, so any valid HTML attribute works there. The `label` is inserted as raw HTML, which is how inline SVG icons are supported.
```
---
+55 -97
View File
@@ -1,68 +1,20 @@
# Calendar
A single-date picker rendered server-side with full client-side interaction. The selected date is stored in a hidden input and submitted as part of a form. Supports three drill-down views: days → months → years.
A date picker that lets the user click to select a single date. The selected date is stored in a hidden form input and submitted with the form. Think of it as a fancy `<input type="date">` that looks the same on every browser.
---
## HTML structure
## Quick example
```
div.calendar-root[id=cal-{id}, data-year, data-month, data-sel-day,
data-sel-month, data-sel-year, data-view="days"]
div.mb-3.flex.items-center.justify-between ← navigation row
button.cal-prev ← previous month/year/decade
button.cal-month-label ← shows "Month YYYY" / "YYYY" / decade range
button.cal-next ← next
div.cal-dow-row.grid.grid-cols-7 ← SunSat headings (hidden in month/year views)
div.cal-grid.grid.grid-cols-7 ← day/month/year cells, built by JS
input.cal-hidden-input[type=hidden, name] ← holds selected date as yyyy-MM-dd
```csharp
new Calendar(id: "dob", name: "dateOfBirth")
```
---
## CSS mechanics
| Class | Effect |
|---|---|
| `cal-day` | Base day button style (text-center, rounded, hover highlight) |
| `cal-day-selected` | Filled primary circle on the selected day |
| `cal-view-btn` | Base style for month/year selection buttons |
| `cal-view-btn-selected` | Highlighted active month or year |
| Grid is 7-column for days, 3-column for months/years | Switched via `gridTemplateColumns` inline style |
That renders a calendar starting at today's date. When the user clicks a day, the hidden input is updated and the date is included in the form submission.
---
## JavaScript (`initCalendar` in `components.js`)
State is stored entirely in `data-*` attributes on the root element. JS reads and writes these attributes — no hidden state in closures.
### `renderCalendar(root)` — three view modes
**Days view:**
1. Reads `data-year` and `data-month` (0-based, JS-style)
2. Calculates leading empty cells for the first weekday offset
3. Renders numbered `<button>` elements; adds `cal-day-selected` to the matching date
4. Each day button stores `yyyy-MM-dd` in `data-date`
5. On click: updates `data-sel-*`, highlights the new selection, writes value to `.cal-hidden-input`, fires `calendarChange` CustomEvent
**Months view:**
- Renders JanDec abbreviated buttons in a 3-column grid
- Click drills back to days view for that month
**Years view:**
- Renders 12 consecutive years (decade rounded to nearest 12)
- Click drills back to months view for that year
### Navigation buttons
- Prev/Next adjust month ± 1 (wrapping year), year ± 1, or decade ± 12 depending on `data-view`
- Month-label click drills down: days → months → years (no further drill from years)
### Re-initialization
`initAll` re-queries `.calendar-root` after `htmx:afterSwap`, so HTMX-swapped calendars work correctly.
---
## Constructor signature
## All the options
```csharp
public Calendar(
@@ -71,83 +23,89 @@ public Calendar(
DateOnly? selected = null)
```
| Parameter | Description |
| Parameter | What it does |
|---|---|
| `id` | Logical id; element gets `id="cal-{id}"` |
| `name` | Form field name for the hidden input |
| `selected` | Pre-selected date; defaults to today |
| `id` | A unique identifier for this calendar. The root element gets `id="cal-{id}"`. |
| `name` | The form field name for the hidden input. Use this name in your `Command` record on the server. |
| `selected` | The date to pre-select on render. Defaults to today. |
---
## Usage examples
## Real-world examples
### Basic date picker
```csharp
new Calendar(id: "dob", name: "dateOfBirth")
```
### Pre-selected date
```csharp
new Calendar(
id: "appointment",
name: "appointmentDate",
selected: new DateOnly(2026, 9, 15))
```
### Inside a form
### Appointment booking form
```html
<!-- Templates/BookingForm.htmx -->
<form method="post" action="/book">
$$AntiforgeryToken$$
<label class="text-sm font-medium">Pick a date</label>
$$DatePicker$$
<button type="submit">Book</button>
<form method="post" action="/book" class="space-y-6">
$$Token$$
<div>
<label class="text-sm font-medium">Pick a date</label>
$$DatePicker$$
</div>
<button type="submit">Book appointment</button>
</form>
```
```csharp
// Templates/BookingForm.htmx.cs
public IHtmxComponent DatePicker { get; }
public BookingForm(string? afToken = null)
public sealed class BookingForm : BookingFormBase
{
DatePicker = new Calendar(id: "booking", name: "bookingDate");
_afTokenData = /* antiforgery hidden input */;
}
private readonly IHtmxComponent _datePicker;
private readonly byte[] _tokenData;
protected override void RenderDatePicker(HtmxRenderContext ctx)
=> DatePicker.Render(ctx.Next());
public BookingForm(string antiforgeryToken)
{
_tokenData = $"""<input type="hidden" name="__RequestVerificationToken" value="{System.Web.HttpUtility.HtmlAttributeEncode(antiforgeryToken)}" />""".ToUtf8Bytes();
_datePicker = new Calendar(id: "booking", name: "bookingDate");
}
protected override void RenderToken(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_tokenData);
protected override void RenderDatePicker(HtmxRenderContext ctx) => _datePicker.Render(ctx.Next());
}
```
**Reading the submitted value on the server:**
**Reading the submitted date on the server:**
```csharp
public record Command(
[property: FromForm] string BookingDate // "yyyy-MM-dd"
[property: FromForm] string BookingDate // arrives as "yyyy-MM-dd"
);
// Parse:
var date = DateOnly.ParseExact(command.BookingDate, "yyyy-MM-dd");
```
### Listening for selection changes client-side
### Pre-selected date (e.g. editing an existing booking)
```csharp
new Calendar(
id: "appointment",
name: "appointmentDate",
selected: existingBooking.Date)
```
### Reacting to date selection in JavaScript
When the user picks a date, the calendar fires a `calendarChange` custom event:
```js
document.getElementById('cal-appointment').addEventListener('calendarChange', e => {
document.getElementById('cal-booking').addEventListener('calendarChange', e => {
console.log(e.detail.date); // "2026-09-15"
// update other UI elements based on selection
// update price estimates, availability, etc.
});
```
---
## Tips and tricks
## How it works
- The hidden input is always named with the `name` parameter — use this as the form field name when reading the submitted POST.
- Months are 0-based in the JS `data-*` attributes (matching `Date` object convention) but the hidden input always stores `yyyy-MM-dd` with 1-based months.
The calendar is rendered as static HTML by the server, with the current month's grid pre-built as `<button>` elements. JavaScript in `components.js` (`initCalendar`) takes over after the page loads:
- Clicking a day updates the hidden input and highlights the selected date.
- Clicking the month/year label in the navigation row drills down: days → months → years. This lets the user jump to a different year quickly without clicking through months one at a time.
- Prev/Next arrows move through the current view (month by month, year by year, or decade by decade).
All state is stored in `data-*` attributes on the root element — not in JavaScript closures. This means the calendar is fully re-initialised correctly when HTMX swaps it in.
- If you need to clear the selection client-side, set `document.querySelector('#cal-myid .cal-hidden-input').value = ''` and remove `cal-day-selected` from any button.
- To restrict available dates (e.g. no past dates), post-process the rendered buttons in JS using the `calendarChange` event or override the generated grid with a custom `initCalendar` extension.
- To restrict available dates (e.g. no past dates), post-process the rendered buttons in JS using the `calendarChange` event or override the generated grid with a custom `initCalendar` extension.
+47 -95
View File
@@ -1,68 +1,20 @@
# CalendarRange
A date-range picker. The user selects a start date and then an end date. Hover preview shades the range before the second click commits it. Fires a `rangeChange` CustomEvent on every selection change.
A date-range picker. The user clicks once to set a start date and clicks again to set an end date. While hovering, the range between start and the cursor is shaded as a preview. Great for booking forms, report filters, or anything that needs a "from / to" date pair.
---
## HTML structure
## Quick example
```
div.calr-root[id=calr-{id}, data-year, data-month, data-start, data-end, data-view="days"]
div.mb-3.flex.items-center.justify-between ← navigation row
button.calr-prev
button.calr-month-label
button.calr-next
div.cal-dow-row.grid.grid-cols-7 ← day-of-week headings
div.calr-grid.grid.grid-cols-7 ← day cells, rebuilt by JS on each interaction
span.calr-label ← "start → end" or "start → pick end date"
input.calr-hidden-start[type=hidden, name={name}-start]
input.calr-hidden-end[type=hidden, name={name}-end]
```csharp
new CalendarRange(id: "vacation", name: "vacation")
```
---
## CSS mechanics
| Class | Effect |
|---|---|
| `calr-day-start` | Filled primary circle on start date |
| `calr-day-end` | Filled primary circle on end date |
| `calr-day-mid` | Lighter primary tint for dates between start and end |
| `calr-day-plain` | Default un-selected day style |
Hover preview is applied by `updateHoverClasses` by toggling the same CSS classes without rebuilding the DOM.
This renders an empty picker. The user clicks two dates to form a range. Both dates are submitted with the form as `vacation-start` and `vacation-end`.
---
## JavaScript (`initCalendarRange` in `components.js`)
### State
Stored in `data-start` and `data-end` attributes on the root (empty string = not selected).
### Click logic (`grid.onclick`)
1. **Nothing or both selected** → set `start = clicked`, clear `end`
2. **Only start selected:**
- Click after start → set `end`, fire `rangeChange`
- Click before start → move `start` to clicked, clear `end`
- Click on start → clear both (toggle off)
3. Writes values to hidden inputs, fires `rangeChange` CustomEvent: `{ start: "yyyy-MM-dd", end: "yyyy-MM-dd" }`
4. Calls `renderRange` to rebuild grid and `updateLabel` to update the text summary
### Hover preview (`updateHoverClasses`)
- Runs on `grid.onmouseover` without rebuilding the grid — only toggles CSS classes
- Shades the tentative range from `start` to the hovered date before a click commits it
- Cleared on `grid.onmouseleave`
### View navigation
Same as Calendar: Prev/Next, month-label click drills days → months → years. `renderRange` rebuilds the grid on each navigation.
---
## Constructor signature
## All the options
```csharp
public CalendarRange(
@@ -72,46 +24,32 @@ public CalendarRange(
DateOnly? selectedEnd = null)
```
| Parameter | Description |
| Parameter | What it does |
|---|---|
| `id` | Logical id; element gets `id="calr-{id}"` |
| `name` | Base form field name; hidden inputs are `{name}-start` and `{name}-end` |
| `selectedStart` | Pre-selected start date |
| `selectedEnd` | Pre-selected end date |
| `id` | A unique identifier. The root element gets `id="calr-{id}"`. |
| `name` | Base form field name. The two hidden inputs become `{name}-start` and `{name}-end`. |
| `selectedStart` | Pre-selected start date. |
| `selectedEnd` | Pre-selected end date. |
---
## Usage examples
## Real-world examples
### Empty picker
```csharp
new CalendarRange(id: "vacation", name: "vacation")
```
### Pre-selected range
```csharp
new CalendarRange(
id: "vacation",
name: "vacation",
selectedStart: new DateOnly(2026, 7, 1),
selectedEnd: new DateOnly(2026, 7, 14))
```
### Inside a form
### Vacation request form
```html
<!-- Templates/VacationForm.htmx -->
<form method="post" action="/vacation">
$$AntiforgeryToken$$
<label class="text-sm font-medium">Select vacation dates</label>
$$RangePicker$$
<button type="submit">Request</button>
<form method="post" action="/vacation" class="space-y-6">
$$Token$$
<div>
<label class="text-sm font-medium">Select vacation dates</label>
$$RangePicker$$
</div>
<button type="submit">Submit request</button>
</form>
```
**Reading the submitted values:**
**Reading the submitted values on the server:**
```csharp
public record Command(
@@ -119,34 +57,48 @@ public record Command(
[property: FromForm] string VacationEnd // "yyyy-MM-dd"
);
// Validate they are not empty before parsing
if (string.IsNullOrEmpty(command.VacationStart) || string.IsNullOrEmpty(command.VacationEnd))
return; // user did not complete the selection
var start = DateOnly.ParseExact(command.VacationStart, "yyyy-MM-dd");
var end = DateOnly.ParseExact(command.VacationEnd, "yyyy-MM-dd");
```
### Listening for range changes client-side
### Pre-selected range (e.g. editing an existing request)
```csharp
new CalendarRange(
id: "vacation",
name: "vacation",
selectedStart: existingRequest.StartDate,
selectedEnd: existingRequest.EndDate)
```
### Reacting to selection changes in JavaScript
```js
document.getElementById('calr-vacation').addEventListener('rangeChange', e => {
console.log(e.detail.start, e.detail.end);
// e.g. "2026-07-01", "2026-07-14"
// Update a price estimate, nights count, etc.
});
```
### Showing a summary label elsewhere on the page
The `.calr-label` span inside the component automatically updates to show `start → end` or `start → pick end date`. You don't need custom JS for this.
The `.calr-label` element inside the calendar automatically updates to show `start → end` (or `start → pick end date` while mid-selection). You do not need custom JS for the label.
---
## Tips and tricks
## How it works
- Both hidden inputs are always submitted with the form. An empty string means the date was not selected — validate server-side before parsing.
- The user can clear the selection by clicking the start date again after both are set.
- To enforce a minimum range length (e.g. at least 2 nights), use the `rangeChange` event to validate client-side and show an error message.
- The calendar always shows a single month. For a two-month range view, render two `CalendarRange` components side by side and sync their state via the `rangeChange` event.
- The calendar always shows a single month. For a two-month range view, render two `CalendarRange` components side by side and sync their state via the `rangeChange` event.
The click logic follows three states:
---
1. **Nothing selected** — first click sets the start date, clears the end
2. **Start selected, no end** — next click after start sets the end and fires `rangeChange`; clicking before start moves the start; clicking on start again clears both
3. **Both selected** — any click resets and starts again from step 1
The hover preview does not rebuild the grid. It only toggles CSS classes on the day buttons, so it is fast even for long ranges.
All state is stored in `data-start` and `data-end` attributes on the root element, not in closures, so HTMX-swapped calendars re-initialise correctly.
## Complete page example
+54 -68
View File
@@ -1,36 +1,21 @@
# Card
A styled container with optional header (title + description) and footer sections. The body content is always rendered; header and footer are conditionally included.
A bordered box for grouping related content — like a physical card you might hold in your hand. It has three distinct zones: a header (title + subtitle), a body (your content), and a footer (usually actions).
---
## HTML structure
## Quick example
```
div.rounded-lg.border.border-border.bg-card.text-card-foreground.shadow-sm.{extraClasses}
div.flex.flex-col.space-y-1.5.p-6 ← header (omitted when no title/description)
h3.text-2xl.font-semibold ← title
p.text-sm.text-muted-foreground ← description
div.p-6.pt-0 ← content (always present)
{content}
div.flex.items-center.p-6.pt-0 ← footer (omitted when empty)
{footer}
```csharp
new Card(
content: "<p>Your subscription renews on July 1.</p>",
title: "Billing",
description: "Current plan: Pro")
```
---
## CSS mechanics
| Class | Effect |
|---|---|
| `bg-card text-card-foreground` | Pulls from CSS variables — dark mode works automatically |
| `rounded-lg border border-border shadow-sm` | Subtle rounded box with border and drop shadow |
| `p-6 pt-0` on content | Full padding except top (header provides the top spacing) |
| `space-y-1.5` on header | Controlled gap between title and description |
---
## Constructor signature
## All the options
```csharp
public Card(
@@ -41,77 +26,78 @@ public Card(
string extraClasses = "")
```
| Parameter | Description |
| Parameter | What it does |
|---|---|
| `content` | Raw HTML for the card body (always rendered) |
| `title` | Optional heading in the header area |
| `description` | Optional subheading below the title |
| `footer` | Optional raw HTML in the footer area |
| `extraClasses` | Additional Tailwind classes on the outer `div` |
| `content` | The body of the card always shown. Raw HTML. |
| `title` | Optional bold heading at the top of the card. |
| `description` | Optional smaller subtitle below the title. |
| `footer` | Optional section at the bottom, typically holding action buttons. Raw HTML. |
| `extraClasses` | Additional Tailwind classes on the outer `div` — useful for `max-w-sm`, `col-span-2`, etc. |
The header section (title + description) is omitted entirely when both are empty. Same for the footer.
---
## Usage examples
## Real-world examples
### Simple content card
```csharp
new Card(content: "<p>Your subscription renews on July 1.</p>")
```
### Card with title and description
### A stats card on a dashboard
```csharp
new Card(
content: "<p>Manage your billing details and invoices.</p>",
title: "Billing",
description: "Your current plan: Pro")
title: "Total Users",
description: "All registered accounts",
content: $"<p class=\"text-4xl font-bold\">{userCount:N0}</p>")
```
### Card with footer actions
### A confirmation card with footer buttons
Buttons and other components need to be pre-rendered to HTML strings when used inside `content` or `footer`:
```csharp
string ToHtml(IHtmxComponent c)
{
var w = new System.Buffers.ArrayBufferWriter<byte>();
c.Render(new HtmxRenderContext(w));
return System.Text.Encoding.UTF8.GetString(w.WrittenSpan);
}
new Card(
content: "<p>Are you sure you want to cancel your account?</p>",
title: "Delete account",
description: "This action cannot be undone.",
footer: """
<button class="inline-flex h-9 rounded-md border border-input px-4 text-sm mr-2">Cancel</button>
<button class="inline-flex h-9 rounded-md bg-destructive text-destructive-foreground px-4 text-sm">Delete</button>
""")
content: "<p>All your data will be permanently removed.</p>",
footer: ToHtml(new Button("Cancel", variant: "outline"))
+ ToHtml(new Button("Delete", variant: "destructive", type: "submit")))
```
### Constrained width
### A grid of cards
Cards are most commonly placed in a CSS grid in the page template:
```html
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
$$Card1$$
$$Card2$$
$$Card3$$
</div>
```
### Constrained width (e.g. a login card)
```csharp
new Card(
content: "<p>Hello world</p>",
title: "Welcome",
content: "...login form HTML...",
title: "Welcome back",
description: "Sign in to your account",
extraClasses: "max-w-sm mx-auto")
```
### Embedding a component as content
```csharp
// Render a Badge to a string then embed in the card body
var writer = new System.Buffers.ArrayBufferWriter<byte>();
new Badge("Active").Render(new HtmxRenderContext(writer));
var badgeHtml = System.Text.Encoding.UTF8.GetString(writer.WrittenSpan);
new Card(
content: $"<p class='mb-2'>Status:</p>{badgeHtml}",
title: "Account")
```
---
## Tips and tricks
## How it works
- `content`, `footer`, title, and description are inserted as raw HTML — HTML-encode any user-supplied strings before passing them in.
- Use `extraClasses` to set max-width, margin, or custom background without subclassing.
- If you need a completely custom header layout, omit `title` and `description` and build the header HTML in `content`, adding `p-6` padding yourself.
- Cards can be placed in a CSS grid: `<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">`.
- Cards can be placed in a CSS grid: `<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">`.
Card uses CSS variables (`bg-card`, `text-card-foreground`, `border-border`) which automatically adapt to dark mode. The header and footer sections are skipped entirely in the rendered HTML when they are not needed — they do not leave empty divs behind.
All strings passed to `content` and `footer` are raw HTML. HTML-encode any user-supplied values before passing them in.
---
+60 -74
View File
@@ -1,55 +1,10 @@
# Checkbox
A styled checkbox input with an optional visible label. Uses the `accent-primary` Tailwind class so the checkmark color follows your primary theme color.
A styled checkbox with an optional text label. Use it in forms when you want the user to opt in or out of something — "Remember me", "I agree to the terms", or selecting items in a list.
---
## HTML structure
```
div.flex.items-center.space-x-2
input[type=checkbox, id, name, value, class, $$Checked$$]
label[for={id}] ← omitted when label is empty
{label text}
```
---
## CSS mechanics
| Class | Effect |
|---|---|
| `accent-primary` | Checkmark color follows the `--color-primary` CSS variable |
| `h-4 w-4 rounded` | Consistent 16×16 size with slightly rounded corners |
| `cursor-pointer` | Pointer cursor on label |
| `text-sm font-medium leading-none peer-disabled:opacity-70` | Standard label styling |
---
## Constructor signature
```csharp
public Checkbox(
string id,
string label = "",
string name = "",
string value = "true",
bool @checked = false)
```
| Parameter | Description |
|---|---|
| `id` | Element id and the `for` attribute on the label |
| `label` | Visible text next to the checkbox; omit for a standalone checkbox |
| `name` | Form field name (required when used in a form) |
| `value` | Submitted value when checked (default: `"true"`) |
| `checked` | Pre-checked state |
---
## Usage examples
### Basic opt-in checkbox
## Quick example
```csharp
new Checkbox(
@@ -58,7 +13,54 @@ new Checkbox(
name: "newsletter")
```
### Pre-checked
---
## All the options
```csharp
public Checkbox(
string id,
string label = "",
string name = "",
string value = "true",
bool @checked = false)
```
| Parameter | What it does |
|---|---|
| `id` | The element id. Also used by the `<label for="...">` so clicking the label toggles the box. |
| `label` | Visible text next to the checkbox. Leave empty for a standalone checkbox with no label. |
| `name` | Form field name — required if you want the value submitted with the form. |
| `value` | The string that gets submitted when the box is checked. Defaults to `"true"`. |
| `checked` | Pre-tick the checkbox on render. |
---
## Real-world examples
### Terms and conditions on a registration form
```csharp
new Checkbox(
id: "terms",
label: "I agree to the terms of service",
name: "terms",
value: "accepted")
```
Reading it on the server:
```csharp
public record Command(
[property: FromForm] string? Terms = null // null when unchecked, "accepted" when checked
);
bool agreedToTerms = command.Terms == "accepted";
```
> **Important:** HTML forms only submit checkboxes that are *checked*. An unchecked checkbox sends nothing — the field is simply absent. Always use a nullable string (`string?`) or give it a default of `null`.
### Remember me (pre-ticked by default)
```csharp
new Checkbox(
@@ -68,45 +70,29 @@ new Checkbox(
checked: true)
```
### No visible label
### Multiple checkboxes in a preferences form
```csharp
new Checkbox(id: "select-all", name: "selectAll")
new Checkbox(id: "email-alerts", label: "Email alerts", name: "emailAlerts")
new Checkbox(id: "sms-alerts", label: "SMS alerts", name: "smsAlerts")
new Checkbox(id: "weekly-summary", label: "Weekly summary", name: "weeklySummary")
```
### Custom submitted value
```csharp
new Checkbox(
id: "agree",
label: "I agree to the terms",
name: "terms",
value: "accepted")
```
### Reading in a form handler
Server-side:
```csharp
public record Command(
[property: FromForm] string? Newsletter = null, // null when unchecked
[property: FromForm] string? RememberMe = null
[property: FromForm] string? EmailAlerts = null,
[property: FromForm] string? SmsAlerts = null,
[property: FromForm] string? WeeklySummary = null
);
bool wantsNewsletter = command.Newsletter == "true";
bool rememberUser = command.RememberMe == "true";
```
> Note: Unchecked checkboxes are not included in form data. Always use a nullable string or a default value of `null`.
---
## Tips and tricks
## How it works
- Because HTML forms only submit checked checkboxes, pair a checkbox with a hidden input of the same name and value `"false"` if you need the unchecked state explicitly in your command.
- The label `for` attribute ties to the `id`, so clicking the label text toggles the checkbox — always set `id`.
- If you need multi-select (select multiple rows in a table), use the same `name` for all checkboxes; they will be submitted as a comma-separated list or multiple values depending on form binding.
- `accent-primary` is a modern CSS property — all current browsers support it.
- `accent-primary` is a modern CSS property — all current browsers support it.
Checkbox renders a native `<input type="checkbox">` styled with `accent-primary` so the checkmark colour matches your theme's primary colour. The label is a separate `<label for="{id}">` element — clicking anywhere on the label text toggles the checkbox.
---
+51 -92
View File
@@ -1,53 +1,32 @@
# Dialog
A modal dialog using the native HTML `<dialog>` element. Content is organized into optional title, description, body, and footer sections. Open/close is handled by client-side JS via delegated click events on `data-dialog-open` and `data-dialog-close` attributes.
A modal pop-up window that appears on top of the page. Think of it like a small piece of paper sliding onto the desk — the rest of the page dims and you have to deal with the dialog before you can go back to work.
Opening and closing is handled entirely with `data-dialog-open` and `data-dialog-close` HTML attributes — no custom JavaScript needed.
---
## HTML structure
## Quick example
```
dialog[id, class=...]
div.dialog-panel.relative.bg-background.p-6.rounded-lg.shadow-xl.w-full.max-w-md...
button.absolute.top-4.right-4[data-dialog-close={id}] ← × close button
h2.text-lg.font-semibold ← title (omitted when empty)
p.text-sm.text-muted-foreground.mt-1 ← description (omitted when empty)
div.mt-4 ← body content
{content}
div.mt-6.flex.justify-end.gap-2 ← footer (omitted when empty)
{footer}
```csharp
new Dialog(
id: "about-dialog",
title: "About this app",
content: "<p>Version 1.0 — built with .NET 10.</p>",
footer: """<button data-dialog-close="about-dialog">Close</button>""")
```
---
Then somewhere on the page, add a trigger:
## CSS mechanics
```html
<button data-dialog-open="about-dialog">About</button>
```
| Class | Effect |
|---|---|
| `dialog::backdrop` (in `input.css`) | Semi-transparent black overlay behind the dialog |
| `animate-in fade-in-0 zoom-in-95` | CSS entry animation when dialog opens |
| `max-w-md w-full` | Responsive: full width on small screens, capped at `md` |
| `overflow-y-auto max-h-[90vh]` | Scrollable body for tall content |
That's it. No JavaScript needed in your templates.
---
## JavaScript (delegated clicks in `components.js`)
Set up once on `document` and works for all dialogs on the page, including those HTMX-swapped in.
### Open
Any element with `data-dialog-open="myDialogId"` calls `document.getElementById('myDialogId').showModal()`.
### Close
Any element with `data-dialog-close="myDialogId"` calls `document.getElementById('myDialogId').close()`.
The `×` close button inside the dialog panel already has `data-dialog-close` set to the dialog's id.
Clicking the `::backdrop` (outside the panel) also closes the dialog — the click handler checks whether the click target is the `<dialog>` element itself.
---
## Constructor signature
## All the options
```csharp
public Dialog(
@@ -58,96 +37,76 @@ public Dialog(
string footer = "")
```
| Parameter | Description |
| Parameter | What it does |
|---|---|
| `id` | Element id — must be unique per page; also used by `data-dialog-open` |
| `content` | Raw HTML for the dialog body |
| `title` | Optional heading at the top of the panel |
| `description` | Optional subheading below the title |
| `footer` | Optional raw HTML for the bottom button row |
| `id` | A unique identifier. Used both on the `<dialog>` element and in `data-dialog-open`. |
| `content` | The body of the dialog. Raw HTML. |
| `title` | Optional bold heading at the top of the dialog panel. |
| `description` | Optional smaller text below the title. |
| `footer` | Optional button row at the bottom. Raw HTML. |
The title, description, and footer sections are omitted entirely from the HTML when not provided.
---
## Usage examples
## Real-world examples
### Simple information dialog
### Confirmation before a destructive action
```csharp
// In the page component:
Dialog = new Dialog(
id: "about-dialog",
title: "About BeepBoop",
description: "A fast AOT-safe HTMX framework.",
content: "<p>Version 1.0 — built with ❤️ and .NET 10.</p>",
footer: """<button data-dialog-close="about-dialog" class="...">Close</button>""");
```
Trigger button anywhere on the page:
Place the dialog in the page template, then trigger it from a button:
```html
<button data-dialog-open="about-dialog" class="...">About</button>
<!-- Templates/ItemsPage.htmx -->
$$DeleteDialog$$
<!-- ... rest of page ... -->
<button data-dialog-open="confirm-delete">Delete item</button>
```
### Confirmation dialog
```csharp
new Dialog(
// Templates/ItemsPage.htmx.cs
_deleteDialog = new Dialog(
id: "confirm-delete",
title: "Delete item",
content: "<p>This action cannot be undone.</p>",
footer: """
<button data-dialog-close="confirm-delete" class="...">Cancel</button>
<button hx-delete="/items/42" hx-confirm="" data-dialog-close="confirm-delete"
<button data-dialog-close="confirm-delete">Cancel</button>
<button hx-delete="/items/42" data-dialog-close="confirm-delete"
class="bg-destructive text-destructive-foreground ...">
Delete
</button>
""")
""");
```
### HTMX-powered content reload
### Dialog that loads content on demand
Use HTMX's `revealed` trigger to load the dialog body only when it opens:
```csharp
new Dialog(
id: "user-detail",
title: "User details",
content: """<div hx-get="/users/42/detail" hx-trigger="revealed"></div>""")
```
The `revealed` trigger fires when the dialog becomes visible, loading content on demand.
### Closing from outside the dialog
### Embedding inside a page slot
Any element anywhere on the page can close a dialog by setting `data-dialog-close`:
```html
<!-- MyPage.htmx -->
$$DeleteDialog$$
<button data-dialog-open="confirm-delete" class="...">Delete</button>
```
```csharp
public IHtmxComponent DeleteDialog { get; }
public MyPage()
{
DeleteDialog = new Dialog(
id: "confirm-delete",
title: "Confirm deletion",
content: "<p>Are you sure?</p>",
footer: """<button data-dialog-close="confirm-delete">Cancel</button>""");
}
protected override void RenderDeleteDialog(HtmxRenderContext ctx)
=> DeleteDialog.Render(ctx.Next());
<button data-dialog-close="confirm-delete">Never mind</button>
```
---
## Tips and tricks
## How it works
- The `id` is used both on the `<dialog>` element and in `data-dialog-open`/`data-dialog-close` — keep it unique per page.
- The `×` close button is always rendered; `data-dialog-close` on footer buttons is optional but improves UX.
- Use the native `<dialog>` `close` event for any cleanup needed after dismissal: `document.getElementById('id').addEventListener('close', fn)`.
- Dialog content, title, description, and footer are raw HTML — HTML-encode user-supplied values.
- For dialogs that load content via HTMX, place the HTMX attributes on a child div inside `content` rather than on the `<dialog>` element itself to avoid interfering with the dialog open/close lifecycle.
- For dialogs that load content via HTMX, place the HTMX attributes on a child div inside `content` rather than on the `<dialog>` element itself to avoid interfering with the dialog open/close lifecycle.
Dialog uses the native HTML `<dialog>` element with `showModal()`. A backdrop (the dark overlay) comes from the browser's built-in `::backdrop` pseudo-element, styled in `input.css`.
JavaScript in `components.js` listens for clicks anywhere on the page. If the clicked element has `data-dialog-open`, it calls `showModal()` on the matching dialog. If it has `data-dialog-close`, it calls `close()`. Clicking outside the dialog panel (on the backdrop) also closes it.
Because the listener is on `document`, dialogs that are HTMX-swapped in work automatically without any re-initialisation.
All `content` and `footer` strings are raw HTML — HTML-encode any user-supplied values before passing them in.
---
+60 -81
View File
@@ -1,66 +1,10 @@
# DropdownMenu
A button that reveals a floating list of links or actions when clicked. Closes when the user clicks outside or presses Escape. Positioned below the trigger by default.
A button that, when clicked, opens a small floating menu of links. Like a folder label on a filing cabinet — pull it and a list of options drops down. Closes automatically when you click elsewhere or press Escape.
---
## HTML structure
```
div.relative.inline-block ← anchor for absolute positioning
{trigger rendered inline} ← any IHtmxComponent (usually a Button)
div.dropdown-menu.absolute... ← the floating panel; hidden by default
div.w-48.rounded-md.border.bg-popover.shadow-md.p-1
a.dropdown-item ← link item
hr.dropdown-separator ← separator (when isSeparator=true)
```
---
## CSS mechanics
| Class | Effect |
|---|---|
| `hidden` (initial) on panel | Hides the dropdown until toggled by JS |
| `absolute z-50` | Floats above surrounding content |
| `top-full mt-1` | Placed below the trigger with a small gap |
| `right-0` / `left-0` | Controlled by the `position` parameter |
| `dropdown-item` | `flex items-center px-2 py-1.5 text-sm rounded hover:bg-accent cursor-pointer` |
---
## JavaScript (delegated click in `components.js`)
Set up once on `document` — works for HTMX-swapped dropdowns.
**Open / close toggle:**
1. Click on the trigger element (`[data-dropdown-trigger]`) → toggle `.hidden` on the sibling `.dropdown-menu`
2. Click outside the dropdown root → close all open dropdowns
3. `Escape` keydown → close all open dropdowns
4. Click on a `.dropdown-item` link → close the parent dropdown and follow the link
---
## Constructor signature
```csharp
public DropdownMenu(
IHtmxComponent trigger,
IEnumerable<(string Label, string Href, bool IsSeparator)> items,
string position = "right")
```
| Parameter | Description |
|---|---|
| `trigger` | Any `IHtmxComponent` — shown as the visible toggle element |
| `items` | Menu items; `IsSeparator=true` renders an `<hr>` (Label/Href ignored) |
| `position` | `"right"` (default) aligns panel right edge; `"left"` aligns left edge |
---
## Usage examples
### User menu
## Quick example
```csharp
new DropdownMenu(
@@ -69,24 +13,70 @@ new DropdownMenu(
{
("Profile", "/profile", false),
("Settings", "/settings", false),
("", "", true), // separator
("", "", true), // separator line
("Sign out", "/logout", false),
})
```
### Icon-button dropdown
---
## All the options
```csharp
public DropdownMenu(
IHtmxComponent trigger,
IEnumerable<(string Label, string Href, bool IsSeparator)> items,
string position = "right")
```
| Parameter | What it does |
|---|---|
| `trigger` | The visible button that opens the menu. Any `IHtmxComponent` — usually a `Button`. |
| `items` | The list of menu items. Each is a `(Label, Href, IsSeparator)` tuple. |
| `position` | `"right"` aligns the menu to the right edge of the trigger (default). `"left"` aligns it to the left. |
**Item tuple fields:**
| Field | What it does |
|---|---|
| `Label` | The text shown in the menu. |
| `Href` | The URL to navigate to when clicked. |
| `IsSeparator` | Set to `true` to render a divider line instead of a link. `Label` and `Href` are ignored. |
---
## Real-world examples
### User account menu in the header
```csharp
new DropdownMenu(
trigger: new Button("My account", variant: "ghost"),
items: new[]
{
("Profile", "/profile", false),
("Billing", "/billing", false),
("", "", true),
("Sign out", "/logout", false),
})
```
### Row action menu in a table (three-dot icon button)
```csharp
new DropdownMenu(
trigger: new Button("⋯", size: "icon", variant: "ghost"),
items: new[]
{
("Edit", "/items/42/edit", false),
("Delete", "/items/42/delete", false),
})
("Edit", $"/items/{item.Id}/edit", false),
("Delete", $"/items/{item.Id}/delete", false),
},
position: "left") // avoid overflow near the right edge of the screen
```
### Left-aligned dropdown (useful when near the right edge of the viewport)
### Left-aligned menu (near right side of viewport)
Use `position: "left"` when the trigger is close to the right edge of the screen to prevent the menu from clipping off-screen:
```csharp
new DropdownMenu(
@@ -95,26 +85,15 @@ new DropdownMenu(
position: "left")
```
### HTMX action items
Items use `<a href>` — if you need HTMX requests, override by building the HTML manually:
```csharp
// Pass a synthetic IHtmxComponent for trigger and use a raw slot override
// for items that need hx-delete / hx-post, since items only support href links.
// Alternatively, use a Dialog for confirmation dialogs linked from the dropdown.
```
---
## Tips and tricks
## How it works
- The `trigger` is any `IHtmxComponent` — pass a `Button`, an `Avatar`, or any custom component.
- All items are rendered as `<a href>` links. For actions that should POST/DELETE, either route them through normal GET links to a form redirect, or pair them with a confirmation Dialog.
- For a context menu that appears at a table row, pass `new Button("⋯", size: "icon", variant: "ghost")` as the trigger.
- Setting `position: "left"` prevents the dropdown from overflowing the right side of the viewport when the trigger is near the right edge.
- Multiple dropdowns on the same page are handled independently — clicking one will close others.
- Multiple dropdowns on the same page are handled independently — clicking one will close others.
The menu panel is always present in the HTML but hidden with a `hidden` class. When the trigger is clicked, JavaScript toggles the `hidden` class to show it. Clicking anything outside — or pressing Escape — adds `hidden` back.
Because the click listener is attached to `document`, dropdown menus that are HTMX-swapped in work automatically.
All items are rendered as `<a href="...">` links. If you need an action that POSTs data (like a delete), the cleanest approach is to route it through a confirmation Dialog.
---
+51 -64
View File
@@ -1,35 +1,23 @@
# FileInput
A styled file upload field with an optional visible label and description. Supports `accept` MIME types, multiple file selection, and HTMX attributes for server-driven interactions.
A styled file upload field. Use it when you need users to attach files to a form — profile pictures, documents, CSV imports, and so on.
---
## HTML structure
## Quick example
```
div.flex.flex-col.gap-1.5
label[for={id}].text-sm.font-medium ← omitted when label is empty
{label text}
input[type=file, id, name, accept, class, $$Multiple$$, $$HxAttrs$$]
p.text-sm.text-muted-foreground ← omitted when description is empty
{description text}
```csharp
new FileInput(
id: "avatar",
name: "avatar",
accept: "image/*",
label: "Profile picture",
description: "PNG, JPG or GIF up to 2 MB")
```
---
## CSS mechanics
| Class | Effect |
|---|---|
| `file:mr-4 file:py-2 file:px-4 file:rounded-md` | Styles the browser's "Choose file" button via `::file-selector-button` |
| `file:border-0 file:bg-primary file:text-primary-foreground` | Gives the file button the primary color |
| `file:text-sm file:font-semibold file:cursor-pointer` | Consistent text treatment |
| `hover:file:bg-primary/90` | Slight darkening on hover |
| `w-full rounded-md border border-input bg-background text-sm` | Full-width field with border |
---
## Constructor signature
## All the options
```csharp
public FileInput(
@@ -43,71 +31,70 @@ public FileInput(
string hxAttrs = "")
```
| Parameter | Description |
| Parameter | What it does |
|---|---|
| `id` | Element id and label `for` target |
| `name` | Form field name |
| `accept` | MIME types or file extensions, e.g. `"image/*"` or `".pdf,.docx"` |
| `multiple` | Allows selecting more than one file |
| `label` | Visible label above the field |
| `description` | Helper text below the field |
| `extraClasses` | Additional Tailwind classes on the input |
| `hxAttrs` | Verbatim HTMX / data attributes appended to the input |
| `id` | The element id. Also used by the `<label for="...">`. |
| `name` | Form field name — required if you want the file submitted with the form. |
| `accept` | MIME types or extensions to filter the picker, e.g. `"image/*"` or `".pdf,.docx"`. Does not validate server-side. |
| `multiple` | Allow selecting more than one file at a time. |
| `label` | Visible text label above the field. |
| `description` | Hint text below the field (e.g. "Max 5 MB"). |
| `extraClasses` | Additional Tailwind classes on the `<input>` element. |
| `hxAttrs` | Extra HTML attributes appended verbatim (HTMX, `data-*`, etc.). |
---
## Usage examples
## Real-world examples
### Basic single file
### Multiple document attachments
```csharp
new FileInput(
id: "avatar",
name: "avatar",
accept: "image/*",
label: "Profile picture",
description: "PNG, JPG or GIF up to 2 MB")
```
### Multiple files
```csharp
new FileInput(
id: "attachments",
name: "attachments",
accept: ".pdf,.docx,.xlsx",
multiple: true,
label: "Attachments",
id: "attachments",
name: "attachments",
accept: ".pdf,.docx,.xlsx",
multiple: true,
label: "Attachments",
description: "Select one or more documents")
```
### HTMX auto-upload on change
### Auto-upload on file selection (HTMX)
```csharp
new FileInput(
id: "import-csv",
name: "csv",
accept: ".csv",
label: "Import CSV",
hxAttrs: """hx-post="/import" hx-encoding="multipart/form-data" hx-target="#result" hx-trigger="change"""")
id: "import-csv",
name: "csv",
accept: ".csv",
label: "Import CSV",
hxAttrs: """hx-post="/import" hx-encoding="multipart/form-data" hx-target="#result" hx-trigger="change"""")
```
### No label
When using HTMX for file uploads, always include `hx-encoding="multipart/form-data"` — HTMX does not infer it from the input type.
### Reading uploaded files in a handler
```csharp
new FileInput(id: "doc", name: "document", accept: ".pdf")
public static IResult Handle(HttpContext ctx, IFormFile? avatar)
{
if (avatar is null || avatar.Length == 0)
return Results.BadRequest("No file uploaded");
// validate file type server-side (accept= only filters in the browser)
var allowed = new[] { "image/jpeg", "image/png", "image/gif" };
if (!allowed.Contains(avatar.ContentType))
return Results.BadRequest("Invalid file type");
using var stream = avatar.OpenReadStream();
// save the file...
return Results.Ok();
}
```
---
## Tips and tricks
## How it works
- `accept` filters in the browser's file picker dialog but does not validate server-side — always validate the uploaded file type in your handler.
- For HTMX file uploads set `hx-encoding="multipart/form-data"` in `hxAttrs`; HTMX does not infer encoding from the input type.
- Multiple files are bound as a list: `IFormFileCollection` or `List<IFormFile>` in the handler. `FromForm` attribute on the command record field is required.
- To show a preview of the selected image before upload, add a small JS snippet that listens to the `change` event and sets `src` on an `<img>` element via `URL.createObjectURL(e.target.files[0])`.
- `extraClasses` is added to the `<input>` element, not the wrapper `<div>` — use it for overriding width, borders, or custom ring colors.
- `extraClasses` is added to the `<input>` element, not the wrapper `<div>` — use it for overriding width, borders, or custom ring colors.
FileInput renders a standard `<input type="file">`. The browser's built-in "Choose file" button is styled using `::file-selector-button` CSS pseudo-element (via Tailwind's `file:` prefix) so it matches the rest of the UI.
---
+39 -52
View File
@@ -1,36 +1,23 @@
# Input
A styled text input with optional label and description. Supports all standard HTML input types and HTMX attributes.
A styled single-line text field with an optional label and hint text below it. The workhorse of any form — use it for names, emails, passwords, search queries, or any other short text value.
---
## HTML structure
## Quick example
```
div.flex.flex-col.gap-1.5
label[for={id}].text-sm.font-medium ← omitted when label is empty
{label text}
input[type, id, name, placeholder, class, $$HxAttrs$$]
p.text-sm.text-muted-foreground ← omitted when description is empty
{description text}
```csharp
new Input(
id: "email",
name: "email",
inputType: "email",
placeholder: "you@example.com",
label: "Email address")
```
---
## CSS mechanics
| Class | Effect |
|---|---|
| `flex h-10 w-full rounded-md border border-input bg-background` | Full-width 40px height field with border |
| `px-3 py-2 text-sm` | Inner padding and text size |
| `ring-offset-background` | Focus ring offset matches the page background |
| `focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2` | Keyboard-visible focus ring |
| `disabled:cursor-not-allowed disabled:opacity-50` | Disabled state |
| `placeholder:text-muted-foreground` | Placeholder inherits muted color |
---
## Constructor signature
## All the options
```csharp
public Input(
@@ -44,22 +31,22 @@ public Input(
string hxAttrs = "")
```
| Parameter | Description |
| Parameter | What it does |
|---|---|
| `id` | Element id and label `for` target |
| `name` | Form field name |
| `inputType` | HTML type attribute: `text` / `email` / `password` / `number` / `search` / `tel` / `url` / `date` / `time` |
| `placeholder` | Placeholder text |
| `label` | Visible label above the field |
| `description` | Helper text below the field |
| `extraClasses` | Additional Tailwind classes on the input |
| `hxAttrs` | Verbatim HTMX / data attributes appended to the input |
| `id` | Element id. Also used by the `<label for="...">` so clicking the label focuses the input. |
| `name` | Form field name — required if you want the value submitted with the form. |
| `inputType` | HTML type: `text`, `email`, `password`, `number`, `search`, `tel`, `url`, `date`, `time`. |
| `placeholder` | Greyed-out hint inside the field before the user types. |
| `label` | Visible text label above the field. |
| `description` | Small hint text below the field (e.g. "At least 8 characters"). |
| `extraClasses` | Additional Tailwind classes on the `<input>` element. |
| `hxAttrs` | Extra HTML attributes appended verbatim. Use for HTMX, `min`/`max`, `autocomplete`, etc. |
---
## Usage examples
## Real-world examples
### Email and password fields
### Login form fields
```csharp
new Input(
@@ -78,7 +65,18 @@ new Input(
description: "At least 8 characters")
```
### Search with HTMX live search
Reading on the server:
```csharp
public record Command(
[property: FromForm] string Email,
[property: FromForm] string Password
);
```
### Live search with HTMX
This fires a GET request 300ms after the user stops typing and swaps the results in:
```csharp
new Input(
@@ -89,7 +87,9 @@ new Input(
hxAttrs: """hx-get="/search" hx-target="#results" hx-trigger="keyup changed delay:300ms"""")
```
### Number input with constraints (via extraClasses / hxAttrs)
### Number input with min/max constraints
Extra HTML attributes like `min` and `max` can be passed through `hxAttrs`:
```csharp
new Input(
@@ -100,24 +100,11 @@ new Input(
hxAttrs: """min="1" max="100" step="1"""")
```
### URL input
---
```csharp
new Input(
id: "website",
name: "websiteUrl",
inputType: "url",
placeholder: "https://example.com",
label: "Website",
description: "Include https://")
```
## How it works
### Reading in a form handler
```csharp
public record Command(
[property: FromForm] string Email,
[property: FromForm] string Password
Input renders a `<div>` wrapper containing an optional `<label>`, the `<input>`, and an optional description `<p>`. The label and description elements are omitted entirely from the HTML when not provided. The `hxAttrs` string is appended verbatim inside the `<input>` tag, so any valid HTML attribute can be passed through it.
);
```
+40 -57
View File
@@ -1,38 +1,20 @@
# Pagination
A page navigation row with Prev/Next links and numbered page buttons. The current page is highlighted. Links are built from a URL pattern.
A row of numbered page links — Previous, 1, 2, 3…, Next. Use it at the bottom of a list or table when there are too many items to show all at once. You give it the current page number, the total number of pages, and a URL pattern; it builds all the links automatically.
---
## HTML structure
## Quick example
```
nav[aria-label=Pagination].flex.items-center.justify-center.gap-1
a.pag-btn[href=prevUrl, aria-label=Previous] ← disabled styling when current=1
svg (chevron-left)
a.pag-btn[href=url] ← one per page in the visible window
{pageNumber} ← current page has pag-btn-active class
span.pag-ellipsis ← rendered when pages are skipped
a.pag-btn[href=nextUrl, aria-label=Next] ← disabled styling when current=total
svg (chevron-right)
```csharp
new Pagination(current: 3, total: 10, urlPattern: "/blog?page={0}")
```
---
## CSS mechanics
| Class | Effect |
|---|---|
| `pag-btn` | `inline-flex h-9 w-9 items-center justify-center rounded-md border border-input bg-background text-sm hover:bg-accent` |
| `pag-btn-active` | `bg-primary text-primary-foreground border-primary hover:bg-primary/90` |
| `pag-ellipsis` | `inline-flex h-9 w-9 items-center justify-center text-sm text-muted-foreground` |
| `pointer-events-none opacity-50` | Applied to Prev when `current == 1`, to Next when `current == total` |
The visible page window is limited to 7 buttons maximum. For large page counts the component collapses interior pages into ellipsis spans, keeping first page, last page, and the pages immediately around `current` always visible.
This renders a navigation row with links to pages 110 (with ellipsis for interior pages) and the Previous/Next arrows.
---
## Constructor signature
## All the options
```csharp
public Pagination(
@@ -41,60 +23,61 @@ public Pagination(
string urlPattern)
```
| Parameter | Description |
| Parameter | What it does |
|---|---|
| `current` | 1-based current page number |
| `total` | Total number of pages |
| `urlPattern` | URL template with `{0}` replaced by the page number, e.g. `"/items?page={0}"` |
| `current` | The currently active page. 1-based (the first page is `1`). |
| `total` | The total number of pages. |
| `urlPattern` | A URL with `{0}` where the page number goes. E.g. `"/items?page={0}"`. |
The visible page window is at most 7 buttons. For large page counts, interior pages collapse into ellipsis (`…`) while the first page, last page, and pages close to `current` stay visible.
---
## Usage examples
## Real-world examples
### Basic pagination
### Basic list with pagination
```csharp
new Pagination(current: 3, total: 10, urlPattern: "/blog?page={0}")
```html
<!-- Templates/BlogPage.htmx -->
<div class="space-y-6 mb-10">$$Posts$$</div>
$$Pager$$
```
### Preserving query parameters
```csharp
// Build the URL pattern from the current request
var query = HttpUtility.ParseQueryString(Request.QueryString.ToString());
query["page"] = "{0}";
var pattern = "/search?" + query.ToString();
new Pagination(current: page, total: totalPages, urlPattern: pattern)
// Templates/BlogPage.htmx.cs
_pager = new Pagination(
current: page,
total: totalPages,
urlPattern: "/blog?page={0}");
```
### HTMX-powered pagination (swap content without full navigation)
### Preserving filters and sort order across pages
The links are standard `<a>` tags. To intercept them with HTMX, use `hx-boost` on the container or wrap in a boosted `<div>`:
Build the URL pattern to include any query parameters that should survive page navigation:
```csharp
var urlPattern = $"/users?role={role}&sort={sort}&page={{0}}";
new Pagination(current: page, total: totalPages, urlPattern: urlPattern)
```
> Note the double braces `{{0}}` to produce a literal `{0}` after string interpolation.
### HTMX-powered pagination (no full page reload)
Wrap the pagination (and the content it controls) in a `hx-boost` container:
```html
<div hx-boost="true" hx-target="#item-list" hx-push-url="true">
$$Pagination$$
<div id="item-list">$$Items$$</div>
$$Pager$$
</div>
```
### Single page (hides naturally)
```csharp
// When total == 1, Prev and Next are both disabled and only "1" is rendered.
new Pagination(current: 1, total: 1, urlPattern: "/items?page={0}")
```
---
## Tips and tricks
## How it works
- The `urlPattern` uses `string.Format`-style `{0}` — do not use `{page}` or other named placeholders.
- Page numbers are 1-based throughout — the first page is page `1`.
- When `total` is 0 or negative the component renders nothing — guard `total > 1` in the page if you want to hide it entirely when there is only one page.
- To preserve sort order or filters across pages, include those values in the `urlPattern` query string.
- For very small page counts (23 pages), all page buttons are shown with no ellipsis.
- For very small page counts (23 pages), all page buttons are shown with no ellipsis.
All links are plain `<a href="...">` elements — no JavaScript required. The URL for each page is built by calling `string.Format(urlPattern, pageNumber)`. When `current == 1`, the Previous link is styled as disabled (pointer-events removed, opacity reduced); same for Next when `current == total`.
---
+31 -50
View File
@@ -1,98 +1,79 @@
# Progress
A horizontal progress bar. Value is clamped to 0100. Three sizes control the bar height.
A horizontal bar that fills from left to right to show how complete something is. Use it for upload progress, onboarding checklists, storage usage, or anything that has a percentage value.
---
## HTML structure
## Quick example
```
div.w-full.bg-secondary.rounded-full.overflow-hidden.{size class}
div.bg-primary.rounded-full.h-full.transition-all[style="width: {value}%"]
```csharp
new Progress(value: 72)
```
---
## CSS mechanics
| Class | Effect |
|---|---|
| `bg-secondary` | Neutral track color |
| `bg-primary` | Filled indicator color |
| `rounded-full overflow-hidden` | Pill-shaped track; fills also become pill-shaped |
| `transition-all` | Smooth animation when `width` changes |
**Size classes applied to the outer track:**
| Size | Class | Height |
|---|---|---|
| `sm` | `h-1.5` | 6 px |
| `default` | `h-2.5` | 10 px |
| `lg` | `h-4` | 16 px |
---
## Constructor signature
## All the options
```csharp
public Progress(int value, string size = "default")
```
| Parameter | Description |
| Parameter | What it does |
|---|---|
| `value` | Fill percentage; clamped to 0100 |
| `size` | `"sm"` / `"default"` / `"lg"` |
| `value` | How filled the bar is, from 0 to 100. Values outside this range are clamped automatically. |
| `size` | Height of the bar: `"sm"` (6px), `"default"` (10px), or `"lg"` (16px). |
---
## Usage examples
## Real-world examples
### Inline usage
### Disk usage inside a Card
```csharp
new Progress(value: 72)
new Progress(value: 40, size: "sm")
new Progress(value: 100, size: "lg")
```
// Pre-render the Progress bar to HTML
var w = new System.Buffers.ArrayBufferWriter<byte>();
new Progress(value: usedPercent, size: "lg").Render(new HtmxRenderContext(w));
var progressHtml = System.Text.Encoding.UTF8.GetString(w.WrittenSpan);
### Inside a Card
```csharp
new Card(
title: "Disk usage",
title: "Storage",
content: $"""
<div class="mb-2 flex justify-between text-sm">
<span>Used</span>
<span>{used} GB / {total} GB</span>
<span>{usedGb} GB / {totalGb} GB</span>
</div>
{progressHtml}
""")
```
(Pre-render the `Progress` to a string using `HtmxRenderContext` and `ArrayBufferWriter<byte>`.)
### Live progress bar (HTMX polling)
### HTMX live update
Wrap the component in a polling `<div>` that swaps the fragment every second:
```html
<div id="progress-bar"
hx-get="/job/42/progress"
<div id="job-progress"
hx-get="/jobs/42/progress"
hx-trigger="every 1s"
hx-swap="outerHTML">
$$ProgressBar$$
</div>
```
The endpoint returns a partial re-render of this fragment with the updated `value`.
The handler returns a fresh render of the component with the updated value. The `transition-all` CSS on the fill makes the change smooth.
### Three sizes side by side
```csharp
new Progress(value: 40, size: "sm") // compact, good for table rows
new Progress(value: 60) // standard
new Progress(value: 80, size: "lg") // prominent
```
---
## Tips and tricks
## How it works
- Values below 0 are treated as 0; values above 100 are treated as 100 — no manual clamping needed.
- Use `size: "sm"` for compact UI areas such as table rows.
- To animate progress smoothly, let `transition-all` do the work: re-render the component via HTMX on a polling interval or push updates via SSE.
- For an indeterminate spinner, use `Skeleton` instead (it has `animate-pulse` built in).
- For an indeterminate spinner, use `Skeleton` instead (it has `animate-pulse` built in).
Progress is two nested `<div>` elements. The outer one is the grey track; the inner one is the filled bar. The fill width is set as an inline `style="width: {value}%"` so no JavaScript is required. The `transition-all` class makes the bar animate smoothly when the value changes via an HTMX swap.
---
+55 -50
View File
@@ -1,37 +1,26 @@
# RadioGroup
A group of radio buttons sharing the same `name` attribute. Supports horizontal or vertical layout. One option can be pre-selected.
A set of radio buttons where only one option can be selected at a time. Use it when you want the user to pick exactly one value from a short list — pricing plans, delivery options, account types.
---
## HTML structure
## Quick example
```
div.flex.flex-col.gap-1.5
label.text-sm.font-medium ← group label (omitted when empty)
{label}
div.flex.{direction}.gap-3 ← flex-col or flex-row
label.flex.items-center.gap-2.cursor-pointer ← one per option
input[type=radio, name, value, class, $$Checked$$]
span.text-sm
{option label}
```csharp
new RadioGroup(
name: "plan",
label: "Select a plan",
options: new[]
{
("free", "Free", true), // pre-selected
("pro", "Pro", false),
("teams", "Teams", false),
})
```
---
## CSS mechanics
| Class | Effect |
|---|---|
| `accent-primary` | Radio circle color follows `--color-primary` CSS variable |
| `h-4 w-4` | 16×16 radio circle |
| `cursor-pointer` | Pointer cursor on the label |
| `flex-col` (default) | Stacks options vertically |
| `flex-row` | Places options side by side |
---
## Constructor signature
## All the options
```csharp
public RadioGroup(
@@ -41,32 +30,47 @@ public RadioGroup(
string direction = "flex-col")
```
| Parameter | Description |
| Parameter | What it does |
|---|---|
| `name` | Shared `name` attribute for all radio inputs in the group |
| `options` | List of `(Value, Label, Selected)` tuples |
| `label` | Optional visible group heading above the options |
| `direction` | `"flex-col"` (vertical, default) or `"flex-row"` (horizontal) |
| `name` | The shared form field name for all radio buttons in the group. |
| `options` | The list of choices. Each is a `(Value, Label, Selected)` tuple. |
| `label` | Optional heading displayed above the options. |
| `direction` | `"flex-col"` stacks options vertically (default). `"flex-row"` places them side by side. |
**Option tuple fields:**
| Field | What it does |
|---|---|
| `Value` | The string submitted when this option is selected. |
| `Label` | The text shown next to the radio button. |
| `Selected` | Pre-select this option on render. Only one should be `true`. |
---
## Usage examples
## Real-world examples
### Vertical list
### Pricing plan selector
```csharp
new RadioGroup(
name: "plan",
label: "Select a plan",
name: "plan",
label: "Choose your plan",
options: new[]
{
("free", "Free", true),
("pro", "Pro", false),
("teams", "Teams", false),
("free", "Free — up to 3 projects", true),
("pro", "Pro — unlimited projects", false),
("enterprise", "Enterprise — custom pricing", false),
})
```
### Horizontal inline options
Reading on the server:
```csharp
public record Command([property: FromForm] string Plan);
// command.Plan == "free" | "pro" | "enterprise"
```
### Horizontal size selector
```csharp
new RadioGroup(
@@ -75,26 +79,18 @@ new RadioGroup(
direction: "flex-row",
options: new[]
{
("sm", "S", false),
("md", "M", true),
("lg", "L", false),
("sm", "S", false),
("md", "M", true),
("lg", "L", false),
("xl", "XL", false),
})
```
### Reading in a form handler
```csharp
public record Command([property: FromForm] string Plan);
// command.Plan == "free" | "pro" | "teams"
```
### Dynamic options from database
### Options built dynamically from the database
```csharp
var options = categories
.Select((cat, i) => (cat.Slug, cat.Name, i == 0))
.Select((cat, i) => (cat.Slug, cat.Name, i == 0)) // first option pre-selected
.ToArray();
new RadioGroup(name: "category", label: "Category", options: options)
@@ -102,6 +98,15 @@ new RadioGroup(name: "category", label: "Category", options: options)
---
## How it works
Each option is a `<label>` element containing a native `<input type="radio">` and a `<span>` with the label text. Because the `<input>` is inside the `<label>`, clicking anywhere on the label text selects the option. All radio buttons in the group share the same `name` attribute — the browser ensures only one can be selected at a time.
The radio dot colour follows your primary theme colour via `accent-primary`.
```
---
## Tips and tricks
- Only one option in the group can have `Selected = true`; if multiple are marked selected the last one wins (standard HTML behavior).
+66 -62
View File
@@ -1,37 +1,28 @@
# Select
A styled `<select>` dropdown. Supports a pre-selected value, optional label, and optional description text. HTMX attributes can be added.
A styled dropdown that lets the user pick one option from a list. Use it for things like country selection, category filters, or anything where the user chooses from a fixed set of values.
---
## HTML structure
## Quick example
```
div.flex.flex-col.gap-1.5
label[for={id}].text-sm.font-medium ← omitted when label is empty
{label}
select[id, name, class, $$HxAttrs$$]
option[value, $$Selected$$] ← one per option; selected="selected" when matched
{display}
p.text-sm.text-muted-foreground ← omitted when description is empty
{description}
```csharp
new Select(
id: "country",
name: "country",
label: "Country",
options: new[]
{
("us", "United States"),
("gb", "United Kingdom"),
("ca", "Canada"),
},
selectedValue: "us")
```
---
## CSS mechanics
| Class | Effect |
|---|---|
| `flex h-10 w-full rounded-md border border-input bg-background` | Full-width 40px select field |
| `px-3 py-2 text-sm` | Inner padding and text size |
| `focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring` | Keyboard focus ring |
| `disabled:cursor-not-allowed disabled:opacity-50` | Disabled state |
| `bg-background` | Ensures the dropdown matches the page background in dark mode |
---
## Constructor signature
## All the options
```csharp
public Select(
@@ -45,59 +36,72 @@ public Select(
string hxAttrs = "")
```
| Parameter | Description |
| Parameter | What it does |
|---|---|
| `id` | Element id and label `for` target |
| `options` | List of `(Value, Display)` tuples |
| `selectedValue` | Pre-selected option value; `null` = no pre-selection (first option shown) |
| `name` | Form field name |
| `label` | Optional visible label |
| `description` | Optional helper text below the field |
| `extraClasses` | Additional Tailwind classes on the `<select>` element |
| `hxAttrs` | Verbatim HTMX / data attributes |
| `id` | The element id. Also used by the `<label for="...">`. |
| `options` | The list of choices. Each is a `(Value, Display)` tuple. |
| `selectedValue` | The `Value` of the option to pre-select. Leave `null` to show the first option. |
| `name` | Form field name — required if you want the value submitted. |
| `label` | Visible text label above the dropdown. |
| `description` | Small hint text below the field. |
| `extraClasses` | Additional Tailwind classes on the `<select>` element. |
| `hxAttrs` | Extra HTML attributes appended verbatim — use for HTMX and `data-*`. |
---
## Usage examples
## Real-world examples
### Country selector
### Category filter that reloads the list on change
```csharp
new Select(
id: "country",
name: "country",
label: "Country",
id: "category",
name: "category",
label: "Filter by category",
options: categories.Select(c => (c.Slug, c.Name)),
selectedValue: currentCategory,
hxAttrs: """hx-get="/products" hx-target="#product-list" hx-trigger="change"""")
```
### Dynamic options from the database (with current value pre-selected)
```csharp
var options = roles.Select(r => (r.Id.ToString(), r.Name));
new Select(
id: "role",
name: "roleId",
label: "Role",
options: options,
selectedValue: user.RoleId.ToString())
```
Reading on the server:
```csharp
public record Command([property: FromForm] string RoleId);
```
### Simple yes/no choice
```csharp
new Select(
id: "active",
name: "isActive",
label: "Status",
options: new[]
{
("us", "United States"),
("gb", "United Kingdom"),
("ca", "Canada"),
("au", "Australia"),
("true", "Active"),
("false", "Inactive"),
},
selectedValue: "us")
selectedValue: user.IsActive ? "true" : "false")
```
### Dynamic options from data
---
```csharp
var options = categories.Select(c => (c.Slug, c.Name));
## How it works
new Select(
id: "category",
name: "category",
label: "Category",
options: options,
selectedValue: existingCategory)
```
### HTMX on-change reload
```csharp
new Select(
id: "region",
name: "region",
label: "Region",
options: regions,
Select renders a standard `<select>` element — no custom dropdown JavaScript. The browser's native dropdown is used, which is the most accessible and reliable approach. The selected option is matched by `Value` and has `selected="selected"` set on render.
hxAttrs: """hx-get="/cities" hx-target="#city-select" hx-trigger="change" hx-include="[name='region']"""")
```
+31 -37
View File
@@ -1,36 +1,19 @@
# Separator
A thin divider line. Renders as a horizontal `<hr>` or a vertical bar depending on orientation.
A thin dividing line. Use it to visually separate sections of a page or items in a toolbar. Like a ruled line on a notepad, it gives your layout breathing room and clarity.
---
## HTML structure
## Quick example
**Horizontal:**
```
hr.border-t.border-border.my-4.{extraClasses}
```
**Vertical:**
```
span.inline-block.border-l.border-border.mx-2.h-4.{extraClasses}
```csharp
new Separator() // horizontal rule
new Separator(orientation: "vertical") // vertical bar
```
---
## CSS mechanics
| Class | Effect |
|---|---|
| `border-t border-border` | Top border in the theme's border color (horizontal) |
| `border-l border-border` | Left border in the theme's border color (vertical) |
| `my-4` | Default vertical margin for horizontal separators |
| `mx-2` | Default horizontal margin for vertical separators |
| `h-4` | 16px height for vertical separators |
---
## Constructor signature
## All the options
```csharp
public Separator(
@@ -38,46 +21,57 @@ public Separator(
string extraClasses = "")
```
| Parameter | Description |
| Parameter | What it does |
|---|---|
| `orientation` | `"horizontal"` (default) or `"vertical"` |
| `extraClasses` | Additional Tailwind classes on the element |
| `orientation` | `"horizontal"` (default) renders an `<hr>`. `"vertical"` renders an inline bar. |
| `extraClasses` | Additional Tailwind classes to override spacing or colour. |
---
## Usage examples
## Real-world examples
### Horizontal divider
### Between sections on a settings page
```csharp
new Separator()
```html
<h2 class="text-lg font-semibold">Account</h2>
<p class="text-sm text-muted-foreground">Manage your account details.</p>
$$Sep1$$
<h2 class="text-lg font-semibold">Notifications</h2>
```
### Vertical divider in a flex toolbar
```csharp
_sep1 = new Separator();
```
### Vertical bar in a text editor toolbar
```html
<div class="flex items-center gap-2">
<button>Bold</button>
$$VertSep$$
$$Sep$$
<button>Italic</button>
$$VertSep$$
$$Sep$$
<button>Underline</button>
</div>
```
```csharp
var VertSep = new Separator(orientation: "vertical");
_sep = new Separator(orientation: "vertical");
```
### Custom margin
### More or less spacing
```csharp
new Separator(extraClasses: "my-8") // extra vertical space
new Separator(extraClasses: "my-0 mt-2") // override default margin
new Separator(extraClasses: "my-8") // extra breathing room above and below
new Separator(extraClasses: "my-2") // tighter spacing
```
---
## How it works
A horizontal separator is an `<hr>` element with a top border. A vertical separator is an inline `<span>` with a left border and a fixed height of 16px. Both use `border-border` which follows the theme's CSS variable and adapts to dark mode automatically.
## Tips and tricks
- The horizontal `Separator` is an `<hr>` element — it carries semantic meaning as a thematic break. Use it between content sections.
+48 -56
View File
@@ -1,78 +1,58 @@
# Skeleton
An animated loading placeholder. Use it in place of real content while data is being fetched or rendered asynchronously. The animation communicates to the user that content is loading.
An animated grey placeholder that pulsates while real content is loading. Think of it as a rough pencil sketch of your UI — it shows the user where something will appear so the page feels responsive even before the data is ready.
---
## HTML structure
## Quick example
```
div.animate-pulse.rounded-md.bg-muted.{classes}
```csharp
new Skeleton("h-4 w-3/4") // a loading line of text
new Skeleton("h-10 w-full") // a loading input field
new Skeleton("rounded-full h-12 w-12") // a loading avatar
```
---
## CSS mechanics
| Class | Effect |
|---|---|
| `animate-pulse` | Tailwind's built-in fade-in/out animation (1.5s loop) |
| `bg-muted` | Neutral muted background color from the theme |
| `rounded-md` | Slightly rounded corners |
| User-supplied `classes` | Control size and shape (e.g. `h-4 w-32`, `h-10 w-full`, `rounded-full h-12 w-12`) |
---
## Constructor signature
## All the options
```csharp
public Skeleton(string classes = "")
```
| Parameter | Description |
| Parameter | What it does |
|---|---|
| `classes` | Tailwind classes controlling size, shape, and spacing |
| `classes` | Tailwind classes that control the size and shape of the placeholder. |
There are no other parameters. The component itself is just an animated `<div>` — you shape it entirely through CSS classes.
---
## Usage examples
## Real-world examples
### Text line placeholders
```csharp
new Skeleton("h-4 w-3/4 mb-2")
new Skeleton("h-4 w-1/2")
```
### Avatar placeholder
```csharp
new Skeleton("rounded-full h-12 w-12")
```
### Card skeleton loader
```csharp
new Card(
content: """
<div class="flex items-center gap-4">
<!-- Render each Skeleton eagerly to a string or use slot injection -->
</div>
<div class="mt-4 space-y-2">
</div>
""")
```
### Full-width block placeholder
```csharp
new Skeleton("h-10 w-full")
```
### HTMX skeleton swap pattern
### A card loading state (avatar + two text lines)
```html
<div class="flex items-center gap-4 p-6">
$$AvatarSkeleton$$
<div class="space-y-2 flex-1">
$$Line1$$
$$Line2$$
</div>
</div>
```
```csharp
_avatarSkeleton = new Skeleton("rounded-full h-10 w-10");
_line1 = new Skeleton("h-4 w-1/2");
_line2 = new Skeleton("h-4 w-3/4");
```
### HTMX swap: show skeleton immediately, replace with real content
Render the skeleton into a slot. HTMX fires immediately on page load and swaps it with the real content:
```html
<!-- Shown immediately; HTMX replaces with real content -->
<div id="user-list"
hx-get="/users"
hx-trigger="load"
@@ -81,13 +61,25 @@ new Skeleton("h-10 w-full")
</div>
```
The page renders the skeleton on initial load; the HTMX request fires immediately and replaces it once the data arrives.
The skeleton appears instantly; the data loads in the background and replaces it.
### A full table loading state
```csharp
// Stack five skeleton rows to simulate a loading table
var rows = string.Concat(Enumerable.Range(0, 5).Select(_ =>
{
var w = new System.Buffers.ArrayBufferWriter<byte>();
new Skeleton("h-8 w-full mb-2").Render(new HtmxRenderContext(w));
return System.Text.Encoding.UTF8.GetString(w.WrittenSpan);
}));
```
---
## Tips and tricks
## How it works
- Multiple `Skeleton` elements stacked in a `div.space-y-2` create a convincing text-block placeholder.
Skeleton is a single `<div>` with `animate-pulse` (Tailwind's built-in pulsing animation) and `bg-muted`. You control the shape entirely through the `classes` parameter — use `h-*` and `w-*` for size, and `rounded-full` for circular shapes like avatars.
- `rounded-full` makes a circle — useful for avatar skeletons. Combine with equal `h-*` and `w-*` values.
- The `classes` parameter replaces the default empty string — provide complete size + spacing classes.
- For table skeletons, render a `Table` with each cell containing a Skeleton HTML string (pre-rendered to a string via `ArrayBufferWriter<byte>`).
+45 -53
View File
@@ -1,33 +1,23 @@
# Slider
A styled `<input type="range">` with optional label and description. Supports min/max/step/value and HTMX attributes.
A draggable range control. Use it when you want the user to pick a numeric value within a range — volume, brightness, price range, font size.
---
## HTML structure
## Quick example
```
div.flex.flex-col.gap-1.5
label[for={id}].text-sm.font-medium ← omitted when label is empty
{label}
input[type=range, id, name, min, max, step, value, class, $$HxAttrs$$]
p.text-sm.text-muted-foreground ← omitted when description is empty
{description}
```csharp
new Slider(
id: "volume",
name: "volume",
label: "Volume")
```
---
## CSS mechanics
| Class | Effect |
|---|---|
| `w-full h-2 rounded-lg appearance-none cursor-pointer accent-primary` | Full-width, pill-shaped track; thumb follows primary color |
| `bg-secondary` | Track fill color |
| `accent-primary` | Thumb and active track color follows `--color-primary` |
Defaults to a 0100 range with a starting value of 50.
---
## Constructor signature
## All the options
```csharp
public Slider(
@@ -43,60 +33,62 @@ public Slider(
string hxAttrs = "")
```
| Parameter | Description |
| Parameter | What it does |
|---|---|
| `id` | Element id and label `for` target |
| `name` | Form field name |
| `min` | Minimum value (default: 0) |
| `max` | Maximum value (default: 100) |
| `step` | Increment step (default: 1) |
| `value` | Initial value (default: 50) |
| `label` | Optional visible label |
| `description` | Optional helper text |
| `extraClasses` | Additional Tailwind classes on the input |
| `hxAttrs` | Verbatim HTMX / data attributes |
| `id` | The element id. Also used by `<label for="...">`. |
| `name` | Form field name. |
| `min` | Lowest selectable value. |
| `max` | Highest selectable value. |
| `step` | How much the value changes per tick. |
| `value` | Starting position of the thumb. |
| `label` | Visible text label above the slider. |
| `description` | Small hint text below the slider. |
| `extraClasses` | Additional Tailwind classes on the `<input>`. |
| `hxAttrs` | Extra HTML attributes appended verbatim. |
---
## Usage examples
## Real-world examples
### Basic 0100 slider
### Brightness setting (stepped)
```csharp
new Slider(
id: "volume",
name: "volume",
label: "Volume")
id: "brightness",
name: "brightness",
min: 10,
max: 100,
step: 10,
value: 70,
label: "Brightness",
description: "10 to 100")
```
### Fixed range with step
```csharp
new Slider(
id: "brightness",
name: "brightness",
min: 10,
max: 100,
step: 10,
value: 70,
label: "Brightness",
description: "10100")
```
### Live HTMX update
### Font size with live HTMX update
```csharp
new Slider(
id: "fontSize",
name: "fontSize",
min: 12,
max: 24,
max: 32,
value: 16,
label: "Font size",
hxAttrs: """hx-post="/settings/font-size" hx-trigger="change" hx-include="[name='fontSize']"""")
hxAttrs: """hx-post="/settings/font-size" hx-trigger="change"""")
```
### Reading in a form handler
### Reading the value in a form handler
```csharp
public record Command([property: FromForm] int Volume);
// command.Volume == 0..100
```
---
## How it works
Slider renders a native `<input type="range">`. The thumb and active track colour follow your primary theme colour via `accent-primary`. No JavaScript is needed — the browser handles the drag interaction natively.
```csharp
public record Command([property: FromForm] int Volume);
+34 -62
View File
@@ -1,46 +1,22 @@
# Switch
A toggle switch (on/off). Renders as a hidden `<input type="checkbox">` with a styled track and thumb driven by JavaScript. Fires no custom events — read the underlying checkbox value in form submissions.
An on/off toggle that looks like a physical light switch. Use it for settings where the effect is immediate or where a simple checked/unchecked checkbox would feel too plain — "Enable notifications", "Dark mode", "Maintenance mode".
---
## HTML structure
## Quick example
```
label[for={id}].flex.items-center.gap-3.cursor-pointer
div.switch-root.relative.w-11.h-6.rounded-full ← outer track
input[type=checkbox, id, name, class="sr-only", $$Checked$$] ← hidden; holds true state
div.switch-thumb.absolute.top-0.5.left-0.5... ← animated thumb
span.text-sm.select-none ← label text (omitted when empty)
{label}
```csharp
new Switch(
id: "notifications",
label: "Enable notifications",
name: "enableNotifications",
isChecked: true)
```
---
## CSS mechanics
| Class | Effect |
|---|---|
| `sr-only` | Hides the real checkbox visually but keeps it accessible |
| `switch-root` | `bg-input` (off) / `bg-primary` (on) — toggled by JS adding `switch-on` class |
| `switch-thumb` | `h-5 w-5 rounded-full bg-background shadow transition-transform` |
| `translate-x-5` | Added to thumb by JS when switch is on (slides right) |
---
## JavaScript (`initSwitch` in `components.js`)
Runs on `DOMContentLoaded` and `htmx:afterSwap`.
**Per-switch initialization:**
1. Guard `_switchInit` prevents double-binding
2. Sync visual state from the hidden checkbox `checked` property on load
3. On `label` click: toggle `checked`, toggle `switch-on` on the track, toggle `translate-x-5` on the thumb
---
## Constructor signature
## All the options
```csharp
public Switch(
@@ -50,54 +26,50 @@ public Switch(
bool isChecked = false)
```
| Parameter | Description |
| Parameter | What it does |
|---|---|
| `id` | Element id for the hidden checkbox; label's `for` attribute |
| `label` | Optional visible text to the right of the toggle |
| `name` | Form field name for the hidden checkbox |
| `isChecked` | Initial on/off state |
| `id` | The element id for the hidden checkbox. |
| `label` | Optional text shown to the right of the toggle. |
| `name` | Form field name — required if you want the value submitted. |
| `isChecked` | Whether the switch is on by default. |
---
## Usage examples
## Real-world examples
### Basic on/off toggle
### Preferences form with multiple toggles
```csharp
new Switch(
id: "notifications",
label: "Enable notifications",
name: "enableNotifications",
isChecked: true)
new Switch(id: "email-alerts", label: "Email alerts", name: "emailAlerts", isChecked: prefs.EmailAlerts)
new Switch(id: "push-notifs", label: "Push notifications", name: "pushNotifs", isChecked: prefs.PushNotifs)
new Switch(id: "weekly-summary", label: "Weekly digest", name: "weeklySummary", isChecked: prefs.WeeklySummary)
```
### Toggle without label
```csharp
new Switch(id: "darkMode", name: "darkMode")
```
### Reading in a form handler
Reading on the server:
```csharp
public record Command(
[property: FromForm] string? EnableNotifications = null
[property: FromForm] string? EmailAlerts = null, // null = off
[property: FromForm] string? PushNotifs = null,
[property: FromForm] string? WeeklySummary = null
);
bool notificationsOn = command.EnableNotifications != null;
bool emailAlerts = command.EmailAlerts != null;
```
> Like all checkboxes, an unchecked switch is not included in the form submission. Use `null` as the default in your command record.
> **Important:** Like a checkbox, an unchecked switch is not included in the form submission. Always use `string?` (nullable) with a default of `null`.
### HTMX auto-save on change
### Toggle without a label (e.g. in a table row)
```csharp
// The hidden checkbox is named, so wrap in a form or use hx-include:
new Switch(
id: "maintenance",
name: "maintenanceMode",
label: "Maintenance mode",
isChecked: currentState)
new Switch(id: $"active-{user.Id}", name: "isActive", isChecked: user.IsActive)
```
---
## How it works
Switch is a styled `<label>` wrapping a hidden `<input type="checkbox">`. JavaScript in `components.js` listens for clicks and animates the visible track and thumb. The hidden checkbox holds the actual state and is what gets submitted with a form. Because it is a real checkbox under the hood, the form submission behaviour is identical to a plain Checkbox component.
```
```html
+40 -59
View File
@@ -1,45 +1,25 @@
# Table
A styled HTML data table with a header row, optional caption, optional footer row, and one or more data rows. All data is plain strings.
A styled HTML table with a header row, data rows, and optional caption and footer. Use it when you have a list of items with multiple columns — user lists, order history, product inventories.
---
## HTML structure
## Quick example
```
div.overflow-auto.rounded-md.border.border-border
table.w-full.text-sm.caption-bottom
caption.mt-4.text-sm.text-muted-foreground ← omitted when empty
{caption}
thead
tr.border-b.bg-muted/50
th.h-12.px-4.text-left.font-medium ← one per header
{header}
tbody
tr.border-b.hover:bg-muted/40 ← one per row; last row has no border
td.p-4 ← one per cell; raw HTML
{cell}
tfoot ← omitted when empty
tr
td[colspan=N].p-4.text-muted-foreground
{footer}
```csharp
new Table(
headers: new[] { "Name", "Email", "Role" },
rows: users.Select(u => new[]
{
System.Web.HttpUtility.HtmlEncode(u.DisplayName ?? ""),
System.Web.HttpUtility.HtmlEncode(u.Email),
u.Role
}))
```
---
## CSS mechanics
| Class | Effect |
|---|---|
| `overflow-auto` on wrapper | Horizontal scroll on small screens |
| `bg-muted/50` on header | Slightly tinted header row |
| `hover:bg-muted/40` on data rows | Subtle hover highlight |
| `border-b` on rows | Row separator lines |
| `caption-bottom` | Caption appears below the table |
---
## Constructor signature
## All the options
```csharp
public Table(
@@ -49,33 +29,27 @@ public Table(
string footer = "")
```
| Parameter | Description |
| Parameter | What it does |
|---|---|
| `headers` | Column heading strings |
| `rows` | Each inner `IEnumerable<string>` is one row; cells are raw HTML |
| `caption` | Optional caption below the table |
| `footer` | Optional footer cell (spans all columns) |
| `headers` | Column heading strings. |
| `rows` | Each inner collection is one table row. Each string in it is a cell. Cells are raw HTML. |
| `caption` | Optional summary text displayed below the table. |
| `footer` | Optional footer text that spans all columns. |
> **HTML safety:** Cell values are inserted as raw HTML. Always use `System.Web.HttpUtility.HtmlEncode()` on any user-supplied strings before passing them in.
---
## Usage examples
## Real-world examples
### Basic data table
```csharp
new Table(
headers: new[] { "Name", "Email", "Role" },
rows: users.Select(u => new[] { u.DisplayName ?? "", u.Email, u.Role }))
```
### With caption and footer
### Table with a count caption and footer note
```csharp
new Table(
headers: new[] { "Product", "Price", "Stock" },
rows: products.Select(p => new[]
{
p.Name,
System.Web.HttpUtility.HtmlEncode(p.Name),
$"${p.Price:F2}",
p.Stock.ToString()
}),
@@ -83,14 +57,15 @@ new Table(
footer: "Prices include VAT")
```
### Cells with HTML content (e.g. badges)
### Status column with a Badge
Pre-render the badge to an HTML string and embed it in the cell:
```csharp
// Pre-render a Badge to HTML string
string ActiveBadge()
string RenderBadge(string label, string variant = "default")
{
var buf = new System.Buffers.ArrayBufferWriter<byte>();
new Badge("Active").Render(new HtmxRenderContext(buf));
new Badge(label, variant).Render(new HtmxRenderContext(buf));
return System.Text.Encoding.UTF8.GetString(buf.WrittenSpan);
}
@@ -99,28 +74,34 @@ new Table(
rows: users.Select(u => new[]
{
System.Web.HttpUtility.HtmlEncode(u.DisplayName ?? ""),
u.IsActive ? ActiveBadge() : ""
u.IsActive ? RenderBadge("Active", "default") : RenderBadge("Inactive", "secondary")
}))
```
### With action buttons per row
### Row actions with HTMX edit button
```csharp
string EditBtn(string id) => $"""
<button hx-get="/users/{id}/edit" hx-target="#modal" class="text-sm underline">Edit</button>
""";
string EditLink(string id) =>
$"""<button hx-get="/users/{id}/edit" hx-target="#modal" class="text-sm underline">Edit</button>""";
new Table(
headers: new[] { "Name", "Actions" },
headers: new[] { "Name", "" },
rows: users.Select(u => new[]
{
System.Web.HttpUtility.HtmlEncode(u.DisplayName ?? ""),
EditBtn(u.Id!)
EditLink(u.Id!)
}))
```
---
## How it works
Table wraps a standard `<table>` in an `overflow-auto` container so it scrolls horizontally on small screens. Header cells use `<th>` and data cells use `<td>`. The `caption` is rendered inside a `<caption>` element below the table; the `footer` spans all columns in a `<tfoot>` row.
```
---
## Tips and tricks
- Cells are raw HTML — HTML-encode any user-supplied string values before inserting them to prevent XSS.
+62 -63
View File
@@ -1,50 +1,27 @@
# Tabs
A tabbed interface. One tab panel is visible at a time. The active tab has a highlighted style; all others are hidden. Client-side JS switches panels without a server round-trip.
A row of clickable tabs that each reveal different content. Only one tab is visible at a time. Think of it like a filing cabinet with labelled dividers — you flip between sections without leaving the page.
---
## HTML structure
## Quick example
```
div[id].tabs-root
div.tabs-list.flex.gap-1.border-b.mb-4 ← tab button strip
button.tabs-trigger[data-tab={tabId}] ← one per tab; ACTIVE/INACTIVE variant
{label}
div.tabs-panel[data-tab={tabId}] ← one per tab; hidden or visible
{content}
```csharp
new Tabs(
id: "settings-tabs",
tabs: new[]
{
("general", "General", "<p>General settings here.</p>"),
("security", "Security", "<p>Password and 2FA here.</p>"),
("billing", "Billing", "<p>Payment details here.</p>"),
})
```
---
## CSS mechanics
| Class | Effect |
|---|---|
| `tabs-trigger` | `px-4 py-2 text-sm font-medium rounded-t-md -mb-px` |
| Active trigger | `bg-background border border-b-0 border-border text-foreground` |
| Inactive trigger | `text-muted-foreground hover:text-foreground hover:bg-muted/40` |
| `tabs-panel[hidden]` | `display: none` via the HTML `hidden` attribute |
The first tab is active by default.
---
## JavaScript (`initTabs` in `components.js`)
Runs on `DOMContentLoaded` and `htmx:afterSwap`.
**Per-instance initialization:**
1. Guard `_tabsInit` prevents double-binding
2. Reads all `.tabs-trigger` and `.tabs-panel` elements within the root
3. Activates the first tab on init (removes `hidden`, applies active class)
4. On trigger click:
- Deactivate all panels (set `hidden`, downgrade trigger class to inactive)
- Activate the clicked panel by matching `data-tab` attribute
- Apply active class to the clicked trigger
---
## Constructor signature
## All the options
```csharp
public Tabs(
@@ -52,52 +29,74 @@ public Tabs(
IEnumerable<(string Id, string Label, string Content)> tabs)
```
| Parameter | Description |
| Parameter | What it does |
|---|---|
| `id` | Root element id — must be unique per page if multiple Tabs are rendered |
| `tabs` | List of `(Id, Label, Content)` tuples; `Id` must be unique within this instance |
| `id` | A unique identifier for this tabs widget. Required if you have more than one `Tabs` on the same page. |
| `tabs` | The list of tabs. Each is a `(Id, Label, Content)` tuple. |
**Tab tuple fields:**
| Field | What it does |
|---|---|
| `Id` | A unique identifier for this tab within the widget. Used internally to link the trigger to the panel. |
| `Label` | The text shown on the tab button. |
| `Content` | The HTML content shown when this tab is active. |
---
## Usage examples
## Real-world examples
### Simple tabbed content
### User profile page with tabbed sections
```csharp
new Tabs(
id: "settings-tabs",
id: "profile-tabs",
tabs: new[]
{
("general", "General", "<p>General settings content here.</p>"),
("security", "Security", "<p>Security settings content here.</p>"),
("billing", "Billing", "<p>Billing details here.</p>"),
("overview", "Overview", $"<p>Joined {user.CreatedAt:MMMM yyyy}</p>"),
("activity", "Activity", activityHtml),
("settings", "Settings", settingsFormHtml),
})
```
### HTML-rich content in a tab
### Tab containing a full component
Pre-render inner components to HTML strings before embedding them:
```csharp
new Tabs(
id: "code-tabs",
tabs: new[]
{
("csharp", "C#", "<pre><code>var x = 42;</code></pre>"),
("fsharp", "F#", "<pre><code>let x = 42</code></pre>"),
("vb", "VB.NET", "<pre><code>Dim x As Integer = 42</code></pre>"),
})
```
### Embedding a full component in a tab
```csharp
// Pre-render the inner component to HTML string
var buf = new System.Buffers.ArrayBufferWriter<byte>();
new Table(headers: cols, rows: data).Render(new HtmxRenderContext(buf));
var tableHtml = System.Text.Encoding.UTF8.GetString(buf.WrittenSpan);
string Render(IHtmxComponent c)
{
var buf = new System.Buffers.ArrayBufferWriter<byte>();
c.Render(new HtmxRenderContext(buf));
return System.Text.Encoding.UTF8.GetString(buf.WrittenSpan);
}
new Tabs(
id: "report",
tabs: new[]
{
("table", "Table", Render(new Table(headers: cols, rows: rows))),
("summary", "Summary", summaryHtml),
})
```
### Code samples in multiple languages
```csharp
new Tabs(
id: "code-example",
tabs: new[]
{
("csharp", "C#", "<pre><code>var x = 42;</code></pre>"),
("fsharp", "F#", "<pre><code>let x = 42</code></pre>"),
})
```
---
## How it works
All tab panels are present in the HTML on page load. JavaScript in `components.js` hides all but the first using the HTML `hidden` attribute. When a tab button is clicked, its matching panel has `hidden` removed and all others get it added back. No server request is made — this is pure client-side switching.
{
("summary", "Summary", "<p>High level numbers.</p>"),
("detail", "Detail", tableHtml),
+39 -51
View File
@@ -1,37 +1,23 @@
# Textarea
A styled multi-line text input with optional label, description, default value, and HTMX attributes.
A styled multi-line text input. Use it when you need more than a single line of text — comments, descriptions, notes, bio fields, or message composition.
---
## HTML structure
## Quick example
```
div.flex.flex-col.gap-1.5
label[for={id}].text-sm.font-medium ← omitted when label is empty
{label}
textarea[id, name, placeholder, rows, class, $$HxAttrs$$]
{defaultValue}
p.text-sm.text-muted-foreground ← omitted when description is empty
{description}
```csharp
new Textarea(
id: "comment",
name: "comment",
placeholder: "Write a comment…",
label: "Comment",
rows: 5)
```
---
## CSS mechanics
| Class | Effect |
|---|---|
| `flex min-h-[80px] w-full rounded-md border border-input bg-background` | Full-width field with minimum height |
| `px-3 py-2 text-sm` | Inner padding and text size |
| `focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring` | Keyboard focus ring |
| `disabled:cursor-not-allowed disabled:opacity-50` | Disabled state |
| `placeholder:text-muted-foreground` | Muted placeholder text |
| `resize-y` | Allows vertical resize only |
---
## Constructor signature
## All the options
```csharp
public Textarea(
@@ -46,45 +32,37 @@ public Textarea(
int rows = 3)
```
| Parameter | Description |
| Parameter | What it does |
|---|---|
| `id` | Element id and label `for` target |
| `name` | Form field name |
| `placeholder` | Placeholder text |
| `label` | Optional visible label |
| `description` | Optional helper text below the field |
| `defaultValue` | Pre-filled content of the textarea |
| `extraClasses` | Additional Tailwind classes on the textarea |
| `hxAttrs` | Verbatim HTMX / data attributes |
| `rows` | Number of visible rows (default: 3) |
| `id` | The element id. Also used by the `<label for="...">`. |
| `name` | Form field name. |
| `placeholder` | Greyed-out hint inside the field when it is empty. |
| `label` | Visible text label above the field. |
| `description` | Small hint below the field (e.g. character limits). |
| `defaultValue` | Pre-filled content. |
| `extraClasses` | Additional Tailwind classes on the `<textarea>`. |
| `hxAttrs` | Extra HTML attributes appended verbatim. |
| `rows` | How many lines tall the field is initially. Default is 3. |
---
## Usage examples
## Real-world examples
### Comment field
```csharp
new Textarea(
id: "comment",
name: "comment",
placeholder: "Write a comment…",
label: "Comment",
rows: 5)
```
### Bio field with default value
### Bio field (editing an existing value)
```csharp
new Textarea(
id: "bio",
name: "bio",
label: "Bio",
description: "Tell us about yourself (max 280 characters)",
defaultValue: user.Bio ?? "")
description: "Max 280 characters",
defaultValue: System.Web.HttpUtility.HtmlEncode(user.Bio ?? ""),
rows: 4)
```
### Auto-expand with HTMX
### Auto-growing field (expands as the user types)
Pass a small `oninput` handler through `hxAttrs`:
```csharp
new Textarea(
@@ -95,9 +73,19 @@ new Textarea(
hxAttrs: """oninput="this.style.height=''; this.style.height=this.scrollHeight+'px'"""")
```
### Auto-save on input
### Reading on the server
```csharp
public record Command(
[property: FromForm] string Bio
);
```
---
## How it works
Textarea renders a standard HTML `<textarea>` element. The `defaultValue` is placed between the opening and closing tags (not in `value` like an `<input>`). Always HTML-encode any user-supplied `defaultValue` before passing it in.
new Textarea(
id: "draft",
name: "content",
+45 -81
View File
@@ -1,53 +1,18 @@
# TimePicker
A styled time picker. The user selects hours, minutes, and optionally AM/PM. The component always writes the selected time as `HH:MM` (24-hour) to the hidden input, regardless of whether 12-hour display mode is used. Optionally renders a visible label and description.
A styled time selector with separate dropdowns for hours and minutes (and optionally AM/PM). The selected time is always stored in a hidden input as `HH:MM` in 24-hour format, regardless of whether you show the 12-hour display mode.
---
## HTML structure
## Quick example
```
div.flex.flex-col.gap-1.5
label.text-sm.font-medium ← omitted when empty
{label}
div.flex.items-center.gap-1.rounded-md.border.border-input.bg-background.px-3.py-2
select.timepicker-h[name={name}-h] ← hour select (112 or 023)
span.text-muted-foreground :
select.timepicker-m[name={name}-m] ← minute select (0059)
select.timepicker-ampm[name={name}-ampm] ← AM/PM (12h mode only)
input.sr-only[type=hidden, name={name}] ← hidden input holding HH:MM
p.text-sm.text-muted-foreground ← omitted when empty
{description}
```csharp
new TimePicker(name: "startTime", label: "Start time")
```
---
## CSS mechanics
| Class | Effect |
|---|---|
| `rounded-md border border-input bg-background` | Consistent styling with other form fields |
| `sr-only` on hidden input | Hidden visually but included in form submission |
| `appearance-none` on `<select>` elements | Removes native browser dropdown arrow for uniform style |
| `focus:outline-none` on selects | Focus ring deferred to the wrapper `div` |
---
## JavaScript (`syncTime` in `components.js`)
Runs on `DOMContentLoaded` and `htmx:afterSwap`.
### `syncTime(wrapper)`
1. Finds `.timepicker-h`, `.timepicker-m`, `.timepicker-ampm`, and the hidden `input`
2. On any `change` event across the three visible selects:
- Reads hour, minute, and AM/PM values
- Converts 12h → 24h if AM/PM select is present
- Writes `HH:MM` to the hidden input
---
## Constructor signature
## All the options
```csharp
public TimePicker(
@@ -58,49 +23,24 @@ public TimePicker(
bool use12h = false)
```
| Parameter | Description |
| Parameter | What it does |
|---|---|
| `name` | Form field name; hidden input gets this name, visible selects get `{name}-h`, `{name}-m`, `{name}-ampm` |
| `selected` | Pre-selected time as `"HH:MM"` (24h format); defaults to current time |
| `label` | Optional visible label |
| `description` | Optional helper text |
| `use12h` | If `true`, shows AM/PM select and hour range 112 |
| `name` | Form field name. The hidden input gets this name and always holds a `HH:MM` value. The visible selects get `{name}-h`, `{name}-m`, `{name}-ampm`. |
| `selected` | Pre-selected time as `"HH:MM"` in 24-hour format. Defaults to the current time. |
| `label` | Visible text label above the picker. |
| `description` | Small hint text below the picker. |
| `use12h` | Show 12-hour mode with an AM/PM dropdown. The hidden input still stores 24h format. |
---
## Usage examples
## Real-world examples
### Basic time picker (24h)
```csharp
new TimePicker(name: "startTime", label: "Start time")
```
### 12-hour mode
```csharp
new TimePicker(
name: "meetingTime",
label: "Meeting time",
use12h: true)
```
### Pre-selected time
```csharp
new TimePicker(
name: "alarmTime",
selected: "07:30",
label: "Alarm",
use12h: true)
```
### Inside a form
### Appointment booking with start and end times
```html
<!-- ScheduleForm.htmx -->
<form method="post" action="/schedule">
$$AntiforgeryToken$$
<form method="post" action="/schedule" class="space-y-4">
$$Token$$
$$StartTime$$
$$EndTime$$
<button type="submit">Save</button>
@@ -108,16 +48,40 @@ new TimePicker(
```
```csharp
public ScheduleForm()
{
StartTime = new TimePicker(name: "startTime", label: "Start", selected: "09:00");
EndTime = new TimePicker(name: "endTime", label: "End", selected: "17:00");
}
// ScheduleForm.htmx.cs
_startTime = new TimePicker(name: "startTime", label: "Start", selected: "09:00");
_endTime = new TimePicker(name: "endTime", label: "End", selected: "17:00");
```
**Reading the submitted values:**
Reading on the server:
```csharp
public record Command(
[property: FromForm] string StartTime, // "HH:MM"
[property: FromForm] string EndTime
);
var start = TimeOnly.ParseExact(command.StartTime, "HH:mm");
var end = TimeOnly.ParseExact(command.EndTime, "HH:mm");
```
### 12-hour display mode with a pre-selected time
```csharp
new TimePicker(
name: "alarmTime",
selected: "07:30",
label: "Alarm time",
use12h: true)
```
The user sees `7:30 AM` in the dropdowns, but `07:30` is what gets submitted.
---
## How it works
TimePicker renders three `<select>` elements (hours, minutes, and optionally AM/PM) styled to look like a single field, plus a hidden `<input>` that holds the combined value. JavaScript in `components.js` listens for changes on any of the three selects and writes the correctly formatted `HH:MM` value to the hidden input, converting from 12h to 24h when needed.
public record Command(
[property: FromForm] string StartTime, // "09:00"
[property: FromForm] string EndTime // "17:00"
+39 -72
View File
@@ -1,104 +1,73 @@
# Toast
A transient notification that appears in the bottom-right corner (or wherever `ToastViewport` is placed), auto-dismisses after a configurable duration, and can be dismissed manually.
A small pop-up notification that appears in the corner of the screen, stays briefly, and then fades out on its own. Use it to give users confirmation after an action — "Saved!", "Error: could not connect", "Profile updated".
Toasts are triggered **client-side** via `window.showToast(...)` from JavaScript — they are not server-rendered components like most others. The `Toast` component class produces the initial toast markup for use as a static template or in the ToastViewport; in practice most toasts are created dynamically by the JS API.
Unlike most components, toasts are triggered from **JavaScript**, not from the server-rendered template.
---
## HTML structure (dynamically created by JS)
```
div.toast[role=alert, aria-live=polite, data-variant]
div.flex.items-start.gap-3
div.flex-1
p.font-medium.text-sm ← title
p.text-sm.text-muted-foreground ← description (omitted when empty)
button.ml-auto[aria-label=Dismiss] ← × close button
svg (×)
```
The outer `div.toast` is appended to the `ToastViewport` container by JS and removed after `duration` ms.
---
## CSS mechanics
| Class | Effect |
|---|---|
| `toast` | Defined in `input.css`: `w-80 rounded-lg border bg-background p-4 shadow-lg pointer-events-auto` |
| `toast-enter` / `toast-exit` | CSS keyframe animation classes applied by JS for slide-in/fade-out |
| `data-variant="default"` | Border `border-border` |
| `data-variant="destructive"` | Border `border-destructive`, title `text-destructive` |
| `data-variant="success"` | Border `border-green-500` |
---
## JavaScript (`showToast` in `components.js`)
## Quick example
```js
window.showToast({
title: "Operation complete", // required
description: "All items saved.", // optional
variant: "success", // "default" | "destructive" | "success"
duration: 4000 // milliseconds before auto-dismiss
title: "Saved!",
variant: "success",
duration: 3000
});
```
**Implementation steps:**
1. Build the toast `div` element with the classes and markup described above
2. Apply `toast-enter` class → CSS slide-in animation plays
3. Append to the `ToastViewport` (`#toast-viewport` by default, or the first `.toast-viewport` found)
4. After `duration` ms, apply `toast-exit` class → CSS fade-out animation plays
5. After fade-out completes, remove the element from the DOM
6. Dismiss button click runs the same fade-out + remove cycle immediately
---
## Constructor signature
## All the options
```csharp
public Toast(
string title,
string description = "",
string variant = "default")
```js
window.showToast({
title: string, // required
description: string, // optional — shown below the title
variant: string, // "default" | "destructive" | "success"
duration: number // milliseconds before auto-dismiss (default: 4000)
})
```
The constructor builds a static initial toast element. Most use-cases call `window.showToast(...)` from JS instead.
| Parameter | Description |
| Option | What it does |
|---|---|
| `title` | Required notification heading |
| `description` | Optional body text |
| `variant` | `"default"` / `"destructive"` / `"success"` |
| `title` | The main notification text. |
| `description` | Optional secondary text below the title. |
| `variant` | `"default"` = neutral; `"destructive"` = red border (errors); `"success"` = green border. |
| `duration` | How long the toast stays visible before fading out. |
---
## Usage examples
## Real-world examples
### Trigger from JavaScript after an HTMX event
### Show a toast after an HTMX request completes
```js
document.body.addEventListener('htmx:afterRequest', function (e) {
if (e.detail.successful) {
window.showToast({ title: 'Saved', variant: 'success', duration: 3000 });
window.showToast({ title: 'Changes saved', variant: 'success', duration: 3000 });
} else {
window.showToast({ title: 'Error', description: 'Could not save.', variant: 'destructive' });
window.showToast({ title: 'Something went wrong', description: 'Please try again.', variant: 'destructive' });
}
});
```
### Trigger from a server response header
### Trigger from the server via a response header
Add a response header `HX-Trigger` in your handler:
Add an `HX-Trigger` response header in your handler to fire a custom event:
```csharp
ctx.Response.Headers.Append("HX-Trigger",
"""{"showToast":{"title":"Saved!","variant":"success","duration":3000}}""");
"""{
"showToast": {
"title": "Profile updated",
"variant": "success",
"duration": 3000
}
}""");
```
Client-side listener:
Then listen for it on the client:
```js
document.body.addEventListener('showToast', function (e) {
@@ -106,17 +75,15 @@ document.body.addEventListener('showToast', function (e) {
});
```
### Server-rendered initial toast (rare)
```csharp
// Used as a slot inside a page that always shows a greeting on first load:
protected override void RenderWelcomeToast(HtmxRenderContext ctx)
=> new Toast("Welcome back!", "Your dashboard is ready.", "success").Render(ctx.Next());
```
This is the cleanest pattern for server-triggered toasts — the server decides the message and variant, the client handles the display.
---
## Tips and tricks
## How it works
`window.showToast` creates a new `<div>` with the toast content and appends it to the `ToastViewport` container. A CSS animation slides it in. After `duration` ms, a fade-out animation plays and then the element is removed from the DOM. The dismiss button (×) triggers the same fade-out immediately.
You must have a `ToastViewport` component in your layout for toasts to appear. See [ToastViewport.md](./ToastViewport.md).
- Always place a single `ToastViewport` in your main layout so toasts have a container to render into. See [ToastViewport.md](ToastViewport.md).
- Use the `HX-Trigger` header pattern to trigger toasts from HTMX responses — it keeps toast logic on the server without requiring extra HTMX endpoints.
+17 -55
View File
@@ -1,48 +1,14 @@
# ToastViewport
The fixed container that holds all `Toast` notifications. Place exactly one `ToastViewport` in your main layout (e.g. `MainLayout.htmx`). The viewport is invisible when empty and stacks toasts upward as they are added.
The fixed container where toast notifications appear. Place exactly one `ToastViewport` in your main layout — it sits in the corner of the screen and is invisible when no toasts are showing. New toasts stack upward as they are added.
---
## HTML structure
```
div[id={id}].toast-viewport.fixed.bottom-4.right-4.z-50.flex.flex-col-reverse.gap-2.w-80.pointer-events-none
```
---
## CSS mechanics
| Class | Effect |
|---|---|
| `fixed bottom-4 right-4` | Anchored to the bottom-right corner of the viewport |
| `z-50` | Floats above all other content including dialogs and dropdowns |
| `flex flex-col-reverse gap-2` | New toasts appear on top; older ones push downward |
| `w-80` | Matches the default toast width |
| `pointer-events-none` | The container itself doesn't capture clicks — toasts set `pointer-events-auto` individually |
---
## Constructor signature
```csharp
public ToastViewport(string id = "toast-viewport")
```
| Parameter | Description |
|---|---|
| `id` | Element id (default: `"toast-viewport"`). `components.js` queries `#toast-viewport` by default — only change this if you also update the JS lookup. |
---
## Usage examples
### Place in MainLayout
## Quick example
```html
<!-- MainLayout.htmx -->
<body class="...">
<body>
<main>$$Body$$</main>
$$ToastViewport$$
</body>
@@ -50,34 +16,30 @@ public ToastViewport(string id = "toast-viewport")
```csharp
// MainLayout.htmx.cs
public IHtmxComponent ToastViewport { get; } = new ToastViewport();
protected override void RenderToastViewport(HtmxRenderContext ctx)
=> ToastViewport.Render(ctx.Next());
_toastViewport = new ToastViewport();
```
### Custom id (advanced)
That's all. Every call to `window.showToast(...)` will now display in the bottom-right corner of the screen.
---
## All the options
```csharp
new ToastViewport(id: "notifications-container")
public ToastViewport(string id = "toast-viewport")
```
Then update the JS lookup:
| Parameter | What it does |
|---|---|
| `id` | The element id. `components.js` looks for `#toast-viewport` by default. Only change this if you also update the JavaScript. |
```js
// In components.js or a custom script:
const viewport = document.getElementById('notifications-container');
```
---
### Custom position (bottom-left)
## How it works
The position is set by Tailwind classes on the rendered element. To change position, subclass the component or pass `extraClasses` if supported, or override the `toast-viewport` class in your `input.css`:
ToastViewport renders a single fixed `<div>` anchored to the bottom-right of the screen. It has `pointer-events: none` so it doesn't block clicks on the page behind it. Individual toasts set `pointer-events: auto` so their dismiss buttons are still clickable.
```css
.toast-viewport {
bottom: 1rem;
left: 1rem;
right: auto;
Toasts are appended to this element by `window.showToast()` and removed after their duration expires.
}
```
+38 -47
View File
@@ -1,48 +1,24 @@
# Tooltip
A text hint that appears on hover. Implemented entirely in CSS using Tailwind's `group` and `group-hover` utilities — no JavaScript required.
A small text hint that appears when the user hovers over an element. Use it to label icon buttons, clarify abbreviations, or explain options that don't have visible text.
Tooltips are implemented entirely in CSS — no JavaScript required.
---
## HTML structure
## Quick example
```
span.relative.inline-flex.items-center.group
{trigger component rendered inline}
span.tooltip-text.absolute.z-50.px-2.py-1.text-xs.rounded.bg-foreground.text-background
.whitespace-nowrap.pointer-events-none
.opacity-0.group-hover:opacity-100.transition-opacity.duration-150
.{position classes}
{tooltip text}
```csharp
new Tooltip(
text: "Delete item",
trigger: new Button("🗑", size: "icon", variant: "ghost"))
```
---
## CSS mechanics
| Utility | Effect |
|---|---|
| `group` on wrapper | Enables `group-hover:*` utilities on descendants |
| `opacity-0` | Tooltip invisible at rest |
| `group-hover:opacity-100` | Tooltip fades in when the wrapper (group) is hovered |
| `transition-opacity duration-150` | 150ms fade animation |
| `pointer-events-none` | Tooltip itself doesn't interfere with hover detection |
| `bg-foreground text-background` | Dark-on-light / light-on-dark automatically via CSS variables |
| `whitespace-nowrap` | Prevents the tooltip from wrapping |
| `z-50` | Floats above surrounding content |
**Position classes by `position` parameter:**
| Position | Classes |
|---|---|
| `top` (default) | `bottom-full mb-1.5 left-1/2 -translate-x-1/2` |
| `bottom` | `top-full mt-1.5 left-1/2 -translate-x-1/2` |
| `left` | `right-full mr-1.5 top-1/2 -translate-y-1/2` |
| `right` | `left-full ml-1.5 top-1/2 -translate-y-1/2` |
Hover over the button and the label "Delete item" appears above it.
---
## Constructor signature
## All the options
```csharp
public Tooltip(
@@ -51,33 +27,48 @@ public Tooltip(
string position = "top")
```
| Parameter | Description |
| Parameter | What it does |
|---|---|
| `text` | Tooltip label (plain text; HTML not supported) |
| `trigger` | Any `IHtmxComponent` that acts as the hover target |
| `position` | `"top"` / `"bottom"` / `"left"` / `"right"` |
| `text` | The tooltip text. Plain text only — no HTML. |
| `trigger` | Any `IHtmxComponent` — this is the element the user hovers over. |
| `position` | Where the tooltip appears: `"top"` (default), `"bottom"`, `"left"`, or `"right"`. |
---
## Usage examples
## Real-world examples
### Icon button with tooltip
### Icon buttons in a toolbar
```csharp
new Tooltip(text: "Bold", trigger: new Button("B", size: "icon", variant: "ghost"))
new Tooltip(text: "Italic", trigger: new Button("I", size: "icon", variant: "ghost"))
new Tooltip(text: "Save", trigger: new Button("💾", size: "icon", variant: "ghost"))
```
### Right-aligned tooltip (near the left edge of the UI)
```csharp
new Tooltip(
text: "Delete item",
trigger: new Button("🗑", size: "icon", variant: "ghost"))
text: "View help documentation",
trigger: new Button("?", size: "icon", variant: "outline"),
position: "right")
```
### Top/bottom/left/right positions
### Below the element
```csharp
new Tooltip(text: "Above", trigger: new Button("Hover me"), position: "top")
new Tooltip(text: "Below", trigger: new Button("Hover me"), position: "bottom")
new Tooltip(text: "Left", trigger: new Button("Hover me"), position: "left")
new Tooltip(text: "Right", trigger: new Button("Hover me"), position: "right")
new Tooltip(
text: "This cannot be undone",
trigger: new Button("Delete", variant: "destructive"),
position: "bottom")
```
---
## How it works
Tooltip wraps the trigger in a `<span class="group">`. The tooltip text is an absolutely positioned `<span>` inside that wrapper with `opacity-0` by default and `group-hover:opacity-100` to fade it in. Because this is pure Tailwind CSS, there is no JavaScript involved and no initialisation needed for HTMX-swapped content.
### Tooltip on an Avatar
```csharp
+525
View File
@@ -0,0 +1,525 @@
# Component API Smells
This document catalogs potential complaints about the current component/template API.
Each item includes:
- Problem
- Why it hurts
- Potential solutions
- Consequences/costs
## 1) Magic Strings for Variants, Sizes, Types, Directions
Problem:
- Many components use raw strings for semantic options (`variant`, `size`, `type`, `direction`, `align`, etc.).
- Invalid values often silently fall back to defaults.
Why it hurts:
- No compile-time safety.
- Typos are easy to miss.
- Weak IntelliSense discoverability.
Potential solutions:
- Replace string options with enums for common semantic domains.
- Generate constants classes per component for non-breaking intermediate step.
- Add analyzers that validate allowed literals where strings are retained.
Consequences/costs:
- Enums can be breaking if public signatures change.
- Constants are low cost but do not fully prevent invalid values.
- Analyzer route adds tooling complexity.
## 2) Inconsistent Styling Extensibility (`extraClasses` and wrappers)
Problem:
- Some components have `extraClasses`; others do not.
- Developers often wrap components in outer `div` just to apply layout/styling.
Why it hurts:
- No consistent mental model.
- Extra wrapper markup increases noise and nesting depth.
Potential solutions:
- Add a standard `className` (or `extraClasses`) parameter to every component.
- Support class merging utility behavior in a shared helper.
Consequences/costs:
- Public constructor expansion across many components.
- Need policy for precedence (base classes first vs custom classes first).
## 3) Missing Uniform Attribute Pass-Through
Problem:
- Attribute extensibility is fragmented (`hxAttrs` in some places, none in others).
- No first-class support for arbitrary `aria-*`, `data-*`, test IDs, analytics attributes.
Why it hurts:
- Manual string composition is error-prone.
- Difficult accessibility and testing instrumentation.
Potential solutions:
- Add a shared attributes bag type (`IReadOnlyDictionary<string, string?>`).
- Keep `hxAttrs` temporarily as compatibility shim.
Consequences/costs:
- Larger refactor surface.
- Slight allocation/processing overhead.
- Requires HTML attribute encoding rules in one central place.
## 4) `hxAttrs` Raw String Footgun
Problem:
- Raw attribute strings allow malformed markup or accidental injection.
Why it hurts:
- Hard-to-debug render bugs.
- Security posture depends on each caller doing manual encoding correctly.
Potential solutions:
- Deprecate raw `hxAttrs` in favor of typed/structured attrs.
- Provide safe helper methods to construct HTMX attribute sets.
Consequences/costs:
- Migration needed for existing call sites.
- Potentially breaking unless a gradual fallback is kept.
## 5) Unsafe-by-Default Raw HTML Content Paths
Problem:
- Several components accept string content that is rendered as raw HTML.
- Caller must remember to encode user-provided values.
Why it hurts:
- XSS risk in real application code.
- Easy to misuse when moving quickly.
Potential solutions:
- Safe-by-default encoding for plain string inputs.
- Separate APIs for encoded text vs trusted HTML (explicit escape hatch).
- Introduce a `SafeHtml` wrapper type for intentional raw HTML.
Consequences/costs:
- Safe-by-default may break current behavior for callers relying on raw HTML.
- Trusted-HTML API adds conceptual complexity, but clearer intent.
## 6) Inconsistent Security Guidance in Component Docs
Problem:
- Some docs mention encoding; others do not provide clear warnings.
Why it hurts:
- Security correctness relies on tribal knowledge.
Potential solutions:
- Add a standardized "Security" section to every component doc.
- Include explicit examples: safe input, unsafe input, and fix.
Consequences/costs:
- Documentation maintenance overhead.
- Strong DX/security benefit for low implementation cost.
## 7) No Explicit "Component Props" Model in Markup
Problem:
- Slots replace placeholders, but there is no direct concept of passing typed props in `.htmx` markup itself.
- Dynamic behavior is mostly constructor-centric in `.htmx.cs`.
Why it hurts:
- Feels unlike modern component systems where props are explicit and local.
- New users may expect inline component parameterization and be surprised.
Potential solutions:
- Document this limitation clearly as a design constraint.
- Add a generated props record convention per component/page.
- Explore optional parameterized slot syntax in generator (long-term).
Consequences/costs:
- Props model requires generator design changes.
- Parameterized slot syntax is high complexity and may conflict with AOT simplicity.
## 8) Constructor Bloat and Low Readability
Problem:
- Some components expose many optional parameters, often multiple strings.
Why it hurts:
- Ambiguous calls and poor self-documentation.
- Easy to mis-order arguments.
Potential solutions:
- Favor required args + options record pattern.
- Add fluent builders for complex components.
Consequences/costs:
- Options records improve readability but introduce extra types.
- Builders can increase allocations and complexity.
## 9) Tuple-Based APIs for Complex Components
Problem:
- Components like tabs/accordion/dropdown/table rely on tuple collections.
Why it hurts:
- Tuples are easy to misuse and harder to read than named objects.
- Harder to evolve APIs without breaking all call sites.
Potential solutions:
- Replace tuple parameters with named records (`TabItem`, `AccordionItem`, etc.).
Consequences/costs:
- Migration churn across existing usage.
- Clear long-term maintainability win.
## 10) Missing Validation Feedback for Invalid Inputs
Problem:
- Invalid option values often degrade silently rather than failing fast.
Why it hurts:
- Bugs are hidden and discovered late.
Potential solutions:
- Add debug-time validation with clear exceptions/messages.
- Optionally emit logs/diagnostics in production with safe defaults.
Consequences/costs:
- Strict runtime validation can be breaking for existing invalid usages.
- Diagnostics-only mode is safer for migration.
## 11) Inconsistent Boolean Option Naming
Problem:
- Different components use varying naming styles for booleans and toggles.
Why it hurts:
- Low API predictability.
Potential solutions:
- Define naming conventions (`isX`, `hasX`, `enableX`) and enforce globally.
Consequences/costs:
- Rename churn if normalized retroactively.
## 12) CSS Contract Coupled to JS via Hidden Class Names
Problem:
- Interactive behavior relies on specific class/data selectors that are effectively API contracts.
Why it hurts:
- Refactoring classes can break behavior.
- Coupling is not obvious from constructor APIs.
Potential solutions:
- Document required selectors/events in each interactive component doc.
- Prefer stable `data-component`/`data-role` markers over purely visual class names.
Consequences/costs:
- Markup updates across components and JS.
- Better long-term resilience to style refactors.
## 13) Runtime Behavior Dependencies Not Surfaced in API
Problem:
- Components requiring JS initialization do not expose that requirement in code signatures.
Why it hurts:
- Silent "renders but does not work" failures.
Potential solutions:
- Add `Requires JavaScript` section in docs and XML comments.
- Add lightweight marker interface or metadata attribute for interactive components.
Consequences/costs:
- Minimal runtime cost; mostly documentation/tooling work.
## 14) Accessibility Ergonomics Gaps
Problem:
- No consistent way to pass `aria-*`, `id`, `for`, `describedby` across all components.
Why it hurts:
- Accessibility quality depends on manual wrapper hacks.
Potential solutions:
- Introduce shared accessible options type.
- Provide defaults and enforce required labels where relevant.
Consequences/costs:
- Constructor changes and additional validation logic.
## 15) Testability Friction (No Standard Test IDs)
Problem:
- No consistent `data-testid` or attribute pass-through strategy.
Why it hurts:
- E2E selectors become brittle (class/text-based selectors).
Potential solutions:
- Add standard attributes bag and testing guidance.
- Add `testId` convenience parameter in interactive/form primitives.
Consequences/costs:
- Minor API surface increase.
- Significant test stability benefit.
## 16) Documentation Discoverability Gaps
Problem:
- Component docs focus on usage but under-emphasize known limitations and smell areas.
Why it hurts:
- New contributors re-learn the same constraints repeatedly.
Potential solutions:
- Add dedicated docs for limitations, anti-patterns, and migration strategy.
- Add an index from component reference into Issues docs.
Consequences/costs:
- Ongoing documentation upkeep.
## 17) Inconsistent Naming (`extraClasses` vs alternatives)
Problem:
- Similar concepts have inconsistent parameter names.
Why it hurts:
- Context switching overhead.
Potential solutions:
- Standardize naming dictionary and enforce in reviews.
- Offer temporary backward-compatible aliases.
Consequences/costs:
- Alias support increases short-term complexity.
## 18) Lack of Strongly-Typed Domain Primitives
Problem:
- IDs, route paths, CSS classes, and labels are all plain strings.
Why it hurts:
- Accidental parameter swaps and weak intent signaling.
Potential solutions:
- Introduce lightweight value objects or records for high-value domains (`DialogId`, `CssClassList`, etc.).
Consequences/costs:
- Added type count and conversion code.
- Better readability and safer APIs.
## 19) Missing Centralized Class Composition Policy
Problem:
- Tailwind class strings are composed ad-hoc in constructors.
Why it hurts:
- Risk of duplicate/conflicting classes.
- Hard to audit variant behavior consistency.
Potential solutions:
- Add shared class composition helper utilities.
- Optionally adopt a deterministic merge utility pattern.
Consequences/costs:
- New utility dependency or internal helper maintenance.
## 20) Limited Error Reporting for Misconfigured Interactive Components
Problem:
- Missing/incorrect JS hooks often fail quietly.
Why it hurts:
- Time-consuming debugging.
Potential solutions:
- Development-only console warnings/assertions from `components.js` when expected markers are missing.
Consequences/costs:
- Slight JS complexity increase.
- Better troubleshooting experience.
## 21) Form Component API Inconsistency
Problem:
- Form primitives vary in how they accept value/default/checked/attrs/labels.
Why it hurts:
- Hard to predict usage patterns across components.
Potential solutions:
- Define a shared form control contract:
- `name`, `id`, `label`, `value`, `disabled`, `required`, `className`, `attributes`
Consequences/costs:
- Widespread API harmonization work.
- Major usability win once stabilized.
## 22) No First-Class Validation/Error State Patterns
Problem:
- Error display, invalid styling, and message linkage are largely ad-hoc.
Why it hurts:
- Inconsistent UX and accessibility for validation states.
Potential solutions:
- Add canonical form-field wrapper component and error semantics.
- Add helper patterns in docs for mapping server validation to components.
Consequences/costs:
- Additional abstractions and migration.
## 23) Table API Lacks Strong Cell/Column Models
Problem:
- Table inputs as nested strings are simplistic and rigid.
Why it hurts:
- Hard to represent links, badges, actions, and per-cell semantics safely.
Potential solutions:
- Introduce column and row models with typed cell renderers.
- Support text cell vs trusted HTML cell explicit APIs.
Consequences/costs:
- Significant redesign effort for table API.
- High payoff for real-world usage.
## 24) Potential Over-Eager Precomputation in Constructors
Problem:
- Some components precompute heavy HTML payloads in constructor.
Why it hurts:
- Allocation spikes for large datasets.
- Can surprise developers expecting render-time streaming.
Potential solutions:
- Lazy compute/cache expensive sections.
- Document performance profile and guardrails per component.
Consequences/costs:
- Possible complexity in caching invalidation.
- Better performance transparency.
## 25) No Compatibility Policy for API Evolution
Problem:
- No explicit deprecation policy for parameter renames or behavior changes.
Why it hurts:
- Contributors hesitate to improve APIs due to break risk.
Potential solutions:
- Define semver/deprecation policy in docs.
- Use staged migration with obsolete annotations.
Consequences/costs:
- Process overhead, but critical for long-term maintainability.
## 26) No Unified Component Design Principles Doc
Problem:
- Patterns are documented, but not as enforceable design principles.
Why it hurts:
- New components may diverge in API style.
Potential solutions:
- Publish a component API style guide with mandatory rules and preferred patterns.
Consequences/costs:
- Requires reviewer discipline.
## 27) Internationalization (i18n) Boundaries Not Explicit
Problem:
- Many labels/content are plain strings without explicit localization guidance.
Why it hurts:
- Inconsistent localization strategy across pages/components.
Potential solutions:
- Add docs for localizable boundaries and resource integration patterns.
Consequences/costs:
- Documentation and integration work.
## 28) Missing "Known Limitations" Section in Component Reference Entry Point
Problem:
- The main component reference does not prominently call out systemic limitations.
Why it hurts:
- Developers discover constraints by trial and error.
Potential solutions:
- Add up-front limitations and issue tracker links in component reference.
Consequences/costs:
- Low cost; immediate discoverability gains.
## 29) API Surface Differs Across Similar Components
Problem:
- Similar categories (display/form/interactive) do not expose comparable extension points.
Why it hurts:
- Surprising differences force re-learning per component.
Potential solutions:
- Define a baseline component contract by category:
- Display: `className`, `attributes`
- Form: baseline form control props + attrs
- Interactive: baseline + JS contract notes
Consequences/costs:
- Requires systematic API audit and staged rollout.
## 30) Missing Tooling Support for API Misuse
Problem:
- No analyzers/code fixes for common mistakes (invalid variant, unsafe content, missing encoding).
Why it hurts:
- Review burden remains manual.
Potential solutions:
- Introduce Roslyn analyzers for:
- magic string validation
- unsafe raw HTML from untrusted sources
- missing serialization registration patterns
Consequences/costs:
- Initial tooling investment is medium-high.
- Scales quality across the codebase after adoption.
---
## Cross-Cutting Improvement Patterns
1. Standardized base options record:
- `className`
- `attributes`
- `testId`
- `ariaLabel`
2. Strong typing for semantic options:
- enums/constants/analyzers
3. Safe content model:
- text-safe by default, explicit trusted-html escape hatch
4. Better docs contract:
- every component doc should include:
- security notes
- accessibility notes
- JS dependency notes (if interactive)
- extension points
5. Migration strategy:
- additive changes first
- obsolete old params
- remove deprecated paths in major version bump
@@ -0,0 +1,329 @@
# Component-by-Component Concerns
This matrix captures likely API/DX complaints per component area, including potential improvement directions.
## Display Components
### Alert
Concerns:
- Variant as magic string.
- Content/title may be used as raw HTML without explicit safety boundary.
- Custom classes/attributes may be inconsistent.
Potential improvements:
- Enum for variant.
- Safe text API + explicit trusted HTML path.
- Standard `className` and `attributes`.
### Avatar
Concerns:
- Fallback/shape/size options can become string-heavy.
- Accessibility attributes may be awkward without attr bag.
Potential improvements:
- Typed size/shape options.
- Uniform attributes model.
### Badge
Concerns:
- Variant string typing and typo risk.
- Inconsistent extension points compared with Button/Input.
Potential improvements:
- Variant enum/constants.
- Standardized extensibility surface.
### Breadcrumb
Concerns:
- Item model may be primitive/string-only.
- Accessibility hooks and custom attrs may be limited.
Potential improvements:
- Named item record model.
- Attr bag and aria convenience options.
### Card
Concerns:
- Raw HTML sections can be misused.
- Optional sections create many constructor parameters.
Potential improvements:
- Options record.
- Safe content model.
### Progress
Concerns:
- Value bounds validation may be implicit or absent.
- Class extension inconsistencies.
Potential improvements:
- Explicit min/max validation.
- Standard class and attr extension.
### Separator
Concerns:
- Orientation/type as magic string.
- Thin API extensibility for accessibility semantics.
Potential improvements:
- Enum for orientation.
- Attr bag support.
### Skeleton
Concerns:
- Shape/sizing patterns vary by caller wrappers.
- Lacks compositional guidance for complex placeholders.
Potential improvements:
- Preset variants + className override.
- Pattern docs for loading states.
### Table
Concerns:
- Primitive row/cell string model limits rich content.
- Possible heavy constructor precomputation for large data.
- Safety boundary unclear when rendering rich cell content.
Potential improvements:
- Typed column/cell models.
- Lazy render/cache strategy for large tables.
- Explicit text-vs-html cell APIs.
### Tooltip
Concerns:
- Trigger/content composition can rely on string/slot conventions.
- Accessibility and focus behavior may need clearer guidance.
Potential improvements:
- Better keyboard and aria documentation.
- Attr/class pass-through consistency.
## Form Components
### Button
Concerns:
- Variant/size/type magic strings.
- `hxAttrs` raw string ergonomics/security risk.
- Need for wrapper to add layout classes in some contexts.
Potential improvements:
- Enums/constants.
- Structured attributes.
- Standard `className`.
### Checkbox
Concerns:
- Label/id/checked model may not match other form controls.
- Limited pass-through attributes.
Potential improvements:
- Shared form-control options contract.
- Attr bag and validation state support.
### FileInput
Concerns:
- Accepted file types and attrs may be cumbersome.
- Inconsistent API vs Input/Textarea.
Potential improvements:
- Shared form-control options.
- Better file-specific typed options.
### Input
Concerns:
- Input type as string.
- Validation/aria hooks likely manual.
Potential improvements:
- Input type enum/constants.
- Baseline form options and attr bag.
### RadioGroup
Concerns:
- Tuple options reduce readability.
- Direction/layout often string-based.
- Attr pass-through likely limited.
Potential improvements:
- Named option record.
- Typed direction values.
- Standard form/attr contract.
### Select
Concerns:
- Option model and selected/default semantics may be inconsistent.
- Styling/attrs may differ from Input/Textarea.
Potential improvements:
- Named option record.
- Unified form control API.
### Slider
Concerns:
- Value/min/max/step validation and formatting ergonomics.
- Limited attr/class extensibility in some usages.
Potential improvements:
- Stronger numeric validation and docs.
- Standard extension points.
### Switch
Concerns:
- Checked/value semantics may differ from checkbox.
- JS and accessibility contracts may not be obvious.
Potential improvements:
- Form contract alignment.
- Explicit a11y + JS requirements docs.
### Textarea
Concerns:
- Similar concerns to Input: attrs, validation, and consistency.
Potential improvements:
- Shared baseline form options.
## Interactive Components
### Accordion
Concerns:
- Tuple-based item model.
- JS selector coupling via markup classes/data attributes.
- Rich content safety boundary if strings are HTML.
Potential improvements:
- Named `AccordionItem` model.
- Stable data-role contracts.
- Explicit safe/trusted content APIs.
### Calendar
Concerns:
- JS dependency and date contract coupling.
- Limited custom attributes for instrumentation/a11y.
Potential improvements:
- Explicit JS contract docs in API comments.
- Standard attrs support.
### CalendarRange
Concerns:
- Similar to Calendar plus complexity around range state.
- Validation/error state API may be weak.
Potential improvements:
- Strong state model and docs.
- Better attr/validation contract.
### Dialog
Concerns:
- Open/close semantics rely on data attributes and JS wiring.
- Content sections may use raw HTML strings.
Potential improvements:
- Named trigger/actions patterns in docs.
- Safe content APIs and structured attrs.
### DropdownMenu
Concerns:
- Item model can be tuple-heavy.
- Keyboard and accessibility behavior depends on JS contract.
Potential improvements:
- Named item/action records.
- Explicit interaction/accessibility contract docs.
### Tabs
Concerns:
- Tuple-based tab definitions.
- ID/active state handling as plain strings.
- JS contract may be implicit.
Potential improvements:
- `TabItem` record + typed active key model.
- Explicit JS and accessibility notes.
### TimePicker
Concerns:
- Value format/string handling may be error-prone.
- JS coupling and validation ergonomics.
Potential improvements:
- Typed time value helpers.
- Clear formatting and validation rules.
## Notification Components
### Toast
Concerns:
- Trigger lifecycle and JS coupling may not be obvious.
- Variant/style options likely string-based.
Potential improvements:
- Typed options and JS contract docs.
- Standard attrs/class extension points.
### ToastViewport
Concerns:
- Placement/config likely string-heavy.
- Global singleton usage constraints may be under-documented.
Potential improvements:
- Typed placement/options.
- Clear singleton/layout guidance.
## Navigation Components
### Pagination
Concerns:
- Data model may be primitive and hard to customize.
- Accessibility state semantics need consistency.
Potential improvements:
- Named model for page items and actions.
- Strong accessibility defaults and hooks.
## Cross-Component Cost Notes
Low-cost improvements:
- Better docs for limitations/security/js contracts.
- Add design guidelines and migration policy.
- Constants for common string literals.
Medium-cost improvements:
- Standard `className` and `attributes` options.
- Options records for complex constructors.
- Named item models replacing tuples.
High-cost improvements:
- Safe-by-default content model transition.
- Full enum migration for all semantic options.
- Analyzer suite for API misuse detection.
@@ -0,0 +1,51 @@
# Component API Design Guidelines
Use this when creating or evolving components so the API remains predictable.
## Goals
- Consistency across all components
- Safe defaults for user content
- Low ceremony for common use cases
- Explicit escape hatches for advanced scenarios
## Baseline API Conventions
For every new component, prefer:
- `className` for caller-supplied Tailwind/CSS classes
- `attributes` for arbitrary HTML attributes (`aria-*`, `data-*`, test IDs)
- Strongly typed semantic options where practical (enums or constants)
- Named item records instead of tuples for complex lists
## Safety Rules
1. Plain text input should be encoded by default.
2. Raw HTML should require an explicit trusted path.
3. Never require callers to manually concatenate unsafe attribute strings for normal usage.
## Documentation Rules
Every component doc should include:
1. Quick example
2. All options
3. Security notes
4. Accessibility notes
5. JS dependency notes (if interactive)
6. Extension points (`className`, `attributes`)
## Evolution Rules
1. Prefer additive changes first.
2. Mark old APIs as deprecated with migration examples.
3. Remove deprecated paths only in major release.
## Review Checklist
- Is the API consistent with sibling components?
- Can callers add classes without wrapper divs?
- Can callers pass `aria-*` and `data-*` safely?
- Are semantic options type-safe?
- Are user-provided strings encoded by default?
- Are interactive JS requirements documented?
@@ -0,0 +1,128 @@
# Component API Limitations and Workarounds
This page documents known limitations in the current component API and practical ways to work effectively with them.
## 1) Magic String Parameters
Limitation:
- Semantic options like `variant`, `size`, and similar values are frequently string-based.
What this means:
- Typos may silently fall back to defaults.
Workaround:
- Centralize repeated literals in local constants in your feature code.
- Prefer named arguments for readability.
Example:
```csharp
private const string VariantDestructive = "destructive";
var deleteButton = new Button(
label: "Delete",
variant: VariantDestructive,
size: "sm");
```
## 2) Extra Styling Often Requires Wrappers
Limitation:
- Not every component exposes a class extension parameter.
What this means:
- You may need wrapper elements for layout spacing, sizing, or responsive behavior.
Workaround:
- Use a minimal wrapper pattern and keep wrapper intent obvious.
Example:
```html
<div class="md:max-w-sm w-full">$$SaveButton$$</div>
```
## 3) No Universal Attribute Bag
Limitation:
- Some components expose `hxAttrs`, some do not, and no shared attributes model exists yet.
What this means:
- Passing `aria-*`, `data-*`, or test selectors is inconsistent.
Workaround:
- Prefer wrapper-level attributes where possible.
- If using raw attr strings, keep them static and explicit.
## 4) No Direct "Props in .htmx Markup" Model
Limitation:
- Component/page parameterization is constructor-driven in `.htmx.cs`, not inline-props driven in `.htmx` markup.
What this means:
- Dynamic behavior is assembled in C# code-behind.
Workaround:
- Treat `.htmx` as shape and slot layout.
- Treat `.htmx.cs` as the single source of component input logic.
## 5) Raw HTML Output Requires Discipline
Limitation:
- Several components render provided strings as HTML.
What this means:
- User input must be encoded before rendering.
Workaround:
- Always encode user-provided values before `ToUtf8Bytes()`.
Example:
```csharp
var safeName = System.Web.HttpUtility.HtmlEncode(userDisplayName);
_nameData = safeName.ToUtf8Bytes();
```
## 6) Interactive Components Depend on JS Contracts
Limitation:
- Components like tabs, accordion, dialog, calendar, and toast depend on JavaScript hooks/selectors.
What this means:
- Markup can render but behave incorrectly if expected JS wiring is missing.
Workaround:
- Verify behavior after HTMX swaps.
- Keep required data-role/class markers intact.
## 7) Tuple APIs for Complex Components
Limitation:
- Some components expect tuple arrays for items/options.
What this means:
- Call sites can become harder to read and evolve.
Workaround:
- Build small local records/variables first, then map to tuples.
## 8) Form API Inconsistency
Limitation:
- Form primitives do not all expose the same extension points.
What this means:
- You need per-component familiarity.
Workaround:
- Create feature-local helper methods to normalize usage patterns.
## 9) Recommendation for Teams
If multiple developers are contributing:
1. Define local conventions for allowed variant strings.
2. Standardize wrapper patterns (`layout wrappers`, `a11y wrappers`, `test-id wrappers`).
3. Review for HTML encoding whenever user input is rendered.
4. Track repeated pain points in docs/Issues for future API upgrades.
@@ -0,0 +1,68 @@
# Improvement Options and Costs
This matrix helps prioritize API improvements by DX value, risk, and migration cost.
Legend:
- Effort: S (small), M (medium), L (large), XL (very large)
- Break Risk: Low, Medium, High
- Runtime Impact: Positive, Neutral, Slight Negative
| Proposal | Solves | Effort | Break Risk | Runtime Impact | Notes |
|---|---|---|---|---|---|
| Add standardized `className` to all components | wrapper-div workaround, styling consistency | M | Low | Neutral | Additive change if optional |
| Add standardized attributes bag (`attributes`) | missing `aria-*`, `data-*`, test IDs | L | Medium | Slight Negative | Best long-term extensibility |
| Keep `hxAttrs` as compatibility fallback | migration safety | S | Low | Neutral | Mark as legacy in docs |
| Enums for variant/size/type | magic strings | M | Medium | Neutral | Better compile-time safety |
| Constants classes for allowed string values | magic strings (partial) | S | Low | Neutral | Good transitional step |
| Replace tuple APIs with named records | readability, future extensibility | M | Medium | Neutral | `TabItem`, `AccordionItem`, etc. |
| Add options-record constructors for complex components | constructor bloat | M | Low | Neutral | Improves call-site clarity |
| Safe-by-default text encoding APIs | XSS risk | M | High | Neutral | Breaking if current raw HTML behavior is relied on |
| Explicit trusted HTML wrapper API | intentional raw HTML path | M | Medium | Neutral | Clear security intent |
| Add standardized Security section in every component doc | security docs inconsistency | M | Low | Neutral | High ROI docs work |
| Add standardized Accessibility section in every component doc | a11y gaps | M | Low | Neutral | High ROI docs work |
| Add JS contract metadata/docs for interactive components | hidden JS dependencies | S | Low | Neutral | Immediate debugging benefit |
| Add development-time JS diagnostics in components.js | silent interactive failures | M | Low | Slight Negative | Dev-only checks recommended |
| Add analyzer: invalid variant literals | magic string typos | L | Low | Neutral | Tooling investment pays off |
| Add analyzer: unsafe unencoded user input usage | XSS prevention | L | Medium | Neutral | Requires careful heuristics |
| Add API evolution policy and deprecation plan | change management | S | Low | Neutral | Needed before major refactors |
| Introduce per-category baseline contracts | inconsistency across components | L | Medium | Neutral | Strategic but broad |
| Introduce table column/cell model | table API limitations | L | Medium | Neutral | High payoff for real apps |
| Add form control baseline contract | form component inconsistency | L | Medium | Neutral | Improves predictability |
| Introduce lazy compute in heavy components | precompute allocation concerns | M | Low | Positive | Benchmark before and after |
## Recommended Sequencing
1. Documentation-first, no-break improvements:
- known limitations docs
- security/a11y/js contract sections
- issue tracker links and migration guidance
2. Additive API upgrades:
- `className`
- `attributes`
- options records
- constants for allowed values
3. Strong typing and validation:
- enums
- analyzers
- debug validations
4. Breaking/security-hardening updates:
- safe-by-default content model
- deprecate raw string HTML entry points
## Consequence Summary
Positive consequences:
- More predictable and discoverable API
- Lower bug rate from string typos and attr mistakes
- Better security baseline
- Less wrapper-div boilerplate
Negative/neutral consequences:
- Larger API surface
- Migration overhead in existing usages
- Potentially more allocations for flexible attribute models
- Need for contributor discipline to maintain consistency
+30
View File
@@ -0,0 +1,30 @@
# Issues and API Improvement Tracker
This folder tracks known design/API smells in the current template stack, with proposed fixes and their trade-offs.
Use this section for:
- Capturing developer pain points before they are forgotten
- Aligning on possible API improvements
- Comparing implementation cost vs DX benefit
- Planning staged, low-risk migrations
## Files
- `Component-API-Smells.md` - exhaustive problem catalog and solution options
- `Improvement-Options-and-Costs.md` - decision matrix with cost/consequence analysis
- `Roadmap.md` - phased adoption plan
- `Component-by-Component-Concerns.md` - component-level complaint inventory and improvements
- `Components/00-API-Limitations-and-Workarounds.md` - component API limitations and practical workarounds
- `Components/00-API-Design-Guidelines.md` - component API design principles for future changes
## Scope
This is intentionally broader than bug tracking. Many entries are not defects; they are DX and API design weaknesses (inconsistency, discoverability, ergonomics, safety defaults, and maintainability concerns).
## Ground Rules
1. Keep issues concrete and reproducible.
2. Include at least one realistic solution.
3. Always state consequences (breaking change risk, complexity, perf, AOT constraints).
4. Prefer incremental migration paths over large rewrites.
+81
View File
@@ -0,0 +1,81 @@
# API Improvement Roadmap
This roadmap is designed to improve DX without destabilizing the current template.
## Phase 0: Track and Communicate (Now)
Goals:
- Make limitations explicit.
- Prevent repeated confusion.
Actions:
- Publish issues catalog and cost matrix.
- Link issues from the main component reference.
- Add "Known Limitations" + "Security" notes in component docs.
Success criteria:
- New contributors can find limitations before implementation.
## Phase 1: Low-Risk Additive API Improvements
Goals:
- Improve ergonomics with minimal break risk.
Actions:
- Add optional `className` to all components.
- Add optional `attributes` bag to all components.
- Keep `hxAttrs` as legacy fallback.
- Introduce constants classes for common string domains.
Success criteria:
- Most wrapper-div style workarounds disappear.
- Most custom attribute hacks disappear.
## Phase 2: Standardization and Strong Typing
Goals:
- Reduce error-prone string APIs.
Actions:
- Move high-value components to enums for semantic options.
- Introduce options records where constructors are overloaded.
- Replace tuple-based list inputs with named records.
Success criteria:
- IntelliSense can guide common component usage.
- Fewer runtime surprises from typos.
## Phase 3: Security and Validation Hardening
Goals:
- Safer defaults and clearer intent.
Actions:
- Introduce safe-by-default text paths.
- Keep explicit trusted HTML APIs for advanced usage.
- Add analyzers (or debug validators) for invalid options and unsafe patterns.
Success criteria:
- XSS risk materially reduced.
- Unsafe usage becomes explicit and reviewable.
## Phase 4: Deep API Evolution
Goals:
- Improve advanced composition and maintainability.
Actions:
- Formalize per-category component contracts.
- Improve table/form models for richer data and validation scenarios.
- Consider generator-level support for richer parameter/props models.
Success criteria:
- New component additions follow one predictable design style.
- Complex UIs require less custom glue code.
## Migration Strategy
1. Add new APIs first.
2. Mark old parameters/patterns as deprecated.
3. Provide codemod or migration examples.
4. Remove deprecated surface only in major release.