Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions .github/workflows/pull_request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,19 @@ jobs:
- name: Setup dotnet
uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.0.x
dotnet-version: |
8.0.x
9.0.x
10.0.x
- name: Install Tools
run: dotnet tool restore
working-directory: ./fsharp-view-engine
working-directory: ./lib
- name: Install Packages
run: dotnet paket install
working-directory: ./fsharp-view-engine
working-directory: ./lib
- name: Test
run: ./fake.sh Test
working-directory: ./fsharp-view-engine
working-directory: ./lib
preview:
name: Preview
runs-on: ubuntu-latest
Expand Down
13 changes: 8 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,19 @@ jobs:
- name: Setup dotnet
uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.0.x
dotnet-version: |
8.0.x
9.0.x
10.0.x
- name: Restore Tools
run: dotnet tool restore
working-directory: ./fsharp-view-engine
working-directory: ./lib
- name: Install Packages
run: dotnet paket install
working-directory: ./fsharp-view-engine
working-directory: ./lib
- name: Publish
run: ./fake.sh Publish
working-directory: ./fsharp-view-engine
run: ./fake.sh PushNugets
working-directory: ./lib
env:
NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }}
deploy:
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
.idea
.vscode
.claude
File renamed without changes.
70 changes: 48 additions & 22 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,24 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Overview
FSharp.ViewEngine is a view engine for F# web applications. It provides a functional approach to generating HTML with type-safe F# code, including integrated support for HTMX, Alpine.js, Tailwind CSS, and SVG elements.
FSharp.ViewEngine is a view engine for F# web applications. It provides a functional approach to generating HTML with type-safe F# code, including integrated support for HTMX, Alpine.js, Datastar, Tailwind CSS, and SVG elements.

## Repository Structure
- `fsharp-view-engine/` — F# library, app, tests, and build system
- `pulumi/` — Pulumi infrastructure (TypeScript) for deploying the demo app
- `.github/` — CI workflows (at repo root, with `working-directory: fsharp-view-engine`)
- `lib/` — Core F# library, tests, benchmarks, and build system
- `docs/` — Documentation Giraffe web app with Markdown pages and build system
- `etc/` — Assets (logos)
- `pulumi/` — Pulumi infrastructure (TypeScript) for deploying the docs app
- `.github/` — CI workflows
- `.claude/` — Claude Code settings

## Common Development Commands

**Important:** When running in a bash shell (including Claude Code), always use `./fake.sh` instead of `fake.cmd`.

### Library (`lib/`)

```bash
# All dotnet/fake commands run from fsharp-view-engine/
cd fsharp-view-engine
cd lib

# Restore tools and packages
dotnet tool restore
Expand All @@ -30,31 +33,47 @@ dotnet run --project src/Tests/Tests.fsproj # Direct
# Run a single test by name
dotnet run --project src/Tests/Tests.fsproj -- --filter "Should render html document"

# Run demo app with Tailwind watch
./fake.sh Watch

# Create NuGet package (needs GITHUB_REF_NAME env var)
./fake.sh Pack
```

### Docs app (`docs/`)

```bash
cd docs

# Pulumi deployment
# Run docs app with Tailwind watch (hot reload)
./fake.sh Watch

# Build CSS for production
./fake.sh BuildCss

# Publish docs app
./fake.sh Publish
```

### Pulumi deployment

```bash
cd pulumi
npm install
pulumi up -s prod
```

## Architecture

### Core Type System (`fsharp-view-engine/src/FSharp.ViewEngine/Core.fs`)
### Core Type System (`lib/src/FSharp.ViewEngine/Core.fs`)
Two discriminated unions form the foundation:
- **Element**: `Text | Tag | Void | Fragment | Raw | Noop` — represents DOM nodes
- **Attribute**: `KeyValue | Boolean | Children | Noop` — represents HTML attributes

Rendering uses `StringBuilder` with recursive pattern matching. `Text` is HTML-encoded; `Raw` is not. `Void` elements (e.g., `br`, `img`) are self-closing and reject children.

### Module Organization
### Module Organization (`lib/src/FSharp.ViewEngine/`)
- `Html.fs` — Standard HTML elements and attributes as static members on `Html` type
- `Htmx.fs` — HTMX attributes (`_hxGet`, `_hxPost`, etc.) on `Htmx` type
- `Alpine.fs` — Alpine.js directives (`_xData`, `_xShow`, etc.) on `Alpine` type
- `Datastar.fs` — Datastar attributes (`_dsSignals`, `_dsOn`, etc.) on `Datastar` type
- `Tailwind.fs` — Tailwind UI custom elements on `Tailwind` type
- `Svg.fs` — SVG elements and attributes on `Svg` type

Expand All @@ -64,31 +83,38 @@ open FSharp.ViewEngine
open type Html
open type Htmx

div [ _class "container"; _hxGet "/api"; _children [ h1 "Hello" ] ]
|> Element.render
div {
_class "container"
_hxGet "/api"
h1 { "Hello" }
}
|> Render.toHtmlDocString
```

### Project Structure
- `fsharp-view-engine/src/FSharp.ViewEngine/` — Core library (NuGet package)
- `fsharp-view-engine/src/Tests/` — xUnit tests
- `fsharp-view-engine/src/Build/` — FAKE build system (`Program.fs` defines targets)
- `fsharp-view-engine/src/App/` — Demo Giraffe web app with Markdown docs
- `lib/src/FSharp.ViewEngine/` — Core library (NuGet package)
- `lib/src/Tests/` — Expecto tests
- `lib/src/Benchmarks/` — BenchmarkDotNet benchmarks
- `lib/src/Build/` — FAKE build system (Test, Pack, PushNugets targets)
- `docs/src/Docs/` — Documentation Giraffe web app with Markdown pages
- `docs/src/Build/` — FAKE build system (Watch, BuildCss, Publish targets)
- `pulumi/` — Infrastructure as code (Pulumi + TypeScript)

## Development Patterns

- **New HTML elements** in `Html.fs`: use `Tag` for normal elements, `Void` for self-closing. Add convenience overloads (e.g., `p (text:string)`).
- **Framework attributes**: HTMX → `Htmx.fs` with `_hx` prefix; Alpine → `Alpine.fs` with `_x` prefix.
- **Framework attributes**: HTMX → `Htmx.fs` with `_hx` prefix; Alpine → `Alpine.fs` with `_x` prefix; Datastar → `Datastar.fs` with `_ds` prefix.
- **New doc pages**: Add markdown in `docs/src/Docs/docs/`, handler in `Handlers.fs`, route in `Program.fs`, nav link in `Views.fs`.
- **Tests** compare rendered HTML strings using `String.clean` for whitespace normalization. Use `// language=HTML` comment for IDE syntax highlighting in expected strings.

## Build System
- FAKE build scripts invoked via `fake.cmd`/`fake.sh`
- Paket for package management (`paket.dependencies` at root of `fsharp-view-engine/`)
- FAKE build scripts invoked via `fake.cmd`/`fake.sh` (separate in `lib/` and `docs/`)
- Paket for package management (`paket.dependencies` in `lib/` and `docs/`)
- .NET 10.0 SDK (`global.json`)
- CI: GitHub Actions — tests on PRs, NuGet publish on release tags (`v*.*.*`)

## Infrastructure (Pulumi)
- TypeScript Pulumi project in `pulumi/`
- Deploys demo app to Kubernetes via AWS ECR + Cloudflare Tunnel
- Deploys docs app to Kubernetes via AWS ECR
- Domain: `fsharpviewengine.meiermade.com`
- Stack references: `identity`, `infrastructure`, `fsharp-view-engine-identity`
File renamed without changes.
115 changes: 74 additions & 41 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
[![Release](https://github.com/meiermade/FSharp.ViewEngine/actions/workflows/release.yml/badge.svg)](https://github.com/meiermade/FSharp.ViewEngine/actions/workflows/release.yml)

<p align="center">
<img src="etc/logo.png" alt="FSharp.ViewEngine" width="128">
</p>

# FSharp.ViewEngine
View engine for F#. Inspired by [Giraffe.ViewEngine](https://github.com/giraffe-fsharp/Giraffe.ViewEngine) and
[Feliz.ViewEngine](https://github.com/dbrattli/Feliz.ViewEngine).
View engine for F#. Inspired by [Giraffe.ViewEngine](https://github.com/giraffe-fsharp/Giraffe.ViewEngine),
[Feliz.ViewEngine](https://github.com/dbrattli/Feliz.ViewEngine), and
[Oxpecker.ViewEngine](https://github.com/Lanayx/Oxpecker).
Documentation site built using FSharp.ViewEngine available at [https://fsharpviewengine.meiermade.com](https://fsharpviewengine.meiermade.com).
> See [App](./src/App) for the source code.
> See [docs/src/Docs](./docs/src/Docs) for the source code.

## Installation
Add the core view engine package.
Expand All @@ -18,47 +23,38 @@ open FSharp.ViewEngine
open type Html
open type Htmx
open type Alpine
open type Datastar
open type Tailwind

html [
html {
_lang "en"
_children [
head [
title "Test"
meta [ _charset "utf-8" ]
link [ _href "/css/compiled.css"; _rel "stylesheet" ]
]
body [
_xData "{showContent: false}"
_class "bg-gray-50"
_children [
div [
_id "page"
_class [ "flex"; "flex-col" ]
_children [
h1 [ _hxGet "/hello"; _hxTarget "#page"; _children "Hello" ]
h1 [ _hxGet "/world"; _hxTarget "#page"; _children "World" ]
]
]
br
div [
_xShow "showContent"
_children [
h2 [ _children "Content" ]
p [ _children "Some content" ]
ul [
_children [
li [ _children "One" ]
li [ _children "Two" ]
]
]
]
]
]
]
]
]
|> Element.render
head {
title "Test"
meta { _charset "utf-8" }
link { _href "/css/compiled.css"; _rel "stylesheet" }
}
body {
_xData "{showContent: false}"
_class "bg-gray-50"
div {
_id "page"
_class [ "flex"; "flex-col" ]
h1 { _hxGet "/hello"; _hxTarget "#page"; "Hello" }
h1 { _hxGet "/world"; _hxTarget "#page"; "World" }
}
br
div {
_xShow "showContent"
h2 { "Content" }
p { "Some content" }
ul {
li { "One" }
li { "Two" }
}
}
}
}
|> Render.toHtmlDocString
```
```html
<!DOCTYPE html>
Expand All @@ -85,3 +81,40 @@ html [
</body>
</html>
```

## Benchmarks
Ran on February 6, 2026 with BenchmarkDotNet MediumRun only.

Environment:
- Windows 11 (10.0.26200.7623)
- 12th Gen Intel Core i9-12900HK
- .NET SDK 10.0.102, .NET Runtime 10.0.2 (X64 RyuJIT AVX2)

Command:
```
cd lib && dotnet run -c Release --project src/Benchmarks/Benchmarks.fsproj
```

BuildAndRender (mean, lower is better):
| Method | Mean | Allocated |
|-------------- |----------:|----------:|
| ViewEngineApi | 5.763 μs | 11.4 KB |
| OxpeckerApi | 7.562 μs | 12.88 KB |
| GiraffeApi | 7.925 μs | 23.95 KB |
| FelizApi | 11.053 μs | 25.87 KB |

RenderOnly:
| Method | Mean | Allocated |
|-------------- |---------:|----------:|
| ViewEngineApi | 2.464 μs | 2.94 KB |
| OxpeckerApi | 2.796 μs | 2.94 KB |
| GiraffeApi | 3.176 μs | 12.77 KB |
| FelizApi | 6.151 μs | 14.2 KB |

BuildOnly:
| Method | Mean | Allocated |
|-------------- |---------:|----------:|
| ViewEngineApi | 2.153 μs | 8.46 KB |
| OxpeckerApi | 5.275 μs | 9.95 KB |
| GiraffeApi | 7.323 μs | 11.17 KB |
| FelizApi | 7.707 μs | 11.66 KB |
File renamed without changes.
5 changes: 5 additions & 0 deletions docs/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.paket
obj
bin
paket-files
*DotSettings.user
6 changes: 3 additions & 3 deletions fsharp-view-engine/Dockerfile → docs/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,12 @@ RUN dotnet paket install && dotnet paket restore
COPY src src
COPY fake.sh ./
RUN chmod +x fake.sh
RUN ./fake.sh PublishApp
RUN ./fake.sh Publish

FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled

WORKDIR /app

COPY --from=build /app/src/App/out .
COPY --from=build /app/src/Docs/out .

ENTRYPOINT ["dotnet", "App.dll"]
ENTRYPOINT ["dotnet", "Docs.dll"]
File renamed without changes.
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ source https://api.nuget.org/v3/index.json
storage: none

nuget Fake.Core.Target
nuget FSharp.Core
nuget Giraffe
nuget JetBrains.Annotations
nuget Markdig
nuget Expecto
13 changes: 1 addition & 12 deletions fsharp-view-engine/paket.lock → docs/paket.lock
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
STORAGE: NONE
NUGET
remote: https://api.nuget.org/v3/index.json
Expecto (10.2.3)
FSharp.Core (>= 7.0.200) - restriction: >= net6.0
Mono.Cecil (>= 0.11.4 < 1.0) - restriction: >= net6.0
Fake.Core.CommandLineParsing (6.1.4) - restriction: >= netstandard2.0
FParsec (>= 1.1.1) - restriction: >= netstandard2.0
FSharp.Core (>= 8.0.400) - restriction: >= netstandard2.0
Expand Down Expand Up @@ -48,7 +45,7 @@ NUGET
FSharp.Control.Reactive (6.1.2) - restriction: >= netstandard2.0
FSharp.Core (>= 6.0.7) - restriction: >= netstandard2.0
System.Reactive (>= 6.0.1) - restriction: >= netstandard2.0
FSharp.Core (10.0.102) - restriction: >= netstandard2.0
FSharp.Core (10.0.102)
FSharp.SystemTextJson (1.4.36) - restriction: >= net6.0
FSharp.Core (>= 4.7) - restriction: >= netstandard2.0
System.Text.Json (>= 6.0.10) - restriction: >= netstandard2.0
Expand All @@ -60,15 +57,10 @@ NUGET
System.Text.Json (>= 8.0.6) - restriction: >= net6.0
Giraffe.ViewEngine (1.4) - restriction: >= net6.0
FSharp.Core (>= 5.0) - restriction: >= netstandard2.0
JetBrains.Annotations (2025.2.4)
System.Runtime (>= 4.1) - restriction: && (< net20) (>= netstandard1.0) (< netstandard2.0)
Markdig (0.44)
System.Memory (>= 4.6.3) - restriction: || (>= net462) (&& (>= netstandard2.0) (< netstandard2.1))
Microsoft.Bcl.AsyncInterfaces (10.0.2) - restriction: || (&& (>= net462) (>= net6.0)) (&& (>= net6.0) (< net8.0))
Microsoft.IO.RecyclableMemoryStream (3.0.1) - restriction: >= net6.0
Microsoft.NETCore.Platforms (7.0.4) - restriction: || (&& (< monoandroid) (< net20) (>= netstandard1.0) (< netstandard1.2) (< win8) (< wp8)) (&& (< monoandroid) (< net20) (>= netstandard1.2) (< netstandard1.3) (< win8) (< wpa81)) (&& (< monoandroid) (< net20) (>= netstandard1.3) (< netstandard1.5) (< win8) (< wpa81)) (&& (< monotouch) (< net20) (>= netstandard1.5) (< netstandard2.0) (< win8) (< wpa81) (< xamarintvos) (< xamarinwatchos))
Microsoft.NETCore.Targets (5.0) - restriction: || (&& (< monoandroid) (< net20) (>= netstandard1.0) (< netstandard1.2) (< win8) (< wp8)) (&& (< monoandroid) (< net20) (>= netstandard1.2) (< netstandard1.3) (< win8) (< wpa81)) (&& (< monoandroid) (< net20) (>= netstandard1.3) (< netstandard1.5) (< win8) (< wpa81)) (&& (< monotouch) (< net20) (>= netstandard1.5) (< netstandard2.0) (< win8) (< wpa81) (< xamarintvos) (< xamarinwatchos))
Mono.Cecil (0.11.6) - restriction: >= net6.0
System.Buffers (4.6.1) - restriction: || (>= net462) (&& (>= net6.0) (< net8.0)) (&& (< netcoreapp2.1) (>= netstandard2.0) (< netstandard2.1))
System.Collections.Immutable (10.0.2) - restriction: >= netstandard2.0
System.Memory (>= 4.6.3) - restriction: || (>= net462) (&& (< net8.0) (>= netstandard2.0))
Expand All @@ -84,9 +76,6 @@ NUGET
System.Numerics.Vectors (4.6.1) - restriction: || (>= net462) (&& (< netcoreapp2.1) (>= netstandard2.0) (< netstandard2.1))
System.Reactive (6.1) - restriction: >= netstandard2.0
System.Threading.Tasks.Extensions (>= 4.5.4) - restriction: || (>= net472) (&& (< net6.0) (>= netstandard2.0)) (>= uap10.1)
System.Runtime (4.3.1) - restriction: && (< net20) (>= netstandard1.0) (< netstandard2.0)
Microsoft.NETCore.Platforms (>= 1.1.1) - restriction: || (&& (< monoandroid) (< net45) (>= netstandard1.0) (< netstandard1.2) (< win8) (< wp8)) (&& (< monoandroid) (< net45) (>= netstandard1.2) (< netstandard1.3) (< win8) (< wpa81)) (&& (< monoandroid) (< net45) (>= netstandard1.3) (< netstandard1.5) (< win8) (< wpa81)) (&& (< monotouch) (< net45) (>= netstandard1.5) (< win8) (< wpa81) (< xamarinios) (< xamarinmac) (< xamarintvos) (< xamarinwatchos))
Microsoft.NETCore.Targets (>= 1.1.3) - restriction: || (&& (< monoandroid) (< net45) (>= netstandard1.0) (< netstandard1.2) (< win8) (< wp8)) (&& (< monoandroid) (< net45) (>= netstandard1.2) (< netstandard1.3) (< win8) (< wpa81)) (&& (< monoandroid) (< net45) (>= netstandard1.3) (< netstandard1.5) (< win8) (< wpa81)) (&& (< monotouch) (< net45) (>= netstandard1.5) (< win8) (< wpa81) (< xamarinios) (< xamarinmac) (< xamarintvos) (< xamarinwatchos))
System.Runtime.CompilerServices.Unsafe (6.1.2) - restriction: || (>= net462) (&& (>= net6.0) (< net8.0)) (&& (< net8.0) (>= netstandard2.0)) (&& (< netcoreapp2.1) (>= netstandard2.0) (< netstandard2.1))
System.Text.Encodings.Web (10.0.2) - restriction: || (&& (< net10.0) (>= net9.0)) (&& (>= net462) (>= net6.0)) (&& (>= net6.0) (< net8.0)) (&& (>= net8.0) (< net9.0))
System.Buffers (>= 4.6.1) - restriction: || (>= net462) (&& (< net8.0) (>= netstandard2.0))
Expand Down
Loading
Loading