diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index ec22815..fe4dd39 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -1,4 +1,4 @@ -name: Pull Request +name: Pull Request on: pull_request: paths-ignore: @@ -8,14 +8,44 @@ jobs: name: Test runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Setup dotnet - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v5 with: - dotnet-version: 8.x + dotnet-version: 10.0.x - name: Install Tools run: dotnet tool restore + working-directory: ./fsharp-view-engine - name: Install Packages run: dotnet paket install + working-directory: ./fsharp-view-engine - name: Test run: ./fake.sh Test + working-directory: ./fsharp-view-engine + preview: + name: Preview + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + needs: + - test + steps: + - uses: actions/checkout@v5 + - name: Setup Node + uses: actions/setup-node@v5 + with: + node-version: 24 + - name: Install Packages + run: npm install + working-directory: ./pulumi + - name: Preview + uses: pulumi/actions@v6 + with: + work-dir: ./pulumi + command: preview + stack-name: prod + diff: true + comment-on-pr: true + env: + PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7db891c..8a5a16e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,4 @@ -name: Release +name: Release on: release: branches: @@ -10,16 +10,41 @@ jobs: name: Publish runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Setup dotnet - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v5 with: - dotnet-version: 8.x + dotnet-version: 10.0.x - name: Restore Tools run: dotnet tool restore + working-directory: ./fsharp-view-engine - name: Install Packages run: dotnet paket install + working-directory: ./fsharp-view-engine - name: Publish run: ./fake.sh Publish + working-directory: ./fsharp-view-engine env: NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + deploy: + name: Deploy + runs-on: ubuntu-latest + needs: + - publish + steps: + - uses: actions/checkout@v5 + - name: Setup Node + uses: actions/setup-node@v5 + with: + node-version: 24 + - name: Install Packages + run: npm install + working-directory: ./pulumi + - name: Update + uses: pulumi/actions@v6 + with: + work-dir: ./pulumi + command: up + stack-name: prod + env: + PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_TOKEN }} diff --git a/.gitignore b/.gitignore index 7017895..5eec986 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1 @@ -.idea -.vscode -.paket -obj -bin -nugets -paket-files -*DotSettings.user +.claude diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3dd6247 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,94 @@ +# CLAUDE.md + +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. + +## 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`) +- `.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`. + +```bash +# All dotnet/fake commands run from fsharp-view-engine/ +cd fsharp-view-engine + +# Restore tools and packages +dotnet tool restore +dotnet paket install + +# Run tests (uses Expecto, so dotnet run, not dotnet test) +./fake.sh Test +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 + +# Pulumi deployment +cd pulumi +npm install +pulumi up -s prod +``` + +## Architecture + +### Core Type System (`fsharp-view-engine/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 +- `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 +- `Tailwind.fs` — Tailwind UI custom elements on `Tailwind` type +- `Svg.fs` — SVG elements and attributes on `Svg` type + +### Usage Pattern +```fsharp +open FSharp.ViewEngine +open type Html +open type Htmx + +div [ _class "container"; _hxGet "/api"; _children [ h1 "Hello" ] ] +|> Element.render +``` + +### 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 +- `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. +- **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/`) +- .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 +- Domain: `fsharpviewengine.meiermade.com` +- Stack references: `identity`, `infrastructure`, `fsharp-view-engine-identity` diff --git a/WARP.md b/WARP.md deleted file mode 100644 index 8d8a49a..0000000 --- a/WARP.md +++ /dev/null @@ -1,115 +0,0 @@ -# WARP.md - -This file provides guidance to WARP (warp.dev) when working with code in this repository. - -## Project Overview -FSharp.ViewEngine is a view engine for F# web applications, inspired by Giraffe.ViewEngine and Feliz.ViewEngine. 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. - -## Core Architecture - -### Element System -The view engine is built around a discriminated union system: -- **Element**: The core type representing DOM elements (Text, Tag, Void, Fragment, Raw, Noop) -- **Attribute**: Represents HTML attributes (KeyValue, Boolean, Children, Noop) -- All HTML is generated through composition of these types - -### Module Structure -- `Core.fs`: Defines the core Element and Attribute types, plus the rendering engine -- `Html.fs`: Standard HTML elements and attributes (div, p, h1, _class, _id, etc.) -- `Htmx.fs`: HTMX-specific attributes (_hxGet, _hxPost, _hxTarget, etc.) -- `Alpine.fs`: Alpine.js directives (_xData, _xShow, _xModel, etc.) -- `Tailwind.fs`: Tailwind UI custom elements (el-select, el-dialog, etc.) -- `Svg.fs`: SVG elements and attributes - -## Common Development Commands - -### Build and Test -```bash -# Restore tools and packages -dotnet tool restore -dotnet paket install - -# Run tests (cross-platform) -./fake.sh Test # Linux/macOS -./fake.cmd Test # Windows - -# Alternative direct test command -dotnet test src/Tests/Tests.fsproj -``` - -### Package Management -```bash -# Add NuGet package dependencies -# Edit paket.dependencies file, then: -dotnet paket install - -# Update packages -dotnet paket update -``` - -### Building and Packaging -```bash -# Clean build artifacts -./fake.sh Clean # Linux/macOS -./fake.cmd Clean # Windows - -# Create NuGet package (requires GITHUB_REF_NAME environment variable) -./fake.sh Pack # Linux/macOS -./fake.cmd Pack # Windows -``` - -### Single Test Execution -To run a specific test, use xunit's filtering: -```bash -# Run specific test by name pattern -dotnet test src/Tests/Tests.fsproj --filter "Should render html document" -``` - -## Development Patterns - -### Creating New HTML Elements -When adding HTML elements to `Html.fs`, follow the established patterns: -- Standard elements: `static member elementName (attrs:Attribute seq) = Tag ("elementname", attrs)` -- Void elements (self-closing): Use `Void` instead of `Tag` -- Convenience overloads for common cases (e.g., `p (text:string)` for simple text paragraphs) - -### Adding Framework-Specific Attributes -- HTMX attributes go in `Htmx.fs` with `_hx` prefix -- Alpine.js directives go in `Alpine.fs` with `_x` prefix -- Use the `_frameworkPrefix` function pattern for consistency - -### Testing HTML Output -Tests use string comparison with whitespace normalization. The `String.clean` helper removes excess whitespace for reliable comparisons. Use the `// language=HTML` comment for syntax highlighting in expected output strings. - -## Key Dependencies -- **Paket**: Package management -- **FAKE**: Build automation via F# DSL -- **xUnit**: Testing framework -- **System.Web**: HTML encoding utilities - -## Usage Pattern -The library uses F# static type extensions with `open type` declarations: -```fsharp -open FSharp.ViewEngine -open type Html -open type Htmx -open type Alpine - -// Creates type-safe, composable HTML -div [ - _class "container" - _hxGet "/api/data" - _xData "{loading: false}" - _children [ - h1 [ _children "Hello World" ] - ] -] -|> Element.render -``` - -## Build System Notes -- Uses FAKE build system with F# build scripts in `src/Build/Program.fs` -- Cross-platform build scripts: `fake.sh` (Unix) and `fake.cmd` (Windows) -- Targets: Test, Clean, Pack, Publish -- Version extraction from Git tags for packaging -- CI/CD via GitHub Actions for PR validation and releases \ No newline at end of file diff --git a/etc/logo.svg b/etc/logo.svg deleted file mode 100644 index 27cb1cf..0000000 --- a/etc/logo.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - \ No newline at end of file diff --git a/.config/dotnet-tools.json b/fsharp-view-engine/.config/dotnet-tools.json similarity index 63% rename from .config/dotnet-tools.json rename to fsharp-view-engine/.config/dotnet-tools.json index b9052bc..4321530 100644 --- a/.config/dotnet-tools.json +++ b/fsharp-view-engine/.config/dotnet-tools.json @@ -3,10 +3,11 @@ "isRoot": true, "tools": { "paket": { - "version": "8.0.3", + "version": "10.3.1", "commands": [ "paket" - ] + ], + "rollForward": false } } } \ No newline at end of file diff --git a/fsharp-view-engine/.dockerignore b/fsharp-view-engine/.dockerignore new file mode 100644 index 0000000..a2233c6 --- /dev/null +++ b/fsharp-view-engine/.dockerignore @@ -0,0 +1,8 @@ +**/.git +**/.idea +**/.vs +**/bin +**/obj +**/nugets +**/nul +**/.claude diff --git a/fsharp-view-engine/.gitignore b/fsharp-view-engine/.gitignore new file mode 100644 index 0000000..7017895 --- /dev/null +++ b/fsharp-view-engine/.gitignore @@ -0,0 +1,8 @@ +.idea +.vscode +.paket +obj +bin +nugets +paket-files +*DotSettings.user diff --git a/fsharp-view-engine/AGENTS.md b/fsharp-view-engine/AGENTS.md new file mode 100644 index 0000000..dd9837d --- /dev/null +++ b/fsharp-view-engine/AGENTS.md @@ -0,0 +1 @@ +See [CLAUDE.md](CLAUDE.md) for project guidance. diff --git a/fsharp-view-engine/Dockerfile b/fsharp-view-engine/Dockerfile new file mode 100644 index 0000000..1f72440 --- /dev/null +++ b/fsharp-view-engine/Dockerfile @@ -0,0 +1,31 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0-noble-amd64 AS build + +WORKDIR /app + +# Install tailwindcss +RUN curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/download/v4.1.18/tailwindcss-linux-x64 \ + && chmod +x tailwindcss-linux-x64 \ + && mv tailwindcss-linux-x64 /usr/local/bin/tailwindcss + +RUN tailwindcss --version + +# Restore paket dependencies +COPY .config/ .config/ +RUN dotnet tool restore + +# Install F# dependencies +COPY paket.dependencies paket.lock ./ +RUN dotnet paket install && dotnet paket restore + +COPY src src +COPY fake.sh ./ +RUN chmod +x fake.sh +RUN ./fake.sh PublishApp + +FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled + +WORKDIR /app + +COPY --from=build /app/src/App/out . + +ENTRYPOINT ["dotnet", "App.dll"] diff --git a/FSharp.ViewEngine.sln b/fsharp-view-engine/FSharp.ViewEngine.sln similarity index 81% rename from FSharp.ViewEngine.sln rename to fsharp-view-engine/FSharp.ViewEngine.sln index bc6045c..01a0d46 100644 --- a/FSharp.ViewEngine.sln +++ b/fsharp-view-engine/FSharp.ViewEngine.sln @@ -11,6 +11,8 @@ Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Build", "src\Build\Build.fs EndProject Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Tests", "src\Tests\Tests.fsproj", "{65C2946A-1B1E-45AB-8196-DFE772FA9240}" EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "App", "src\App\App.fsproj", "{ADA5276C-21C3-4633-B83F-F50A93D5FF36}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -32,10 +34,15 @@ Global {65C2946A-1B1E-45AB-8196-DFE772FA9240}.Debug|Any CPU.Build.0 = Debug|Any CPU {65C2946A-1B1E-45AB-8196-DFE772FA9240}.Release|Any CPU.ActiveCfg = Release|Any CPU {65C2946A-1B1E-45AB-8196-DFE772FA9240}.Release|Any CPU.Build.0 = Release|Any CPU + {ADA5276C-21C3-4633-B83F-F50A93D5FF36}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {ADA5276C-21C3-4633-B83F-F50A93D5FF36}.Debug|Any CPU.Build.0 = Debug|Any CPU + {ADA5276C-21C3-4633-B83F-F50A93D5FF36}.Release|Any CPU.ActiveCfg = Release|Any CPU + {ADA5276C-21C3-4633-B83F-F50A93D5FF36}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {4093E9E9-D923-4945-A458-C1F861C3FD1D} = {EA093A44-5365-45ED-B8FB-C506C95405A6} {2562C7E1-2366-4DB0-9A86-278B8AA5117B} = {EA093A44-5365-45ED-B8FB-C506C95405A6} {65C2946A-1B1E-45AB-8196-DFE772FA9240} = {EA093A44-5365-45ED-B8FB-C506C95405A6} + {ADA5276C-21C3-4633-B83F-F50A93D5FF36} = {EA093A44-5365-45ED-B8FB-C506C95405A6} EndGlobalSection EndGlobal diff --git a/LICENSE b/fsharp-view-engine/LICENSE similarity index 100% rename from LICENSE rename to fsharp-view-engine/LICENSE diff --git a/README.md b/fsharp-view-engine/README.md similarity index 86% rename from README.md rename to fsharp-view-engine/README.md index ba451c3..2214ce8 100644 --- a/README.md +++ b/fsharp-view-engine/README.md @@ -1,10 +1,10 @@ -[![Release](https://github.com/ameier38/FSharp.ViewEngine/actions/workflows/release.yml/badge.svg)](https://github.com/ameier38/FSharp.ViewEngine/actions/workflows/release.yml) - -![logo](./etc/logo.svg) +[![Release](https://github.com/meiermade/FSharp.ViewEngine/actions/workflows/release.yml/badge.svg)](https://github.com/meiermade/FSharp.ViewEngine/actions/workflows/release.yml) # 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). +Documentation site built using FSharp.ViewEngine available at [https://fsharpviewengine.meiermade.com](https://fsharpviewengine.meiermade.com). +> See [App](./src/App) for the source code. ## Installation Add the core view engine package. diff --git a/fake.cmd b/fsharp-view-engine/fake.cmd similarity index 100% rename from fake.cmd rename to fsharp-view-engine/fake.cmd diff --git a/fake.sh b/fsharp-view-engine/fake.sh similarity index 100% rename from fake.sh rename to fsharp-view-engine/fake.sh diff --git a/global.json b/fsharp-view-engine/global.json similarity index 66% rename from global.json rename to fsharp-view-engine/global.json index 501e79a..f72210c 100644 --- a/global.json +++ b/fsharp-view-engine/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "8.0.100", + "version": "10.0.100", "rollForward": "latestFeature" } } \ No newline at end of file diff --git a/fsharp-view-engine/paket.dependencies b/fsharp-view-engine/paket.dependencies new file mode 100644 index 0000000..17c8c0d --- /dev/null +++ b/fsharp-view-engine/paket.dependencies @@ -0,0 +1,8 @@ +source https://api.nuget.org/v3/index.json +storage: none + +nuget Fake.Core.Target +nuget Giraffe +nuget JetBrains.Annotations +nuget Markdig +nuget Expecto diff --git a/fsharp-view-engine/paket.lock b/fsharp-view-engine/paket.lock new file mode 100644 index 0000000..6047145 --- /dev/null +++ b/fsharp-view-engine/paket.lock @@ -0,0 +1,104 @@ +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 + Fake.Core.Context (6.1.4) - restriction: >= netstandard2.0 + FSharp.Core (>= 8.0.400) - restriction: >= netstandard2.0 + Fake.Core.Environment (6.1.4) - restriction: >= netstandard2.0 + FSharp.Core (>= 8.0.400) - restriction: >= netstandard2.0 + Fake.Core.FakeVar (6.1.4) - restriction: >= netstandard2.0 + Fake.Core.Context (>= 6.1.4) - restriction: >= netstandard2.0 + FSharp.Core (>= 8.0.400) - restriction: >= netstandard2.0 + Fake.Core.Process (6.1.4) - restriction: >= netstandard2.0 + Fake.Core.Environment (>= 6.1.4) - restriction: >= netstandard2.0 + Fake.Core.FakeVar (>= 6.1.4) - restriction: >= netstandard2.0 + Fake.Core.String (>= 6.1.4) - restriction: >= netstandard2.0 + Fake.Core.Trace (>= 6.1.4) - restriction: >= netstandard2.0 + Fake.IO.FileSystem (>= 6.1.4) - restriction: >= netstandard2.0 + FSharp.Core (>= 8.0.400) - restriction: >= netstandard2.0 + System.Collections.Immutable (>= 8.0) - restriction: >= netstandard2.0 + Fake.Core.String (6.1.4) - restriction: >= netstandard2.0 + FSharp.Core (>= 8.0.400) - restriction: >= netstandard2.0 + Fake.Core.Target (6.1.4) + Fake.Core.CommandLineParsing (>= 6.1.4) - restriction: >= netstandard2.0 + Fake.Core.Context (>= 6.1.4) - restriction: >= netstandard2.0 + Fake.Core.Environment (>= 6.1.4) - restriction: >= netstandard2.0 + Fake.Core.FakeVar (>= 6.1.4) - restriction: >= netstandard2.0 + Fake.Core.Process (>= 6.1.4) - restriction: >= netstandard2.0 + Fake.Core.String (>= 6.1.4) - restriction: >= netstandard2.0 + Fake.Core.Trace (>= 6.1.4) - restriction: >= netstandard2.0 + FSharp.Control.Reactive (>= 5.0.2) - restriction: >= netstandard2.0 + FSharp.Core (>= 8.0.400) - restriction: >= netstandard2.0 + Fake.Core.Trace (6.1.4) - restriction: >= netstandard2.0 + Fake.Core.Environment (>= 6.1.4) - restriction: >= netstandard2.0 + Fake.Core.FakeVar (>= 6.1.4) - restriction: >= netstandard2.0 + FSharp.Core (>= 8.0.400) - restriction: >= netstandard2.0 + Fake.IO.FileSystem (6.1.4) - restriction: >= netstandard2.0 + Fake.Core.String (>= 6.1.4) - restriction: >= netstandard2.0 + Fake.Core.Trace (>= 6.1.4) - restriction: >= netstandard2.0 + FSharp.Core (>= 8.0.400) - restriction: >= netstandard2.0 + FParsec (1.1.1) - restriction: >= netstandard2.0 + FSharp.Core (>= 4.3.4) - restriction: || (>= net45) (>= netstandard2.0) + System.ValueTuple (>= 4.4) - restriction: >= net45 + 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.SystemTextJson (1.4.36) - restriction: >= net6.0 + FSharp.Core (>= 4.7) - restriction: >= netstandard2.0 + System.Text.Json (>= 6.0.10) - restriction: >= netstandard2.0 + Giraffe (8.2) + FSharp.Core (>= 6.0) - restriction: >= net6.0 + FSharp.SystemTextJson (>= 1.3.13) - restriction: >= net6.0 + Giraffe.ViewEngine (>= 1.4) - restriction: >= net6.0 + Microsoft.IO.RecyclableMemoryStream (>= 3.0.1) - restriction: >= net6.0 + 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)) + System.Runtime.CompilerServices.Unsafe (>= 6.1.2) - restriction: || (>= net462) (&& (< net8.0) (>= netstandard2.0)) + System.IO.Pipelines (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)) + System.Memory (>= 4.6.3) - restriction: || (>= net462) (&& (< net8.0) (>= netstandard2.0)) + System.Threading.Tasks.Extensions (>= 4.6.3) - restriction: || (>= net462) (&& (< net8.0) (>= netstandard2.0)) + System.Memory (4.6.3) - restriction: || (>= net462) (&& (>= net6.0) (< net8.0)) (&& (< net8.0) (>= netstandard2.0)) (&& (>= netstandard2.0) (< netstandard2.1)) + System.Buffers (>= 4.6.1) - restriction: || (>= net462) (&& (< netcoreapp2.1) (>= netstandard2.0) (< netstandard2.1)) + System.Numerics.Vectors (>= 4.6.1) - restriction: || (>= net462) (&& (< netcoreapp2.1) (>= netstandard2.0) (< netstandard2.1)) + System.Runtime.CompilerServices.Unsafe (>= 6.1.2) - restriction: || (>= net462) (&& (< netcoreapp2.1) (>= netstandard2.0) (< netstandard2.1)) + 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)) + System.Memory (>= 4.6.3) - restriction: || (>= net462) (&& (< net8.0) (>= netstandard2.0)) + System.Runtime.CompilerServices.Unsafe (>= 6.1.2) - restriction: || (>= net462) (&& (< net8.0) (>= netstandard2.0)) + System.Text.Json (10.0.2) - restriction: >= net6.0 + Microsoft.Bcl.AsyncInterfaces (>= 10.0.2) - restriction: || (>= net462) (&& (< net8.0) (>= netstandard2.0)) + System.Buffers (>= 4.6.1) - restriction: || (>= net462) (&& (< net8.0) (>= netstandard2.0)) + System.IO.Pipelines (>= 10.0.2) - restriction: || (&& (< net10.0) (>= net9.0)) (>= net462) (&& (>= net8.0) (< net9.0)) (&& (< net8.0) (>= netstandard2.0)) + System.Memory (>= 4.6.3) - restriction: || (>= net462) (&& (< net8.0) (>= netstandard2.0)) + System.Runtime.CompilerServices.Unsafe (>= 6.1.2) - restriction: || (>= net462) (&& (< net8.0) (>= netstandard2.0)) + System.Text.Encodings.Web (>= 10.0.2) - restriction: || (&& (< net10.0) (>= net9.0)) (>= net462) (&& (>= net8.0) (< net9.0)) (&& (< net8.0) (>= netstandard2.0)) + System.Threading.Tasks.Extensions (>= 4.6.3) - restriction: || (>= net462) (&& (< net8.0) (>= netstandard2.0)) + System.Threading.Tasks.Extensions (4.6.3) - restriction: || (&& (>= net462) (>= net6.0)) (>= net472) (&& (>= net6.0) (< net8.0)) (&& (< net6.0) (>= netstandard2.0)) (&& (>= netstandard2.0) (>= uap10.1)) + System.ValueTuple (4.6.1) - restriction: && (>= net45) (>= netstandard2.0) diff --git a/fsharp-view-engine/src/App/App.fsproj b/fsharp-view-engine/src/App/App.fsproj new file mode 100644 index 0000000..7548c50 --- /dev/null +++ b/fsharp-view-engine/src/App/App.fsproj @@ -0,0 +1,33 @@ + + + + net10.0 + $(NoWarn);NU1510 + + + + + + + + + + + PreserveNewest + + + true + PreserveNewest + + + + <_ContentIncludedByDefault Remove="Properties\launchSettings.json" /> + + + + + + + + + \ No newline at end of file diff --git a/fsharp-view-engine/src/App/Config.fs b/fsharp-view-engine/src/App/Config.fs new file mode 100644 index 0000000..eda2226 --- /dev/null +++ b/fsharp-view-engine/src/App/Config.fs @@ -0,0 +1,13 @@ +namespace App + +open System + +type Config = + { serverUrl: string } + +module Config = + let load () = + { serverUrl = + Environment.GetEnvironmentVariable("SERVER_URL") + |> Option.ofObj + |> Option.defaultValue "https://localhost:5000" } diff --git a/fsharp-view-engine/src/App/Handlers.fs b/fsharp-view-engine/src/App/Handlers.fs new file mode 100644 index 0000000..d2a1ef4 --- /dev/null +++ b/fsharp-view-engine/src/App/Handlers.fs @@ -0,0 +1,39 @@ +namespace App + +open System +open System.IO +open Giraffe +open Microsoft.AspNetCore.Http +open Markdig +open FSharp.ViewEngine + +module Handlers = + + let private markdownPipeline = MarkdownPipelineBuilder().UseAdvancedExtensions().Build() + + let private readMarkdownFile (fileName: string) = + let filePath = Path.Combine(AppContext.BaseDirectory, "docs", fileName + ".md") + if File.Exists(filePath) then + let content = File.ReadAllText(filePath) + Markdown.ToHtml(content, markdownPipeline) + else + "

Page Not Found

The requested page could not be found.

" + + let private renderPage (title: string) (fileName: string) : HttpHandler = + fun next ctx -> task { + let currentPath = ctx.Request.Path.Value + let markdownContent = readMarkdownFile fileName + let content = Views.layout title currentPath markdownContent + let html = Element.render content + return! htmlString html next ctx + } + + // Route handlers + let homeHandler : HttpHandler = + renderPage "FSharp.ViewEngine Documentation" "home" + + let installationHandler : HttpHandler = + renderPage "Installation - FSharp.ViewEngine" "installation" + + let quickstartHandler : HttpHandler = + renderPage "Quickstart - FSharp.ViewEngine" "quickstart" diff --git a/fsharp-view-engine/src/App/Program.fs b/fsharp-view-engine/src/App/Program.fs new file mode 100644 index 0000000..306730c --- /dev/null +++ b/fsharp-view-engine/src/App/Program.fs @@ -0,0 +1,39 @@ +open Microsoft.AspNetCore.Builder +open Microsoft.Extensions.Hosting +open Microsoft.Extensions.DependencyInjection +open Giraffe +open App.Handlers + +let webApp = + choose [ + GET >=> choose [ + route "/" >=> homeHandler + route "/installation" >=> installationHandler + route "/quickstart" >=> quickstartHandler + ] + ] + +let configureApp (app : IApplicationBuilder) = + app.UseStaticFiles() |> ignore + app.UseGiraffe(webApp) + +let configureServices (services : IServiceCollection) = + services.AddGiraffe() |> ignore + +[] +let main args = + let builder = WebApplication.CreateBuilder(args) + configureServices builder.Services + + let app = builder.Build() + + if app.Environment.IsDevelopment() then + app.UseDeveloperExceptionPage() |> ignore + + configureApp app + + let config = App.Config.load() + app.Run(config.serverUrl) + + 0 // Exit code + diff --git a/fsharp-view-engine/src/App/Views.fs b/fsharp-view-engine/src/App/Views.fs new file mode 100644 index 0000000..5a335a4 --- /dev/null +++ b/fsharp-view-engine/src/App/Views.fs @@ -0,0 +1,324 @@ +module App.Views + +open FSharp.ViewEngine +open type Html +open type Alpine + +type Page = + { title:string } + +let magnifyingGlassIcon = raw """ + + + + """ +let menuIcon = raw """ + + + + """ +let githubIcon = raw """""" +let sunIcon = raw """""" +let moonIcon = raw """""" +let sunIconSmall = raw """""" +let moonIconSmall = raw """""" +let monitorIcon = raw """""" + +let private pageHeader = + header [ + _class [ + "sticky top-0 z-50 flex flex-none flex-wrap items-center justify-between" + "bg-white px-4 py-5 shadow-md shadow-slate-900/5 transition duration-500" + "sm:px-6 lg:px-8 dark:shadow-none dark:bg-slate-900/75 dark:backdrop-blur" + ] + _children [ + // Left section: hamburger + logo + div [ + _class "flex items-center gap-4" + _children [ + // Mobile menu button (hidden on desktop) + div [ + _class "flex lg:hidden" + _children [ + button [ + _type "button" + _class "relative text-slate-500 hover:text-slate-600 dark:text-slate-400 dark:hover:text-slate-300" + _children menuIcon + ] + ] + ] + // Logo + a [ + _href "/" + _class "text-sm font-semibold tracking-wider text-slate-700 dark:text-white" + _children "FSharp.ViewEngine" + ] + ] + ] + // Right section with theme toggle and GitHub + div [ + _class "relative flex basis-0 justify-end gap-6 sm:gap-8 md:grow" + _children [ + // Theme toggle dropdown + div [ + _class "relative z-10" + _xData "{ open: false, theme: localStorage.getItem('theme') || 'system' }" + _xInit """ + $watch('theme', (val) => { + localStorage.setItem('theme', val); + if (val === 'dark' || (val === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) { + document.documentElement.classList.add('dark'); + } else { + document.documentElement.classList.remove('dark'); + } + }); + if (theme === 'dark' || (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) { + document.documentElement.classList.add('dark'); + } + """ + _children [ + button [ + _type "button" + _class [ + "flex h-6 w-6 items-center justify-center rounded-lg shadow-md ring-1" + "shadow-black/5 ring-black/5 dark:bg-slate-700 dark:ring-white/5" + "dark:ring-inset" + ] + _xOn ("click", "open = !open") + _children [ + // Light mode icon (sun) + sunIcon + moonIcon + ] + ] + // Dropdown menu + div [ + _xShow "open" + _xOn ("click.away", "open = false") + _xTransition () + _class [ + "absolute right-0 top-full mt-3 w-36 overflow-hidden rounded-lg" + "bg-white py-1 text-sm font-semibold text-slate-700 shadow-lg ring-1" + "ring-slate-900/10 dark:bg-slate-800 dark:text-slate-300 dark:ring-0" + "dark:highlight-white/5" + ] + _children [ + // Light option + button [ + _type "button" + _class "flex w-full items-center gap-2 px-3 py-2 hover:bg-slate-100 dark:hover:bg-slate-700/50" + _xOn ("click", "theme = 'light'; open = false") + _xBind ("class", "theme === 'light' ? 'text-sky-500' : ''") + _children [ + sunIconSmall + text "Light" + ] + ] + // Dark option + button [ + _type "button" + _class "flex w-full items-center gap-2 px-3 py-2 hover:bg-slate-100 dark:hover:bg-slate-700/50" + _xOn ("click", "theme = 'dark'; open = false") + _xBind ("class", "theme === 'dark' ? 'text-sky-500' : ''") + _children [ + moonIconSmall + text "Dark" + ] + ] + // System option + button [ + _type "button" + _class "flex w-full items-center gap-2 px-3 py-2 hover:bg-slate-100 dark:hover:bg-slate-700/50" + _xOn ("click", "theme = 'system'; open = false") + _xBind ("class", "theme === 'system' ? 'text-sky-500' : ''") + _children [ + monitorIcon + text "System" + ] + ] + ] + ] + ] + ] + // GitHub link + a [ + _href "https://github.com/meiermade/FSharp.ViewEngine" + _class "group" + _children [ + githubIcon + ] + ] + ] + ] + ] + ] + +let private navLink (currentPath: string) (href: string) (label: string) = + let isActive = currentPath = href + li [ + _class "relative" + _children [ + a [ + _class [ + "block w-full pl-3.5 before:pointer-events-none before:absolute" + "before:-left-1 before:top-1/2 before:h-1.5 before:w-1.5" + "before:-translate-y-1/2 before:rounded-full" + if isActive then + "font-semibold text-sky-500 before:bg-sky-500" + else + "text-slate-500 before:hidden before:bg-slate-300 hover:text-slate-600" + + " hover:before:block dark:text-slate-400 dark:before:bg-slate-700 dark:hover:text-slate-300" + ] + _href href + _children label + ] + ] + ] + +let private sidebarNavigation (currentPath: string) = + nav [ + _class "text-base lg:text-sm" + _children [ + ul [ + _role "list" + _class "space-y-9" + _children [ + li [ + _children [ + h2 [ + _class "font-display font-medium text-slate-900 dark:text-white" + _children "Getting started" + ] + ul [ + _role "list" + _class [ + "mt-2 space-y-2 border-l-2 border-slate-100" + "lg:mt-4 lg:space-y-4 lg:border-slate-200 dark:border-slate-800" + ] + _children [ + navLink currentPath "/" "Introduction" + navLink currentPath "/installation" "Installation" + navLink currentPath "/quickstart" "Quickstart" + ] + ] + ] + ] + ] + ] + ] + ] + +let private sidebar (currentPath: string) = + div [ + _class "hidden lg:relative lg:block lg:flex-none" + _children [ + div [ + _class [ + "sticky top-[4.75rem] -ml-0.5 h-[calc(100vh-4.75rem)] w-64" + "overflow-y-auto overflow-x-hidden py-16 pl-0.5 pr-8 xl:w-72 xl:pr-16" + ] + _children [ + sidebarNavigation currentPath + ] + ] + ] + ] + +let private tableOfContents (headings: (string * string) list) = + if List.isEmpty headings then + empty + else + nav [ + _class "sticky top-[4.75rem] -mr-6 w-56 flex-none overflow-y-auto py-16 pr-6" + _children [ + h2 [ + _class "font-display text-sm font-medium text-zinc-900 dark:text-white" + _children "On this page" + ] + ul [ + _role "list" + _class "mt-4 space-y-3 text-sm" + _children [ + for (title, anchor) in headings do + yield li [ + _children [ + a [ + _href $"#{anchor}" + _class "text-zinc-500 hover:text-zinc-600 dark:text-zinc-400 dark:hover:text-zinc-300" + _children title + ] + ] + ] + ] + ] + ] + ] + +let layout (pageTitle: string) (currentPath: string) (content: string) = + html [ + _lang "en" + _class "h-full antialiased" + _children [ + head [ + meta [ _charset "utf-8" ] + meta [ _name "viewport"; _content "width=device-width, initial-scale=1" ] + title pageTitle + script "let t=localStorage.getItem('theme');if(t==='dark'||(!t||t==='system')&&window.matchMedia('(prefers-color-scheme: dark)').matches){document.documentElement.classList.add('dark')}" + link [ _rel "stylesheet"; _href "/css/output.css" ] + script [ _src "https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"; KeyValue ("defer", "true") ] + script [ _src "https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js" ] + link [ _rel "stylesheet"; _href "https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-tomorrow.min.css" ] + script [ _src "https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-fsharp.min.js" ] + ] + body [ + _class "min-h-full bg-white dark:bg-slate-900" + _children [ + pageHeader + div [ + _class [ + "relative mx-auto flex max-w-8xl justify-center" + "sm:px-2 lg:px-8 xl:px-12" + ] + _children [ + sidebar currentPath + div [ + _class [ + "min-w-0 max-w-3xl flex-auto px-4 py-16" + "lg:max-w-none lg:pl-8 lg:pr-0 xl:px-16" + ] + _children [ + article [ + _children [ + div [ + _class "mb-8" + _children [ + p [ + _class "font-display text-sm font-medium text-sky-500" + _children "Getting started" + ] + ] + ] + div [ + _class "prose prose-slate dark:prose-invert max-w-none" + _children [ raw content ] + ] + ] + ] + ] + ] + div [ + _class [ + "hidden xl:sticky xl:top-[4.75rem] xl:-mr-6 xl:block" + "xl:h-[calc(100vh-4.75rem)] xl:flex-none xl:overflow-y-auto" + "xl:py-16 xl:pr-6" + ] + _children [ + tableOfContents [] + ] + ] + ] + ] + ] + ] + ] + ] diff --git a/fsharp-view-engine/src/App/docs/home.md b/fsharp-view-engine/src/App/docs/home.md new file mode 100644 index 0000000..9ef741d --- /dev/null +++ b/fsharp-view-engine/src/App/docs/home.md @@ -0,0 +1,62 @@ +# FSharp.ViewEngine Documentation + +A powerful F# view engine for building HTML with type safety and composability. Inspired by Giraffe.ViewEngine and Feliz.ViewEngine. + +## Key Features + +- **Type-safe HTML generation** with F# +- **Built-in support for HTMX attributes** +- **Tailwind CSS integration** +- **Alpine.js directives support** +- **Composable and functional approach** +- **No runtime dependencies** + +## Quick Example + +```fsharp +open FSharp.ViewEngine +open type Html +open type Htmx +open type Tailwind + +let myPage = + html [ + _lang "en" + _children [ + head [ + title "My App" + meta [ _charset "utf-8" ] + link [ _href "/css/tailwind.css"; _rel "stylesheet" ] + ] + body [ + _class "bg-gray-100" + _children [ + div [ + _class [ "container"; "mx-auto"; "p-4" ] + _children [ + h1 [ + _class [ "text-3xl"; "font-bold"; "text-blue-600"; "mb-4" ] + _children "Welcome!" + ] + button [ + _class [ "bg-blue-500"; "text-white"; "px-4"; "py-2"; "rounded" ] + _hxGet "/api/data" + _hxTarget "#content" + _children "Load Content" + ] + div [ + _id "content" + _class [ "mt-4" ] + ] + ] + ] + ] + ] + ] + ] + |> Element.render +``` + +## Getting Started + +To get started with FSharp.ViewEngine, check out the [Installation](installation) guide and then follow the [Quickstart](quickstart) tutorial. \ No newline at end of file diff --git a/fsharp-view-engine/src/App/docs/installation.md b/fsharp-view-engine/src/App/docs/installation.md new file mode 100644 index 0000000..2da1d33 --- /dev/null +++ b/fsharp-view-engine/src/App/docs/installation.md @@ -0,0 +1,40 @@ +# Installation + +FSharp.ViewEngine is distributed as a NuGet package. You can install it using your preferred package manager. + +## Using .NET CLI + +```bash +dotnet add package FSharp.ViewEngine +``` + +## Using Paket + +Add to your `paket.dependencies`: + +```text +nuget FSharp.ViewEngine +``` + +Then add to your `paket.references`: + +```text +FSharp.ViewEngine +``` + +## Using PackageReference + +Add to your `.fsproj` file: + +```xml + +``` + +## Requirements + +- .NET 10.0 or later +- F# 10 or later + +## Next Steps + +Once you have FSharp.ViewEngine installed, head over to the [Quickstart](quickstart) guide to start building your first HTML views. \ No newline at end of file diff --git a/fsharp-view-engine/src/App/docs/quickstart.md b/fsharp-view-engine/src/App/docs/quickstart.md new file mode 100644 index 0000000..76ab000 --- /dev/null +++ b/fsharp-view-engine/src/App/docs/quickstart.md @@ -0,0 +1,98 @@ +# Quickstart + +Get started with FSharp.ViewEngine in just a few steps. + +## Basic Usage + +Import the core modules and start building HTML: + +```fsharp +open FSharp.ViewEngine +open type Html + +let myView = + div [ + _class "container" + _children [ + h1 [ _children "Hello, World!" ] + p [ _children "Welcome to FSharp.ViewEngine" ] + ] + ] + +// Render to string +let htmlString = Element.render myView +``` + +This will produce the following HTML: + +```html +
+

Hello, World!

+

Welcome to FSharp.ViewEngine

+
+``` + +## With HTMX and Tailwind CSS + +FSharp.ViewEngine includes built-in support for HTMX and Tailwind CSS: + +```fsharp +open FSharp.ViewEngine +open type Html +open type Htmx +open type Tailwind + +let interactiveView = + div [ + _class [ "bg-blue-500"; "text-white"; "p-4"; "rounded" ] + _children [ + button [ + _class [ "bg-green-500"; "hover:bg-green-700"; "px-4"; "py-2"; "rounded" ] + _hxGet "/api/data" + _hxTarget "#result" + _children "Load Data" + ] + div [ + _id "result" + _class [ "mt-4" ] + ] + ] + ] +``` + +## Complete HTML Document + +Here's how to create a complete HTML document: + +```fsharp +let completePage = + html [ + _lang "en" + _children [ + head [ + title [ _children "My Page" ] + meta [ _charset "utf-8" ] + meta [ _name "viewport"; _content "width=device-width, initial-scale=1" ] + link [ _rel "stylesheet"; _href "https://cdn.tailwindcss.com" ] + ] + body [ + _class [ "bg-gray-100"; "font-sans" ] + _children [ + div [ + _class [ "container"; "mx-auto"; "px-4"; "py-8" ] + _children [ + h1 [ + _class [ "text-3xl"; "font-bold"; "text-gray-900"; "mb-4" ] + _children "Welcome to my site!" + ] + p [ + _class [ "text-lg"; "text-gray-600" ] + _children "This is built with FSharp.ViewEngine." + ] + ] + ] + ] + ] + ] + ] +``` \ No newline at end of file diff --git a/fsharp-view-engine/src/App/input.css b/fsharp-view-engine/src/App/input.css new file mode 100644 index 0000000..6300d01 --- /dev/null +++ b/fsharp-view-engine/src/App/input.css @@ -0,0 +1,56 @@ +@import "tailwindcss"; +@source "./{*.fs,**/*.fs}"; +@plugin "@tailwindcss/typography"; +@custom-variant dark (&:where(.dark, .dark *)); + +/* Custom styles inspired by Tailwind CSS docs */ +.prose pre { + position: relative; + border-radius: 0.75rem; + background-color: rgb(15 23 42); + border: 1px solid rgb(30 41 59); + box-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1); + overflow-x: auto; +} + +.prose pre code { + background-color: transparent !important; + border-radius: 0; + padding: 0; + color: rgb(226 232 240); + font-size: 0.875rem; + line-height: 1.7; + font-family: 'Fira Code', 'JetBrains Mono', 'Monaco', 'Cascadia Code', 'Segoe UI Mono', 'Roboto Mono', 'Oxygen Mono', 'Ubuntu Monospace', 'Source Code Pro', 'Fira Mono', 'Droid Sans Mono', 'Courier New', monospace; +} + +/* Add subtle syntax highlighting for F# code */ +.prose pre code .hljs-keyword { + color: rgb(168 85 247); +} + +.prose pre code .hljs-string { + color: rgb(34 197 94); +} + +.prose pre code .hljs-comment { + color: rgb(148 163 184); + font-style: italic; +} + +.prose pre code .hljs-function { + color: rgb(59 130 246); +} + +.prose pre code .hljs-number { + color: rgb(251 146 60); +} + +/* Better inline code styling */ +.prose :not(pre) > code { + background-color: rgb(51 65 85) !important; + color: rgb(226 232 240) !important; + padding: 0.25rem 0.5rem !important; + border-radius: 0.375rem !important; + font-size: 0.875rem !important; + font-weight: 500 !important; +} diff --git a/fsharp-view-engine/src/App/paket.references b/fsharp-view-engine/src/App/paket.references new file mode 100644 index 0000000..94e7207 --- /dev/null +++ b/fsharp-view-engine/src/App/paket.references @@ -0,0 +1,2 @@ +Giraffe +Markdig diff --git a/fsharp-view-engine/src/App/wwwroot/scripts/alpinejs.3.15.0.min.js b/fsharp-view-engine/src/App/wwwroot/scripts/alpinejs.3.15.0.min.js new file mode 100644 index 0000000..0acdcef --- /dev/null +++ b/fsharp-view-engine/src/App/wwwroot/scripts/alpinejs.3.15.0.min.js @@ -0,0 +1,5 @@ +(()=>{var nt=!1,it=!1,W=[],ot=-1;function Ut(e){Rn(e)}function Rn(e){W.includes(e)||W.push(e),Mn()}function Wt(e){let t=W.indexOf(e);t!==-1&&t>ot&&W.splice(t,1)}function Mn(){!it&&!nt&&(nt=!0,queueMicrotask(Nn))}function Nn(){nt=!1,it=!0;for(let e=0;ee.effect(t,{scheduler:r=>{st?Ut(r):r()}}),at=e.raw}function ct(e){N=e}function Yt(e){let t=()=>{};return[n=>{let i=N(n);return e._x_effects||(e._x_effects=new Set,e._x_runEffects=()=>{e._x_effects.forEach(o=>o())}),e._x_effects.add(i),t=()=>{i!==void 0&&(e._x_effects.delete(i),$(i))},i},()=>{t()}]}function ve(e,t){let r=!0,n,i=N(()=>{let o=e();JSON.stringify(o),r?n=o:queueMicrotask(()=>{t(o,n),n=o}),r=!1});return()=>$(i)}var Xt=[],Zt=[],Qt=[];function er(e){Qt.push(e)}function te(e,t){typeof t=="function"?(e._x_cleanups||(e._x_cleanups=[]),e._x_cleanups.push(t)):(t=e,Zt.push(t))}function Ae(e){Xt.push(e)}function Oe(e,t,r){e._x_attributeCleanups||(e._x_attributeCleanups={}),e._x_attributeCleanups[t]||(e._x_attributeCleanups[t]=[]),e._x_attributeCleanups[t].push(r)}function lt(e,t){e._x_attributeCleanups&&Object.entries(e._x_attributeCleanups).forEach(([r,n])=>{(t===void 0||t.includes(r))&&(n.forEach(i=>i()),delete e._x_attributeCleanups[r])})}function tr(e){for(e._x_effects?.forEach(Wt);e._x_cleanups?.length;)e._x_cleanups.pop()()}var ut=new MutationObserver(mt),ft=!1;function ue(){ut.observe(document,{subtree:!0,childList:!0,attributes:!0,attributeOldValue:!0}),ft=!0}function dt(){kn(),ut.disconnect(),ft=!1}var le=[];function kn(){let e=ut.takeRecords();le.push(()=>e.length>0&&mt(e));let t=le.length;queueMicrotask(()=>{if(le.length===t)for(;le.length>0;)le.shift()()})}function m(e){if(!ft)return e();dt();let t=e();return ue(),t}var pt=!1,Se=[];function rr(){pt=!0}function nr(){pt=!1,mt(Se),Se=[]}function mt(e){if(pt){Se=Se.concat(e);return}let t=[],r=new Set,n=new Map,i=new Map;for(let o=0;o{s.nodeType===1&&s._x_marker&&r.add(s)}),e[o].addedNodes.forEach(s=>{if(s.nodeType===1){if(r.has(s)){r.delete(s);return}s._x_marker||t.push(s)}})),e[o].type==="attributes")){let s=e[o].target,a=e[o].attributeName,c=e[o].oldValue,l=()=>{n.has(s)||n.set(s,[]),n.get(s).push({name:a,value:s.getAttribute(a)})},u=()=>{i.has(s)||i.set(s,[]),i.get(s).push(a)};s.hasAttribute(a)&&c===null?l():s.hasAttribute(a)?(u(),l()):u()}i.forEach((o,s)=>{lt(s,o)}),n.forEach((o,s)=>{Xt.forEach(a=>a(s,o))});for(let o of r)t.some(s=>s.contains(o))||Zt.forEach(s=>s(o));for(let o of t)o.isConnected&&Qt.forEach(s=>s(o));t=null,r=null,n=null,i=null}function Ce(e){return z(B(e))}function k(e,t,r){return e._x_dataStack=[t,...B(r||e)],()=>{e._x_dataStack=e._x_dataStack.filter(n=>n!==t)}}function B(e){return e._x_dataStack?e._x_dataStack:typeof ShadowRoot=="function"&&e instanceof ShadowRoot?B(e.host):e.parentNode?B(e.parentNode):[]}function z(e){return new Proxy({objects:e},Dn)}var Dn={ownKeys({objects:e}){return Array.from(new Set(e.flatMap(t=>Object.keys(t))))},has({objects:e},t){return t==Symbol.unscopables?!1:e.some(r=>Object.prototype.hasOwnProperty.call(r,t)||Reflect.has(r,t))},get({objects:e},t,r){return t=="toJSON"?Pn:Reflect.get(e.find(n=>Reflect.has(n,t))||{},t,r)},set({objects:e},t,r,n){let i=e.find(s=>Object.prototype.hasOwnProperty.call(s,t))||e[e.length-1],o=Object.getOwnPropertyDescriptor(i,t);return o?.set&&o?.get?o.set.call(n,r)||!0:Reflect.set(i,t,r)}};function Pn(){return Reflect.ownKeys(this).reduce((t,r)=>(t[r]=Reflect.get(this,r),t),{})}function Te(e){let t=n=>typeof n=="object"&&!Array.isArray(n)&&n!==null,r=(n,i="")=>{Object.entries(Object.getOwnPropertyDescriptors(n)).forEach(([o,{value:s,enumerable:a}])=>{if(a===!1||s===void 0||typeof s=="object"&&s!==null&&s.__v_skip)return;let c=i===""?o:`${i}.${o}`;typeof s=="object"&&s!==null&&s._x_interceptor?n[o]=s.initialize(e,c,o):t(s)&&s!==n&&!(s instanceof Element)&&r(s,c)})};return r(e)}function Re(e,t=()=>{}){let r={initialValue:void 0,_x_interceptor:!0,initialize(n,i,o){return e(this.initialValue,()=>In(n,i),s=>ht(n,i,s),i,o)}};return t(r),n=>{if(typeof n=="object"&&n!==null&&n._x_interceptor){let i=r.initialize.bind(r);r.initialize=(o,s,a)=>{let c=n.initialize(o,s,a);return r.initialValue=c,i(o,s,a)}}else r.initialValue=n;return r}}function In(e,t){return t.split(".").reduce((r,n)=>r[n],e)}function ht(e,t,r){if(typeof t=="string"&&(t=t.split(".")),t.length===1)e[t[0]]=r;else{if(t.length===0)throw error;return e[t[0]]||(e[t[0]]={}),ht(e[t[0]],t.slice(1),r)}}var ir={};function y(e,t){ir[e]=t}function fe(e,t){let r=Ln(t);return Object.entries(ir).forEach(([n,i])=>{Object.defineProperty(e,`$${n}`,{get(){return i(t,r)},enumerable:!1})}),e}function Ln(e){let[t,r]=_t(e),n={interceptor:Re,...t};return te(e,r),n}function or(e,t,r,...n){try{return r(...n)}catch(i){re(i,e,t)}}function re(e,t,r=void 0){e=Object.assign(e??{message:"No error message given."},{el:t,expression:r}),console.warn(`Alpine Expression Error: ${e.message} + +${r?'Expression: "'+r+`" + +`:""}`,t),setTimeout(()=>{throw e},0)}var Me=!0;function ke(e){let t=Me;Me=!1;let r=e();return Me=t,r}function R(e,t,r={}){let n;return x(e,t)(i=>n=i,r),n}function x(...e){return sr(...e)}var sr=xt;function ar(e){sr=e}function xt(e,t){let r={};fe(r,e);let n=[r,...B(e)],i=typeof t=="function"?$n(n,t):Fn(n,t,e);return or.bind(null,e,t,i)}function $n(e,t){return(r=()=>{},{scope:n={},params:i=[],context:o}={})=>{let s=t.apply(z([n,...e]),i);Ne(r,s)}}var gt={};function jn(e,t){if(gt[e])return gt[e];let r=Object.getPrototypeOf(async function(){}).constructor,n=/^[\n\s]*if.*\(.*\)/.test(e.trim())||/^(let|const)\s/.test(e.trim())?`(async()=>{ ${e} })()`:e,o=(()=>{try{let s=new r(["__self","scope"],`with (scope) { __self.result = ${n} }; __self.finished = true; return __self.result;`);return Object.defineProperty(s,"name",{value:`[Alpine] ${e}`}),s}catch(s){return re(s,t,e),Promise.resolve()}})();return gt[e]=o,o}function Fn(e,t,r){let n=jn(t,r);return(i=()=>{},{scope:o={},params:s=[],context:a}={})=>{n.result=void 0,n.finished=!1;let c=z([o,...e]);if(typeof n=="function"){let l=n.call(a,n,c).catch(u=>re(u,r,t));n.finished?(Ne(i,n.result,c,s,r),n.result=void 0):l.then(u=>{Ne(i,u,c,s,r)}).catch(u=>re(u,r,t)).finally(()=>n.result=void 0)}}}function Ne(e,t,r,n,i){if(Me&&typeof t=="function"){let o=t.apply(r,n);o instanceof Promise?o.then(s=>Ne(e,s,r,n)).catch(s=>re(s,i,t)):e(o)}else typeof t=="object"&&t instanceof Promise?t.then(o=>e(o)):e(t)}var wt="x-";function C(e=""){return wt+e}function cr(e){wt=e}var De={};function d(e,t){return De[e]=t,{before(r){if(!De[r]){console.warn(String.raw`Cannot find directive \`${r}\`. \`${e}\` will use the default order of execution`);return}let n=G.indexOf(r);G.splice(n>=0?n:G.indexOf("DEFAULT"),0,e)}}}function lr(e){return Object.keys(De).includes(e)}function pe(e,t,r){if(t=Array.from(t),e._x_virtualDirectives){let o=Object.entries(e._x_virtualDirectives).map(([a,c])=>({name:a,value:c})),s=Et(o);o=o.map(a=>s.find(c=>c.name===a.name)?{name:`x-bind:${a.name}`,value:`"${a.value}"`}:a),t=t.concat(o)}let n={};return t.map(dr((o,s)=>n[o]=s)).filter(mr).map(zn(n,r)).sort(Kn).map(o=>Bn(e,o))}function Et(e){return Array.from(e).map(dr()).filter(t=>!mr(t))}var yt=!1,de=new Map,ur=Symbol();function fr(e){yt=!0;let t=Symbol();ur=t,de.set(t,[]);let r=()=>{for(;de.get(t).length;)de.get(t).shift()();de.delete(t)},n=()=>{yt=!1,r()};e(r),n()}function _t(e){let t=[],r=a=>t.push(a),[n,i]=Yt(e);return t.push(i),[{Alpine:K,effect:n,cleanup:r,evaluateLater:x.bind(x,e),evaluate:R.bind(R,e)},()=>t.forEach(a=>a())]}function Bn(e,t){let r=()=>{},n=De[t.type]||r,[i,o]=_t(e);Oe(e,t.original,o);let s=()=>{e._x_ignore||e._x_ignoreSelf||(n.inline&&n.inline(e,t,i),n=n.bind(n,e,t,i),yt?de.get(ur).push(n):n())};return s.runCleanups=o,s}var Pe=(e,t)=>({name:r,value:n})=>(r.startsWith(e)&&(r=r.replace(e,t)),{name:r,value:n}),Ie=e=>e;function dr(e=()=>{}){return({name:t,value:r})=>{let{name:n,value:i}=pr.reduce((o,s)=>s(o),{name:t,value:r});return n!==t&&e(n,t),{name:n,value:i}}}var pr=[];function ne(e){pr.push(e)}function mr({name:e}){return hr().test(e)}var hr=()=>new RegExp(`^${wt}([^:^.]+)\\b`);function zn(e,t){return({name:r,value:n})=>{let i=r.match(hr()),o=r.match(/:([a-zA-Z0-9\-_:]+)/),s=r.match(/\.[^.\]]+(?=[^\]]*$)/g)||[],a=t||e[r]||r;return{type:i?i[1]:null,value:o?o[1]:null,modifiers:s.map(c=>c.replace(".","")),expression:n,original:a}}}var bt="DEFAULT",G=["ignore","ref","data","id","anchor","bind","init","for","model","modelable","transition","show","if",bt,"teleport"];function Kn(e,t){let r=G.indexOf(e.type)===-1?bt:e.type,n=G.indexOf(t.type)===-1?bt:t.type;return G.indexOf(r)-G.indexOf(n)}function J(e,t,r={}){e.dispatchEvent(new CustomEvent(t,{detail:r,bubbles:!0,composed:!0,cancelable:!0}))}function D(e,t){if(typeof ShadowRoot=="function"&&e instanceof ShadowRoot){Array.from(e.children).forEach(i=>D(i,t));return}let r=!1;if(t(e,()=>r=!0),r)return;let n=e.firstElementChild;for(;n;)D(n,t,!1),n=n.nextElementSibling}function E(e,...t){console.warn(`Alpine Warning: ${e}`,...t)}var _r=!1;function gr(){_r&&E("Alpine has already been initialized on this page. Calling Alpine.start() more than once can cause problems."),_r=!0,document.body||E("Unable to initialize. Trying to load Alpine before `` is available. Did you forget to add `defer` in Alpine's `