├── assets └── icon.png ├── test └── Falco.Datastar.Tests │ ├── Program.fs │ ├── Falco.Datastar.Tests.fsproj │ └── DsTests.fs ├── examples ├── Streaming │ ├── assets │ │ └── badapple.zst │ ├── appsettings.json │ ├── Streaming.fsproj │ ├── Animation.fs │ └── Streaming.fs ├── Signals │ ├── appsettings.json │ ├── Signals.fsproj │ └── Signals.fs ├── ClickAndSwap │ ├── appsettings.json │ ├── ClickAndSwap.fsproj │ └── ClickAndSwap.fs ├── ClickToEdit │ ├── appsettings.json │ ├── ClickToEdit.fsproj │ └── ClickToEdit.fs ├── ClickToLoad │ ├── appsettings.json │ ├── ClickToLoad.fsproj │ └── ClickToLoad.fs ├── HelloWorld │ ├── appsettings.json │ ├── HelloWorld.fsproj │ └── HelloWorld.fs └── InputForm │ ├── appsettings.json │ ├── InputForm.fsproj │ └── InputForm.fs ├── global.json ├── .editorconfig ├── .github └── workflows │ └── build.yml ├── src └── Falco.Datastar │ ├── Utility.fs │ ├── Request.fs │ ├── Falco.Datastar.fsproj │ ├── Response.fs │ ├── Types.fs │ └── Ds.fs ├── Build.ps1 ├── Falco.Datastar.sln ├── .gitignore ├── LICENSE └── README.md /assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/falcoframework/Falco.Datastar/HEAD/assets/icon.png -------------------------------------------------------------------------------- /test/Falco.Datastar.Tests/Program.fs: -------------------------------------------------------------------------------- 1 | module Program = 2 | [] 3 | let main _ = 0 4 | -------------------------------------------------------------------------------- /examples/Streaming/assets/badapple.zst: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/falcoframework/Falco.Datastar/HEAD/examples/Streaming/assets/badapple.zst -------------------------------------------------------------------------------- /global.json: -------------------------------------------------------------------------------- 1 | { 2 | "sdk": { 3 | "version": "10.0.0", 4 | "rollForward": "latestFeature", 5 | "allowPrerelease": false 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /examples/Signals/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Urls": "http://localhost:5001", 3 | "Logging": { 4 | "LogLevel": { 5 | "Default": "Information" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /examples/ClickAndSwap/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Urls": "http://localhost:5001", 3 | "Logging": { 4 | "LogLevel": { 5 | "Default": "Information" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /examples/ClickToEdit/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Urls": "http://localhost:5001", 3 | "Logging": { 4 | "LogLevel": { 5 | "Default": "Information" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /examples/ClickToLoad/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Urls": "http://localhost:5001", 3 | "Logging": { 4 | "LogLevel": { 5 | "Default": "Information" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /examples/HelloWorld/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Urls": "http://localhost:5001", 3 | "Logging": { 4 | "LogLevel": { 5 | "Default": "Information" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /examples/InputForm/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Urls": "http://localhost:5001", 3 | "Logging": { 4 | "LogLevel": { 5 | "Default": "Information" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /examples/Streaming/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Urls": "http://localhost:5001", 3 | "Logging": { 4 | "LogLevel": { 5 | "Default": "Information" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root=true 2 | 3 | [*] 4 | charset=utf-8 5 | end_of_line=lf 6 | trim_trailing_whitespace=true 7 | insert_final_newline=true 8 | indent_style=space 9 | indent_size=4 10 | max_line_length=120 11 | tab_width=4 12 | -------------------------------------------------------------------------------- /examples/ClickToLoad/ClickToLoad.fsproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net9.0 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /examples/ClickToEdit/ClickToEdit.fsproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net9.0 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /examples/HelloWorld/HelloWorld.fsproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net9.0 4 | HelloWorld 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | <_ContentIncludedByDefault Remove="Properties\launchSettings.json" /> 14 | 15 | 16 | -------------------------------------------------------------------------------- /examples/InputForm/InputForm.fsproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net9.0 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | PreserveNewest 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /examples/Signals/Signals.fsproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net9.0 5 | 6 | 7 | 8 | <_ContentIncludedByDefault Remove="Properties\launchSettings.json" /> 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /examples/ClickAndSwap/ClickAndSwap.fsproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net9.0 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | PreserveNewest 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | dotnet-version: ['10.0.x'] 14 | steps: 15 | - uses: actions/checkout@v2 16 | 17 | - name: Setup .NET Core SDK ${{ matrix.dotnet-version }} 18 | uses: actions/setup-dotnet@v3 19 | with: 20 | dotnet-version: ${{ matrix.dotnet-version }} 21 | 22 | - name: Install dependencies 23 | run: dotnet restore src/Falco.Datastar 24 | 25 | - name: Build 26 | run: dotnet build src/Falco.Datastar -c Release 27 | 28 | - name: Test 29 | run: dotnet test test/Falco.Datastar.Tests -c Release 30 | -------------------------------------------------------------------------------- /examples/Streaming/Streaming.fsproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net9.0 5 | 6 | 7 | 8 | 9 | PreserveNewest 10 | 11 | 12 | 13 | 14 | 15 | 16 | <_ContentIncludedByDefault Remove="Properties\launchSettings.json" /> 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /examples/HelloWorld/HelloWorld.fs: -------------------------------------------------------------------------------- 1 | open Falco 2 | open Falco.Markup 3 | open Falco.Routing 4 | open Falco.Datastar 5 | open Microsoft.AspNetCore.Builder 6 | 7 | let handleIndex : HttpHandler = 8 | let html = 9 | Elem.html [] [ 10 | Elem.head [] [ Ds.cdnScript ] 11 | Elem.body [] [ 12 | Text.h1 "Example: Hello World" 13 | Elem.button 14 | [ Attr.id "hello"; Ds.onClick (Ds.get "/click") ] 15 | [ Text.raw "Click Me" ] 16 | ] 17 | ] 18 | Response.ofHtml html 19 | 20 | let handleClick : HttpHandler = 21 | // create an HTML element which will replace the button with the same `id` 22 | let html = Elem.h2 [ Attr.id "hello" ] [ Text.raw "Hello, World, from the Server!" ] 23 | Response.ofHtmlElements html 24 | 25 | let wapp = WebApplication.Create() 26 | 27 | let endpoints = 28 | [ get "/" handleIndex 29 | get "/click" handleClick ] 30 | 31 | wapp.UseRouting() 32 | .UseFalco(endpoints) 33 | .Run() 34 | -------------------------------------------------------------------------------- /src/Falco.Datastar/Utility.fs: -------------------------------------------------------------------------------- 1 | namespace Falco.Datastar 2 | 3 | open System 4 | open System.Text 5 | 6 | module internal String = 7 | let newLines = [| "\r\n"; "\n"; "\r" |] 8 | let split (delimiters:string seq) (line:string) = line.Split(delimiters |> Seq.toArray, StringSplitOptions.None) 9 | let IsPopulated = String.IsNullOrWhiteSpace >> not 10 | let toKebab (pascalString:string) = 11 | (StringBuilder(), pascalString.ToCharArray()) 12 | ||> Seq.fold (fun stringBuilder chr -> 13 | if Char.IsUpper(chr) 14 | then stringBuilder.Append("-").Append(Char.ToLower(chr)) 15 | else stringBuilder.Append(chr) 16 | ) 17 | |> _.Replace("-", "", 0, 1).ToString() 18 | 19 | module internal Bool = 20 | let inline eitherOr trueThing falseThing bool = 21 | match bool with 22 | | true -> trueThing 23 | | _ -> falseThing 24 | 25 | module Option = 26 | let toValueOption = function 27 | | Some value -> ValueSome value 28 | | None -> ValueNone 29 | -------------------------------------------------------------------------------- /examples/ClickAndSwap/ClickAndSwap.fs: -------------------------------------------------------------------------------- 1 | open Falco 2 | open Falco.Datastar.SignalPath 3 | open Falco.Markup 4 | open Falco.Routing 5 | open Falco.Datastar 6 | open Microsoft.AspNetCore.Builder 7 | 8 | module View = 9 | let template content = 10 | Elem.html [ Attr.lang "en" ] [ 11 | Elem.head [] [ Ds.cdnScript ] 12 | Elem.body [ Ds.signal (sp"showWayToGo", false) ] 13 | content ] 14 | 15 | module Components = 16 | let clicker = 17 | Elem.button 18 | [ Ds.onClick "$showWayToGo = !$showWayToGo" ] 19 | [ Text.raw "Click Me" ] 20 | 21 | module App = 22 | let handleIndex : HttpHandler = 23 | let html = 24 | View.template [ 25 | Text.h1 "Example: Click & Swap" 26 | Elem.h2 [ Ds.show "$showWayToGo" ] [ Text.raw "Way to go! You clicked it!" ] 27 | View.Components.clicker ] 28 | 29 | Response.ofHtml html 30 | 31 | [] 32 | let main args = 33 | let wapp = WebApplication.Create() 34 | 35 | let endpoints = 36 | [ 37 | get "/" App.handleIndex 38 | ] 39 | 40 | wapp.UseRouting() 41 | .UseFalco(endpoints) 42 | .Run() 43 | 0 // Exit code 44 | -------------------------------------------------------------------------------- /src/Falco.Datastar/Request.fs: -------------------------------------------------------------------------------- 1 | [] 2 | module Falco.Datastar.Request 3 | 4 | open System.Text.Json 5 | open Microsoft.AspNetCore.Http 6 | open StarFederation.Datastar.FSharp 7 | 8 | /// 9 | /// Deserialize the signals into 'T, using case-insensitive JsonSerializerOptions. Can only call this once per request 10 | /// 11 | /// HttpContext 12 | let getSignals<'T> (ctx:HttpContext) = 13 | ServerSentEventGenerator.ReadSignalsAsync<'T> ctx.Request 14 | 15 | /// 16 | /// Deserialize the signals into 'T, using provided JsonSerializerOptions. Can only call this once per request 17 | /// 18 | /// 19 | /// HttpContext 20 | let getSignalsOptions<'T> (jsonSerializerOptions:JsonSerializerOptions) (ctx:HttpContext)= 21 | ServerSentEventGenerator.ReadSignalsAsync<'T> (ctx.Request, jsonSerializerOptions) 22 | 23 | /// 24 | /// Retrieve a JsonDocument of the Signals. Can only call this once per request 25 | /// 26 | /// HttpContext 27 | let getSignalsJson (ctx:HttpContext) = 28 | JsonDocument.ParseAsync (ServerSentEventGenerator.GetSignalsStream(ctx.Request), JsonDocumentOptions(), ctx.RequestAborted) 29 | -------------------------------------------------------------------------------- /test/Falco.Datastar.Tests/Falco.Datastar.Tests.fsproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net9.0 5 | 6 | false 7 | false 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | all 22 | runtime; build; native; contentfiles; analyzers; buildtransitive 23 | 24 | 25 | all 26 | runtime; build; native; contentfiles; analyzers; buildtransitive 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /Build.ps1: -------------------------------------------------------------------------------- 1 | [CmdletBinding()] 2 | param ( 3 | [Parameter(HelpMessage="The action to execute.")] 4 | [ValidateSet("Build", "Test", "Pack", "Sln")] 5 | [string] $Action = "Build", 6 | 7 | [Parameter(HelpMessage="The msbuild configuration to use.")] 8 | [ValidateSet("Debug", "Release")] 9 | [string] $Configuration = "Debug", 10 | 11 | [switch] $SkipClean 12 | ) 13 | 14 | function RunCommand { 15 | param ([string] $CommandExpr) 16 | Write-Verbose " $CommandExpr" 17 | Invoke-Expression $CommandExpr 18 | } 19 | 20 | $rootDir = $PSScriptRoot 21 | $srcDir = Join-Path -Path $rootDir -ChildPath 'src' 22 | $testDir = Join-Path -Path $rootDir -ChildPath 'test' 23 | 24 | switch ($Action) { 25 | "Test" { $projectdir = Join-Path -Path $testDir -ChildPath 'Falco.Datastar.Tests' } 26 | "Pack" { $projectDir = Join-Path -Path $srcDir -ChildPath 'Falco.Datastar' } 27 | "Sln" { $projectDir = $rootDir } 28 | Default { $projectDir = Join-Path -Path $srcDir -ChildPath 'Falco.Datastar' } 29 | } 30 | 31 | if (!$SkipClean.IsPresent) 32 | { 33 | RunCommand "dotnet restore $projectDir --force --force-evaluate --nologo --verbosity quiet" 34 | RunCommand "dotnet clean $projectDir -c $Configuration --nologo --verbosity quiet" 35 | } 36 | 37 | switch ($Action) { 38 | "Test" { RunCommand "dotnet test `"$projectDir`"" } 39 | "Pack" { RunCommand "dotnet pack `"$projectDir`" -c $Configuration --include-symbols --include-source" } 40 | Default { RunCommand "dotnet build `"$projectDir`" -c $Configuration" } 41 | } 42 | -------------------------------------------------------------------------------- /examples/Streaming/Animation.fs: -------------------------------------------------------------------------------- 1 | module Animation 2 | 3 | open System 4 | open System.IO 5 | open System.Text 6 | open System.Threading.Tasks 7 | 8 | // the laziest zstd parser 9 | let private readAnimationFile zstPath = 10 | let zstdReader (stream:Stream) = seq { 11 | use streamReader = new StreamReader(stream, encoding=Encoding.ASCII) 12 | streamReader.ReadLine() |> ignore // header metadata 13 | while not <| streamReader.EndOfStream do 14 | yield streamReader.ReadLine() 15 | } 16 | let scanLines = 17 | seq { 18 | use zstStream = File.OpenRead(zstPath) 19 | use decompressionStream = new ZstdSharp.DecompressionStream(zstStream) 20 | yield! zstdReader decompressionStream 21 | } 22 | 23 | scanLines 24 | |> Seq.scan (fun (_, sb:StringBuilder) line -> 25 | if line = "?" 26 | then (ValueSome (sb.Replace("?","",0,1).ToString()), sb.Clear()) 27 | else (ValueNone, sb.AppendLine(line)) 28 | ) (ValueNone, StringBuilder() ) 29 | |> Seq.filter (fst >> ValueOption.isSome) 30 | |> Seq.map (fst >> ValueOption.get) 31 | 32 | // the laziest broadcast block with the latest frame of BadApple, plays continuously 33 | let private readBadAppleFrames = 34 | let zstPath = Path.Combine("assets", "badapple.zst") 35 | readAnimationFile zstPath |> Seq.toArray 36 | 37 | let private badAppleFrames = readBadAppleFrames 38 | let mutable currentBadAppleFrame = 0 39 | let private totalBadAppleFrames = badAppleFrames |> Array.length 40 | backgroundTask { 41 | while true do 42 | currentBadAppleFrame <- (currentBadAppleFrame + 1) % totalBadAppleFrames 43 | do! Task.Delay(TimeSpan.FromMilliseconds(50L)) 44 | } |> ignore 45 | 46 | let getCurrentBadAppleFrame () = badAppleFrames[currentBadAppleFrame] 47 | -------------------------------------------------------------------------------- /src/Falco.Datastar/Falco.Datastar.fsproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | Falco.Datastar 4 | 1.3.0 5 | 6 | 7 | Datastar Bindings for the Falco web toolkit. 8 | Copyright 2025 Greg Holden 9 | Greg Holden, Pim Brouwers, Damian Plaza and contributors 10 | en-CA 11 | 12 | 13 | net8.0;net9.0;net10.0 14 | embedded 15 | Library 16 | true 17 | false 18 | true 19 | 20 | 21 | Falco.Datastar 22 | 1.3.0 23 | fsharp;web;falco;falco-sharp;data-star 24 | https://github.com/falcoframework/Falco.Datastar 25 | Apache-2.0 26 | icon.png 27 | README.md 28 | true 29 | git 30 | https://github.com/falcoframework/Falco.Datastar 31 | 32 | 33 | true 34 | true 35 | true 36 | Falco.Datastar 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /test/Falco.Datastar.Tests/DsTests.fs: -------------------------------------------------------------------------------- 1 | namespace Falco.Datastar.Tests 2 | 3 | open Falco.Datastar 4 | open Falco.Markup 5 | open FsUnit.Xunit 6 | open Xunit 7 | 8 | [] 9 | module private Common = 10 | let renderAttr attr = 11 | Elem.div [ attr ] [ ] 12 | |> renderNode 13 | 14 | module DsTests = 15 | [] 16 | let ``Ds.bind should create an attribute`` () = 17 | renderAttr (Ds.bind "signalPath") 18 | |> should equal """
""" 19 | 20 | [] 21 | let ``Ds.post`` () = 22 | Ds.post "/channel" 23 | |> should equal """@post('/channel')""" 24 | 25 | [] 26 | let ``Ds.post with Form`` () = 27 | Ds.post ("/channel", { RequestOptions.Defaults with ContentType = Form }) 28 | |> should equal """@post('/channel',{"contentType":"form"})""" 29 | 30 | [] 31 | let ``Ds.post with SelectedForm`` () = 32 | Ds.post ("/channel", { RequestOptions.Defaults with ContentType = (SelectedForm "myForm") }) 33 | |> should equal """@post('/channel',{"contentType":"form","selector":"myForm"})""" 34 | 35 | [] 36 | let ``Ds.jsonSignalsOptions Exclude`` () = 37 | let filterFiles : SignalsFilter = { IncludePattern = ValueNone; ExcludePattern = ValueSome "files" } 38 | renderAttr (Ds.jsonSignalsOptions filterFiles) 39 | |> should equal """
""" 40 | 41 | [] 42 | let ``Ds.jsonSignalsOptions Include`` () = 43 | let filterFiles : SignalsFilter = { IncludePattern = ValueSome "files"; ExcludePattern = ValueNone } 44 | renderAttr (Ds.jsonSignalsOptions filterFiles) 45 | |> should equal """
""" 46 | 47 | [] 48 | let ``Ds.jsonSignalsOptions Both`` () = 49 | let filterFiles : SignalsFilter = { IncludePattern = ValueSome "files$"; ExcludePattern = ValueSome "^files" } 50 | renderAttr (Ds.jsonSignalsOptions filterFiles) 51 | |> should equal """
""" 52 | 53 | [] 54 | let ``Ds.jsonSignalsOptions Terse`` () = 55 | renderAttr (Ds.jsonSignalsOptions (terse = true)) 56 | |> should equal """
""" 57 | 58 | [] 59 | let ``Ds.jsonSignalsOptions Terse and Both Filters`` () = 60 | let filterFiles : SignalsFilter = { IncludePattern = ValueSome "files$"; ExcludePattern = ValueSome "^files" } 61 | renderAttr (Ds.jsonSignalsOptions (filterFiles, terse = true)) 62 | |> should equal """
""" 63 | 64 | [] 65 | let ``Ds.onIntersect No Options`` () = 66 | renderAttr (Ds.onIntersect "@get('/hello')") 67 | |> should equal """
""" 68 | 69 | [] 70 | let ``Ds.onIntersect Threshold`` () = 71 | renderAttr (Ds.onIntersect ("@get('/hello')", threshold=50)) 72 | |> should equal """
""" 73 | 74 | [] 75 | let ``Ds.style`` () = 76 | renderAttr (Ds.style ("display", "$hiding && 'none'")) 77 | |> should equal """
""" 78 | 79 | -------------------------------------------------------------------------------- /examples/ClickToLoad/ClickToLoad.fs: -------------------------------------------------------------------------------- 1 | open System 2 | open System.Threading.Tasks 3 | open Falco 4 | open Falco.Datastar.Selector 5 | open Falco.Datastar.SignalPath 6 | open Falco.Markup 7 | open Falco.Routing 8 | open Falco.Datastar 9 | open Microsoft.AspNetCore.Builder 10 | open StarFederation.Datastar.FSharp 11 | 12 | let nMoreAgents = 5 13 | 14 | [] 15 | type InnerSignals = 16 | { lastAgentShown: int } 17 | 18 | [] 19 | type MySignals = 20 | { InnerSignals: InnerSignals } 21 | 22 | let handleIndex: HttpHandler = 23 | let html = 24 | Elem.html 25 | [] 26 | [ Elem.head [] [ Ds.cdnScript ] 27 | Elem.body 28 | [ Ds.signal (sp "innerSignals.lastAgentShown", 0) ] 29 | [ Text.h1 "Example: Click to Load" 30 | Elem.div [ Attr.id "loadMoreAgentsButton" ] [] 31 | Elem.table 32 | [] 33 | [ Elem.thead 34 | [] 35 | [ Elem.th [] [ Text.raw "Name" ] 36 | Elem.th [] [ Text.raw "Email" ] 37 | Elem.th [] [ Text.raw "ID" ] ] 38 | Elem.tbody [ Attr.id "agent_rows"; Ds.onInit (Ds.get "/moreAgents") ] [] ] ] ] 39 | 40 | Response.ofHtml html 41 | 42 | let appendRowsFragmentOptions = 43 | { PatchElementsOptions.Defaults with 44 | Selector = ValueSome(sel "#agent_rows") 45 | PatchMode = Append } 46 | 47 | let handleMoreAgents: HttpHandler = 48 | (fun ctx -> 49 | task { 50 | // start the text/event-stream 51 | do! Response.sseStartResponse ctx 52 | 53 | // get the last agent shown number 54 | let! signals = Request.getSignals ctx 55 | let lastAgentShown = signals |> ValueOption.get |> _.InnerSignals.lastAgentShown 56 | 57 | // remove the button - this will not exist on the first call 58 | do! Response.sseRemoveElement ctx (sel "#loadMoreAgentsButton") 59 | 60 | // N more agents 61 | do! 62 | seq { (lastAgentShown + 1) .. (lastAgentShown + nMoreAgents) } 63 | |> Seq.map (fun num -> 64 | Elem.tr [] 65 | [ Elem.td [] [ Text.raw "Agent Smith" ] 66 | Elem.td [] [ Text.raw $"agent{num}@null.org" ] 67 | Elem.td [] [ Text.raw (Guid.NewGuid().ToString("n")) ] ]) 68 | |> Seq.map (Response.sseHtmlElementsOptions ctx appendRowsFragmentOptions) 69 | |> Task.WhenAll 70 | 71 | // restore load more agents button 72 | let button = 73 | Elem.tr [] 74 | [ Elem.td 75 | [ Attr.id "loadMoreAgentsButton"; Attr.colspan "3" ] 76 | [ Elem.button [ Ds.onClick (Ds.get "/moreAgents") ] [ Text.raw "Load More Agents..." ] ] ] 77 | 78 | do! Response.sseHtmlElementsOptions ctx appendRowsFragmentOptions button 79 | 80 | // update the lastAgentShown count 81 | do! Response.ssePatchSignal ctx (sp "lastAgentShown") (lastAgentShown + nMoreAgents) 82 | }) 83 | 84 | let wApp = WebApplication.Create() 85 | 86 | let endpoints = 87 | [ get "/" handleIndex 88 | get "/moreAgents" handleMoreAgents ] 89 | 90 | wApp.UseRouting().UseFalco(endpoints).Run() 91 | -------------------------------------------------------------------------------- /examples/ClickToEdit/ClickToEdit.fs: -------------------------------------------------------------------------------- 1 | open Falco 2 | open Falco.Datastar.SignalPath 3 | open Falco.Routing 4 | open Falco.Datastar 5 | open Falco.Markup 6 | open Microsoft.AspNetCore.Builder 7 | open Microsoft.AspNetCore.Http 8 | open Microsoft.Extensions.Hosting 9 | 10 | let ofMainBody : HttpHandler = 11 | let htmlXml = 12 | Elem.html [] [ 13 | Elem.head [] [ 14 | Elem.title [] [ Text.raw "Click to Edit" ] 15 | Ds.cdnScript 16 | ] 17 | Elem.body [] [ 18 | Text.h1 "Example: Click to Edit" 19 | Elem.div [ Attr.id "contact"; Ds.onInit (Ds.get "clickToEdit/view") ] [] 20 | ] 21 | ] 22 | Response.ofHtml htmlXml 23 | 24 | module clickToEdit = 25 | 26 | type MySignals() = 27 | member val firstName = "John" with get, set 28 | member val lastName = "Doe" with get, set 29 | member val email = "john@doe.com" with get, set 30 | 31 | let readonlyFragment : HttpHandler = (fun ctx -> task { 32 | let! signals = Request.getSignals(ctx) 33 | let fragment = 34 | Elem.div [ Attr.id "contact"; Ds.signals signals ] [ 35 | Elem.label [] [ Text.raw "First Name:" ]; Elem.span [ Ds.text "$firstName" ] []; Elem.br [] 36 | Elem.label [] [ Text.raw "Last Name:" ]; Elem.span [ Ds.text "$lastName" ] []; Elem.br [] 37 | Elem.label [] [ Text.raw "Email:" ]; Elem.span [ Ds.text "$email" ] []; Elem.br [] 38 | Elem.button [ Ds.onClick (Ds.get "clickToEdit/edit") ] [ Text.raw "Edit" ] 39 | Elem.button [ Ds.onClick (Ds.get "clickToEdit/reset") ] [ Text.raw "Reset" ] 40 | ] 41 | return Response.ofHtmlElements fragment ctx 42 | }) 43 | 44 | let editFragment : HttpHandler = (fun ctx -> task { 45 | let! signals = Request.getSignals(ctx) 46 | let fragment = 47 | Elem.div [ Attr.id "contact"; Ds.signals signals ] [ 48 | Elem.label [] [ Text.raw "First Name:" ]; Elem.input [ Attr.type' "text"; Ds.bind (sp"firstName") ]; Elem.br [] 49 | Elem.label [] [ Text.raw "Last Name:" ]; Elem.input [ Attr.type' "text"; Ds.bind (sp"lastName") ]; Elem.br [] 50 | Elem.label [] [ Text.raw "Email:" ]; Elem.input [ Attr.type' "text"; Ds.bind (sp"email") ]; Elem.br [] 51 | Elem.button [ Ds.onClick (Ds.put "clickToEdit/save") ] [ Text.raw "Save" ] 52 | Elem.button [ Ds.onClick (Ds.get "clickToEdit/reset") ] [ Text.raw "Reset" ] 53 | ] 54 | return Response.ofHtmlElements fragment ctx 55 | }) 56 | 57 | let resetSignals : HttpHandler = Response.ofPatchSignals (MySignals()) 58 | 59 | let save (ctx:HttpContext) = 60 | // TODO: get the signals and save to a database 61 | () 62 | 63 | [] 64 | let main args = 65 | let builder = WebApplication.CreateBuilder(args) 66 | let app = builder.Build() 67 | 68 | let endpoints = [ 69 | get "/" (Response.redirectTemporarily "/clickToEdit") 70 | get "/clickToEdit" ofMainBody 71 | get "/clickToEdit/view" clickToEdit.readonlyFragment 72 | get "/clickToEdit/edit" clickToEdit.editFragment 73 | get "/clickToEdit/reset" clickToEdit.resetSignals 74 | put "/clickToEdit/save" (fun ctx -> clickToEdit.save ctx; clickToEdit.readonlyFragment ctx) 75 | ] 76 | 77 | app.UseRouting().UseFalco(endpoints) |> ignore 78 | app.Run() 79 | 80 | 0 // Exit code 81 | 82 | 83 | -------------------------------------------------------------------------------- /examples/InputForm/InputForm.fs: -------------------------------------------------------------------------------- 1 | open Falco 2 | open Falco.Datastar.Selector 3 | open Falco.Datastar.SignalPath 4 | open Falco.Markup 5 | open Falco.Routing 6 | open Falco.Datastar 7 | open Microsoft.AspNetCore.Builder 8 | open Microsoft.AspNetCore.Http 9 | 10 | module View = 11 | let template content = 12 | Elem.html [ Attr.lang "en" ] [ 13 | Elem.head [] [ Ds.cdnScript ] 14 | Elem.body [] content 15 | ] 16 | 17 | module App = 18 | let handleIndex : HttpHandler = 19 | let checkbox name = 20 | [ Text.raw $"{name}:"; Elem.input [ Attr.type' "checkbox"; Attr.name "checkboxes"; Attr.value name ] ] 21 | let html = 22 | View.template [ 23 | Text.h1 "Example: Input Form" 24 | Elem.div [] [ 25 | Elem.form [ Attr.id "myform" ] [ 26 | yield! checkbox "foo" 27 | yield! checkbox "bar" 28 | yield! checkbox "baz" 29 | Elem.button [ Ds.onClick (Ds.get("/endpoint1", RequestOptions.With(Form))) ] [ Text.raw "Submit GET Request" ] 30 | Elem.button [ Ds.onClick (Ds.post("/endpoint1", RequestOptions.With(Form))) ] [ Text.raw "Submit POST Request" ] 31 | ] 32 | Elem.button [ Ds.onClick (Ds.get("/endpoint1", RequestOptions.With(SelectedForm (sel"#myform")))) ] [ 33 | Text.raw "Submit GET request from outside the form" 34 | ] 35 | ] 36 | Elem.hr [] 37 | Elem.div [] [ 38 | Elem.form [ Ds.onEvent ("submit", (Ds.post ("/endpoint2", RequestOptions.With(Form)))) ] [ 39 | Text.raw "foo:" 40 | Elem.input [ Attr.type' "text"; Attr.name "foo"; Attr.required ] 41 | Elem.button [] [ Text.raw "Submit Form" ] 42 | ] 43 | ] 44 | ] 45 | Response.ofHtml html 46 | 47 | let handleEndpointOne (getForm:HttpContext -> RequestData) : HttpHandler = (fun ctx -> task { 48 | let method = ctx.Request.Method 49 | let form = ctx |> getForm 50 | let foo = form.GetStringList("checkboxes") 51 | 52 | let alertString = $"Form data received via {method} request: checkboxes = {foo}" 53 | let alertScript = $"alert('{alertString}')" 54 | 55 | return Response.ofExecuteScript alertScript ctx 56 | }) 57 | 58 | let handleEndpointTwo (getForm:HttpContext -> RequestData): HttpHandler = (fun ctx -> task { 59 | let method = ctx.Request.Method 60 | let form = ctx |> getForm 61 | let foo = form.GetString("foo") 62 | 63 | let alertString = $"Form data received via {method} request: foo = {foo}" 64 | let alertScript = $"alert('{alertString}')" 65 | 66 | return Response.ofExecuteScript alertScript ctx 67 | }) 68 | 69 | 70 | [] 71 | let main args = 72 | let wapp = WebApplication.Create() 73 | 74 | let endpoints = 75 | [ 76 | get "/" App.handleIndex 77 | all "/endpoint1" [ 78 | GET, (App.handleEndpointOne Request.getQuery) 79 | POST, (App.handleEndpointOne (fun ctx -> (ctx |> Request.getForm).Result)) 80 | ] 81 | all "/endpoint2" [ 82 | GET, (App.handleEndpointTwo Request.getQuery) 83 | POST, (App.handleEndpointTwo (fun ctx -> (ctx |> Request.getForm).Result)) 84 | ] 85 | ] 86 | 87 | wapp.UseRouting() 88 | .UseFalco(endpoints) 89 | .Run() 90 | 0 // Exit code 91 | -------------------------------------------------------------------------------- /examples/Streaming/Streaming.fs: -------------------------------------------------------------------------------- 1 | open System 2 | open System.Collections.Concurrent 3 | open System.Threading.Tasks 4 | open Microsoft.AspNetCore.Builder 5 | open Microsoft.FSharp.Core 6 | open Falco 7 | open Falco.Markup 8 | open Falco.Routing 9 | open Falco.Datastar 10 | open Falco.Datastar.SignalPath 11 | 12 | type User = Guid 13 | type UserState = string 14 | 15 | let userDisplays = ConcurrentDictionary() 16 | 17 | [] 18 | type StreamSignals = 19 | { User:User 20 | Display:UserState } 21 | 22 | module ElementIds = 23 | let [] streamView = "streamView" 24 | 25 | module SignalPath = 26 | let userName = sp"user" 27 | let displayType = sp"display" 28 | 29 | module UserState = 30 | let [] displayBadApple = "Watching: Bad Apple" 31 | let [] displayUsers = "Users" 32 | let [] loggedOff = "Logged Off" 33 | 34 | let handleIndex ctx = task { 35 | let html (user:User) = 36 | Elem.html [] [ 37 | Elem.head [ Attr.title "Streaming" ] [ 38 | Ds.cdnScript 39 | Elem.script [ Attr.type' "module" ] [] 40 | ] 41 | Elem.body [ 42 | Ds.signal (SignalPath.userName, user) 43 | Ds.signal (SignalPath.displayType, UserState.displayBadApple) 44 | Ds.onSignalPatchFilter (SignalsFilter.Include SignalPath.displayType) 45 | Ds.onSignalPatch (Ds.get "/channel") 46 | Ds.safariStreamingFix 47 | ] [ 48 | Elem.div [ Ds.onInit (Ds.get "/stream") ] [] 49 | 50 | Text.h1 "Example: Streaming" 51 | 52 | Elem.input [ Attr.id "streamChannel" 53 | Attr.typeRadio 54 | Attr.value UserState.displayBadApple 55 | Ds.bind SignalPath.displayType ] 56 | Elem.label [ Attr.for' "streamDisplayBadApple" ] [ Text.raw "Bad Apple" ] 57 | 58 | Elem.input [ Attr.id "streamChannel" 59 | Attr.typeRadio 60 | Attr.value UserState.displayUsers 61 | Ds.bind SignalPath.displayType ] 62 | Elem.label [ Attr.for' "streamDisplayGuids" ] [ Text.raw "Viewers" ] 63 | 64 | Elem.div [ Attr.id ElementIds.streamView ] [] 65 | ] 66 | ] 67 | return Response.ofHtml (html (Guid.NewGuid())) ctx 68 | } 69 | 70 | let handleViewChange ctx = task { 71 | let! signals = Request.getSignals ctx 72 | match signals with 73 | | ValueNone -> () 74 | | ValueSome signals -> userDisplays.AddOrUpdate (signals.User, signals.Display, Func(fun _ _ -> signals.Display)) |> ignore 75 | } 76 | 77 | let handleStream ctx = task { 78 | Response.sseStartResponse ctx |> ignore 79 | let! signals = Request.getSignals ctx 80 | let signalUser = 81 | match signals with 82 | | ValueSome signals -> signals.User 83 | | ValueNone -> failwith "no user" 84 | 85 | // reset to displaying BadApple 86 | // when the browser tab is hidden and re-displayed, /stream request is made again with out-dated signal values 87 | // we could create more states for the user to restore the original view, but it wasn't the focus of the demo 88 | userDisplays.AddOrUpdate (signalUser, UserState.displayBadApple, Func(fun user userState -> UserState.displayBadApple)) |> ignore 89 | do! Response.ssePatchSignal ctx SignalPath.displayType UserState.displayBadApple 90 | 91 | try 92 | while true do 93 | let _, streamDisplay = userDisplays.TryGetValue(signalUser) 94 | 95 | let patch = 96 | match streamDisplay with 97 | | UserState.displayUsers -> 98 | Elem.pre [ Attr.id ElementIds.streamView ] [ 99 | Text.raw ( 100 | userDisplays 101 | |> Seq.map (fun ud -> (ud.Key, ud.Value)) 102 | |> Seq.map (fun (user, display) -> ((if user = signalUser then "YOU" else user.ToString()), display )) 103 | |> Seq.map (fun (user, display) -> $"{user}: {display}") |> String.concat Environment.NewLine) 104 | ] 105 | | _ -> 106 | Elem.pre [ Attr.id ElementIds.streamView ] [ 107 | Text.raw (Animation.getCurrentBadAppleFrame ()) 108 | ] 109 | 110 | do! Response.sseHtmlElements ctx patch 111 | do! Task.Delay (TimeSpan.FromMilliseconds(50), ctx.RequestAborted) 112 | finally 113 | userDisplays.AddOrUpdate (signalUser, UserState.displayBadApple, Func(fun _ _ -> UserState.loggedOff)) |> ignore 114 | return () 115 | } 116 | 117 | let wapp = WebApplication.Create() 118 | wapp.UseStaticFiles() |> ignore 119 | 120 | let endpoints = 121 | [ get "/" (fun ctx -> handleIndex ctx) 122 | get "/stream" (fun ctx -> handleStream ctx) 123 | get "/channel" (fun ctx -> handleViewChange ctx) ] 124 | 125 | wapp.UseRouting() 126 | .UseFalco(endpoints) 127 | .Run() 128 | -------------------------------------------------------------------------------- /Falco.Datastar.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Examples", "Examples", "{305B55B6-83D0-453C-A77E-080214FA0657}" 4 | EndProject 5 | Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Falco.Datastar", "src\Falco.Datastar\Falco.Datastar.fsproj", "{6BCE09E2-DA8E-44F9-B5EC-C4F76E329471}" 6 | EndProject 7 | Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "HelloWorld", "examples\HelloWorld\HelloWorld.fsproj", "{D6FFA8E3-7F43-47DF-BD1E-4C81F3452797}" 8 | EndProject 9 | Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "ClickToEdit", "examples\ClickToEdit\ClickToEdit.fsproj", "{A33C6CE1-466A-49D5-8553-7408099C55E5}" 10 | EndProject 11 | Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "ClickToLoad", "examples\ClickToLoad\ClickToLoad.fsproj", "{384A5EF7-ED8D-4E20-B177-5B120FC9EFE9}" 12 | EndProject 13 | Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "ClickAndSwap", "examples\ClickAndSwap\ClickAndSwap.fsproj", "{8E2C0AA3-07E5-40D8-8F16-905F5F1D30AE}" 14 | EndProject 15 | Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Signals", "Examples\Signals\Signals.fsproj", "{0187E584-68DF-4D8C-874C-FF8E061932DF}" 16 | EndProject 17 | Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Streaming", "Examples\Streaming\Streaming.fsproj", "{AECB950B-20DD-40C4-9ACA-3A8FF1CFBC05}" 18 | EndProject 19 | Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Falco.Datastar.Tests", "test\Falco.Datastar.Tests\Falco.Datastar.Tests.fsproj", "{772A0FC5-A796-4408-9751-53842F8B8C77}" 20 | EndProject 21 | Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "InputForm", "examples\InputForm\InputForm.fsproj", "{19E053A0-6EA6-4818-AF0D-5BE0B36D55EF}" 22 | EndProject 23 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "environment", "environment", "{13C18205-082E-4DED-9AD4-D3001ABDFECA}" 24 | ProjectSection(SolutionItems) = preProject 25 | global.json = global.json 26 | .github\workflows\build.yml = .github\workflows\build.yml 27 | EndProjectSection 28 | EndProject 29 | Global 30 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 31 | Debug|Any CPU = Debug|Any CPU 32 | Release|Any CPU = Release|Any CPU 33 | EndGlobalSection 34 | GlobalSection(NestedProjects) = preSolution 35 | {D6FFA8E3-7F43-47DF-BD1E-4C81F3452797} = {305B55B6-83D0-453C-A77E-080214FA0657} 36 | {A33C6CE1-466A-49D5-8553-7408099C55E5} = {305B55B6-83D0-453C-A77E-080214FA0657} 37 | {384A5EF7-ED8D-4E20-B177-5B120FC9EFE9} = {305B55B6-83D0-453C-A77E-080214FA0657} 38 | {8E2C0AA3-07E5-40D8-8F16-905F5F1D30AE} = {305B55B6-83D0-453C-A77E-080214FA0657} 39 | {0187E584-68DF-4D8C-874C-FF8E061932DF} = {305B55B6-83D0-453C-A77E-080214FA0657} 40 | {AECB950B-20DD-40C4-9ACA-3A8FF1CFBC05} = {305B55B6-83D0-453C-A77E-080214FA0657} 41 | {19E053A0-6EA6-4818-AF0D-5BE0B36D55EF} = {305B55B6-83D0-453C-A77E-080214FA0657} 42 | EndGlobalSection 43 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 44 | {6BCE09E2-DA8E-44F9-B5EC-C4F76E329471}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 45 | {6BCE09E2-DA8E-44F9-B5EC-C4F76E329471}.Debug|Any CPU.Build.0 = Debug|Any CPU 46 | {6BCE09E2-DA8E-44F9-B5EC-C4F76E329471}.Release|Any CPU.ActiveCfg = Release|Any CPU 47 | {6BCE09E2-DA8E-44F9-B5EC-C4F76E329471}.Release|Any CPU.Build.0 = Release|Any CPU 48 | {D6FFA8E3-7F43-47DF-BD1E-4C81F3452797}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 49 | {D6FFA8E3-7F43-47DF-BD1E-4C81F3452797}.Debug|Any CPU.Build.0 = Debug|Any CPU 50 | {D6FFA8E3-7F43-47DF-BD1E-4C81F3452797}.Release|Any CPU.ActiveCfg = Release|Any CPU 51 | {D6FFA8E3-7F43-47DF-BD1E-4C81F3452797}.Release|Any CPU.Build.0 = Release|Any CPU 52 | {A33C6CE1-466A-49D5-8553-7408099C55E5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 53 | {A33C6CE1-466A-49D5-8553-7408099C55E5}.Debug|Any CPU.Build.0 = Debug|Any CPU 54 | {A33C6CE1-466A-49D5-8553-7408099C55E5}.Release|Any CPU.ActiveCfg = Release|Any CPU 55 | {A33C6CE1-466A-49D5-8553-7408099C55E5}.Release|Any CPU.Build.0 = Release|Any CPU 56 | {384A5EF7-ED8D-4E20-B177-5B120FC9EFE9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 57 | {384A5EF7-ED8D-4E20-B177-5B120FC9EFE9}.Debug|Any CPU.Build.0 = Debug|Any CPU 58 | {384A5EF7-ED8D-4E20-B177-5B120FC9EFE9}.Release|Any CPU.ActiveCfg = Release|Any CPU 59 | {384A5EF7-ED8D-4E20-B177-5B120FC9EFE9}.Release|Any CPU.Build.0 = Release|Any CPU 60 | {8E2C0AA3-07E5-40D8-8F16-905F5F1D30AE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 61 | {8E2C0AA3-07E5-40D8-8F16-905F5F1D30AE}.Debug|Any CPU.Build.0 = Debug|Any CPU 62 | {8E2C0AA3-07E5-40D8-8F16-905F5F1D30AE}.Release|Any CPU.ActiveCfg = Release|Any CPU 63 | {8E2C0AA3-07E5-40D8-8F16-905F5F1D30AE}.Release|Any CPU.Build.0 = Release|Any CPU 64 | {0187E584-68DF-4D8C-874C-FF8E061932DF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 65 | {0187E584-68DF-4D8C-874C-FF8E061932DF}.Debug|Any CPU.Build.0 = Debug|Any CPU 66 | {0187E584-68DF-4D8C-874C-FF8E061932DF}.Release|Any CPU.ActiveCfg = Release|Any CPU 67 | {0187E584-68DF-4D8C-874C-FF8E061932DF}.Release|Any CPU.Build.0 = Release|Any CPU 68 | {AECB950B-20DD-40C4-9ACA-3A8FF1CFBC05}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 69 | {AECB950B-20DD-40C4-9ACA-3A8FF1CFBC05}.Debug|Any CPU.Build.0 = Debug|Any CPU 70 | {AECB950B-20DD-40C4-9ACA-3A8FF1CFBC05}.Release|Any CPU.ActiveCfg = Release|Any CPU 71 | {AECB950B-20DD-40C4-9ACA-3A8FF1CFBC05}.Release|Any CPU.Build.0 = Release|Any CPU 72 | {772A0FC5-A796-4408-9751-53842F8B8C77}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 73 | {772A0FC5-A796-4408-9751-53842F8B8C77}.Debug|Any CPU.Build.0 = Debug|Any CPU 74 | {772A0FC5-A796-4408-9751-53842F8B8C77}.Release|Any CPU.ActiveCfg = Release|Any CPU 75 | {772A0FC5-A796-4408-9751-53842F8B8C77}.Release|Any CPU.Build.0 = Release|Any CPU 76 | {19E053A0-6EA6-4818-AF0D-5BE0B36D55EF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 77 | {19E053A0-6EA6-4818-AF0D-5BE0B36D55EF}.Debug|Any CPU.Build.0 = Debug|Any CPU 78 | {19E053A0-6EA6-4818-AF0D-5BE0B36D55EF}.Release|Any CPU.ActiveCfg = Release|Any CPU 79 | {19E053A0-6EA6-4818-AF0D-5BE0B36D55EF}.Release|Any CPU.Build.0 = Release|Any CPU 80 | EndGlobalSection 81 | EndGlobal 82 | -------------------------------------------------------------------------------- /src/Falco.Datastar/Response.fs: -------------------------------------------------------------------------------- 1 | [] 2 | module Falco.Datastar.Response 3 | 4 | open System.Collections.Generic 5 | open System.Text.Json 6 | open System.Threading.Tasks 7 | open Microsoft.AspNetCore.Http 8 | open Microsoft.Extensions.Primitives 9 | open StarFederation.Datastar.FSharp 10 | open Falco.Markup 11 | 12 | ////////////////////////////////////////////////////////////////// 13 | // SSE RESPONSES 14 | 15 | /// Send the text/event-stream header with additional headers; after, all other events can follow 16 | let sseStartResponseWithHeaders (ctx:HttpContext) (additionalHeaders:KeyValuePair seq) = 17 | ServerSentEventGenerator.StartServerEventStreamAsync (ctx.Response, additionalHeaders) 18 | 19 | /// Send the text/event-stream response header; after, all other events can follow 20 | let sseStartResponse (ctx:HttpContext) = 21 | ServerSentEventGenerator.StartServerEventStreamAsync ctx.Response 22 | 23 | /// Patch an HTML DOM with elements 24 | let sseHtmlElementsOptions (ctx:HttpContext) (options:PatchElementsOptions) (elements:XmlNode) = 25 | ServerSentEventGenerator.PatchElementsAsync (ctx.Response, renderHtml elements, options) 26 | 27 | /// Patch an HTML DOM with elements 28 | let sseHtmlElements (ctx:HttpContext) (elements:XmlNode) = 29 | ServerSentEventGenerator.PatchElementsAsync (ctx.Response, renderHtml elements) 30 | 31 | /// Patch HTML DOM with raw elements 32 | let sseStringElementsOptions (ctx:HttpContext) (options:PatchElementsOptions) (elements:string) = 33 | ServerSentEventGenerator.PatchElementsAsync (ctx.Response, elements, options) 34 | 35 | /// Patch HTML DOM with raw elements 36 | let sseStringElements (ctx:HttpContext) (elements:string) = 37 | ServerSentEventGenerator.PatchElementsAsync (ctx.Response, elements) 38 | 39 | /// Patch Datastar Signals 40 | let ssePatchSignalsOptions<'T> (ctx:HttpContext) (patchSignalsOptions:PatchSignalsOptions) (jsonSerializerOptions:JsonSerializerOptions) (signals:'T) = 41 | ServerSentEventGenerator.PatchSignalsAsync (ctx.Response, (signals, jsonSerializerOptions) |> JsonSerializer.Serialize, patchSignalsOptions) 42 | 43 | /// Patch Datastar Signals 44 | let ssePatchSignals<'T> (ctx:HttpContext) (signals:'T) = 45 | ServerSentEventGenerator.PatchSignalsAsync (ctx.Response, signals |> JsonSerializer.Serialize) 46 | 47 | // Patch Datastar Signals with a SignalPath -> value 48 | let ssePatchSignalOptions<'T> (ctx:HttpContext) (patchSignalOptions:PatchSignalsOptions) (signalPath:SignalPath) (signalValue:'T) = 49 | let signalPatch = (signalPath, signalValue) ||> SignalPath.createJsonNodeFromPathAndValue |> _.ToJsonString(JsonSerializerOptions.SignalsDefault) 50 | ServerSentEventGenerator.PatchSignalsAsync (ctx.Response, signalPatch, patchSignalOptions) 51 | 52 | // Patch Datastar Signals with a SignalPath -> value 53 | let ssePatchSignal<'T> (ctx:HttpContext) (signalPath:SignalPath) (signalValue:'T) = 54 | let signalPatch = (signalPath, signalValue) ||> SignalPath.createJsonNodeFromPathAndValue |> _.ToJsonString() 55 | ServerSentEventGenerator.PatchSignalsAsync (ctx.Response, signalPatch) 56 | 57 | /// Remove an HTML Element by its selector; or multiple if comma separated 58 | let sseRemoveElementOptions (ctx:HttpContext) (options:RemoveElementOptions) (selector:Selector) = 59 | ServerSentEventGenerator.RemoveElementAsync (ctx.Response, selector, options) 60 | 61 | /// Remove an HTML Element by its selector; or multiple if comma separated 62 | let sseRemoveElement (ctx:HttpContext) (selector:Selector) = 63 | ServerSentEventGenerator.RemoveElementAsync (ctx.Response, selector) 64 | 65 | /// Execute Javascript on the client 66 | let sseExecuteScriptOptions (ctx:HttpContext) (options:ExecuteScriptOptions) (script:string) = 67 | ServerSentEventGenerator.ExecuteScriptAsync (ctx.Response, script, options) 68 | 69 | /// Execute Javascript on the client 70 | let sseExecuteScript (ctx:HttpContext) (script:string) = 71 | ServerSentEventGenerator.ExecuteScriptAsync (ctx.Response, script) 72 | 73 | ////////////////////////////////////////////////////////////////// 74 | // OF RESPONSES 75 | 76 | let internal nu task = task :> Task // removes and converts Task to Task 77 | 78 | /// Patch an HTML DOM with elements 79 | let ofHtmlElementsOptions (options:PatchElementsOptions) (elements:XmlNode) = 80 | (fun ctx -> nu (task { 81 | do! sseStartResponse ctx 82 | return! sseHtmlElementsOptions ctx options elements 83 | })) 84 | 85 | /// Patch an HTML DOM with elements 86 | let ofHtmlElements (elements:XmlNode) = 87 | (fun ctx -> nu (task { 88 | do! sseStartResponse ctx 89 | return! sseHtmlElements ctx elements 90 | })) 91 | 92 | /// Patch HTML DOM with raw elements 93 | let ofHtmlStringElementsOptions (options:PatchElementsOptions) (elements:string) = 94 | (fun ctx -> nu (task { 95 | do! sseStartResponse ctx 96 | return! sseStringElementsOptions ctx options elements 97 | })) 98 | 99 | /// Patch HTML DOM with raw elements 100 | let ofHtmlStringElements (elements:string) = 101 | (fun ctx -> nu (task { 102 | do! sseStartResponse ctx 103 | return! sseStringElements ctx elements 104 | })) 105 | 106 | /// Patch Datastar Signals 107 | let ofPatchSignalsOptions<'T> (patchSignalOptions:PatchSignalsOptions) (jsonSerializerOptions:JsonSerializerOptions) (signals:'T) = 108 | (fun ctx -> nu (task { 109 | do! sseStartResponse ctx 110 | return! ssePatchSignalsOptions ctx patchSignalOptions jsonSerializerOptions signals 111 | })) 112 | 113 | /// Patch Datastar Signals 114 | let ofPatchSignals<'T> (signals:'T) = 115 | (fun ctx -> nu (task { 116 | do! sseStartResponse ctx 117 | return! ssePatchSignals ctx signals 118 | })) 119 | 120 | /// Patch Datastar Signals 121 | let ofPatchSignalOptions<'T> (options:PatchSignalsOptions) (signalPath:SignalPath) (signalValue:'T) = 122 | (fun ctx -> nu (task { 123 | do! sseStartResponse ctx 124 | return! ssePatchSignalOptions ctx options signalPath signalValue 125 | })) 126 | 127 | /// Patch Datastar Signals 128 | let ofPatchSignal<'T> (signalPath:SignalPath) (signalValue:'T) = 129 | (fun ctx -> nu (task { 130 | do! sseStartResponse ctx 131 | return! ssePatchSignal ctx signalPath signalValue 132 | })) 133 | 134 | /// Remove an HTML Element by its selector; or multiple if comma separated 135 | let ofRemoveElementOptions (options:RemoveElementOptions) (selector:Selector) = 136 | (fun ctx -> nu (task { 137 | do! sseStartResponse ctx 138 | return! sseRemoveElementOptions ctx options selector 139 | })) 140 | 141 | /// Remove an HTML Element by its selector; or multiple if comma separated 142 | let ofRemoveElement (selector:Selector) = 143 | (fun ctx -> nu (task { 144 | do! sseStartResponse ctx 145 | return! sseRemoveElement ctx selector 146 | })) 147 | 148 | /// Execute Javascript on the client 149 | let ofExecuteScriptOptions (options:ExecuteScriptOptions) (script:string) = 150 | (fun ctx -> nu (task { 151 | do! sseStartResponse ctx 152 | return! sseExecuteScriptOptions ctx options script 153 | })) 154 | 155 | /// Execute Javascript on the client 156 | let ofExecuteScript (script:string) = 157 | (fun ctx -> nu (task { 158 | do! sseStartResponse ctx 159 | return! sseExecuteScript ctx script 160 | })) 161 | -------------------------------------------------------------------------------- /examples/Signals/Signals.fs: -------------------------------------------------------------------------------- 1 | open Falco 2 | open Falco.Markup 3 | open Falco.Routing 4 | open Falco.Datastar 5 | open Microsoft.AspNetCore.Builder 6 | open Falco.Datastar.SignalsFilter 7 | open Falco.Datastar.SignalPath 8 | 9 | let handleIndex : HttpHandler = 10 | let html = 11 | Elem.html [] [ 12 | Elem.head [ Attr.title "Signals" ] [ Ds.cdnScript ] 13 | Elem.body [] [ 14 | Text.h1 "Example: Signals" 15 | 16 | Elem.hr [] 17 | 18 | Elem.div [ Ds.signal (sp"showSignal", false) ] [ 19 | Elem.h2 [] [ Text.raw "Ds.show" ] 20 | Elem.button 21 | [ Ds.onClick "$showSignal = !$showSignal" ] 22 | [ Text.raw "Click Me" ] 23 | Elem.span 24 | [ Ds.show "$showSignal" ] 25 | [ Text.raw "Peek-a-boo!" ] 26 | ] 27 | 28 | Elem.hr [] 29 | Elem.h2 [] [ Text.raw "Ds.onInterval" ] 30 | 31 | Elem.div [ 32 | Ds.signal (sp"intervalSignalOneSecond", false) 33 | Ds.onInterval ("$intervalSignalOneSecond = !$intervalSignalOneSecond", intervalMs = 1000) 34 | Ds.text "'One Second Interval = ' + $intervalSignalOneSecond" 35 | ] [] 36 | 37 | Elem.div [ 38 | Ds.signal (sp"intervalSignalFiveSecond", false) 39 | Ds.onInterval ("$intervalSignalFiveSecond = !$intervalSignalFiveSecond", intervalMs = 5000, leading = true) 40 | Ds.text "'Five Second Interval = ' + $intervalSignalFiveSecond" 41 | ] [] 42 | 43 | Elem.hr [] 44 | Elem.h2 [] [ Text.raw "Ds.effect" ] 45 | 46 | Elem.div [ 47 | Ds.effect "$effectText = (($intervalSignalFiveSecond ? 2 : 0) + ($intervalSignalOneSecond ? 1 : 0)).toString(2)" 48 | ] [ 49 | Text.raw "(($intervalSignalFiveSecond ? 2 : 0) + ($intervalSignalOneSecond ? 1 : 0)).toString(2) = " 50 | Elem.span [ Ds.text "$effectText" ] [] 51 | ] 52 | 53 | Elem.hr [] 54 | Elem.h2 [] [ Text.raw "Ds.onEvent" ] 55 | 56 | Elem.div [ 57 | Ds.signal (sp"selectedText", "hi") 58 | Ds.onEvent("mouseup", "$selectedText = document.getSelection().toString()") 59 | Ds.onEvent("mouseenter", "$selectedText = 'oooo! The anticipation!'") ] [ Text.raw "Select some of this text" ] 60 | Elem.div [ Ds.text "$selectedText" ] [ Text.raw "hi" ] 61 | 62 | Elem.hr [] 63 | 64 | Elem.div [ Ds.signal (sp"ranges.numberSignal", 50) ] [ 65 | Elem.h2 [] [ Text.raw "Ds.bind" ] 66 | Elem.input [ Attr.id "typeCheckbox"; Attr.typeCheckbox; Ds.bind (sp"toggle.typeCheckbox") ]; Elem.label [Attr.for' "typeCheckbox"] [ Text.raw "Check" ] 67 | Elem.br [] 68 | Elem.input [ Attr.typeRadio; Attr.id "A"; Attr.value "A"; Ds.bind (sp"toggle.typeRadio") ]; Elem.label [Attr.for' "A"] [Text.raw "A"] 69 | Elem.input [ Attr.typeRadio; Attr.id "B"; Attr.value "B"; Ds.bind (sp"toggle.typeRadio") ]; Elem.label [Attr.for' "B"] [Text.raw "B"] 70 | Elem.input [ Attr.typeRadio; Attr.id "C"; Attr.value "C"; Ds.bind (sp"toggle.typeRadio") ]; Elem.label [Attr.for' "C"] [Text.raw "C"] 71 | Elem.br [] 72 | Elem.input [ Attr.typeRange; Attr.min "0"; Attr.max "100"; Ds.bind (sp"ranges.numberSignal") ] 73 | Elem.br [] 74 | Elem.input [ Attr.typeText; Ds.bind (sp"ranges.numberSignal") ] 75 | ] 76 | 77 | Elem.hr [] 78 | 79 | Elem.div [ Ds.computed (sp"ranges.rangedSignal", "$ranges.numberSignal * 10") ] [ 80 | Elem.h2 [] [ Text.raw "Ds.computed" ] 81 | Elem.span [ Ds.text "$ranges.numberSignal + ' * 10 = ' + $ranges.rangedSignal" ] [] 82 | ] 83 | 84 | Elem.hr [] 85 | 86 | Elem.div [] [ 87 | Elem.h2 [] [ Text.raw "Ds.attr'" ] 88 | Elem.input [ Attr.typeRange; Attr.disabled; Attr.readonly; Attr.min "0"; Attr.max "1000"; Ds.attr' ("value", "$ranges.rangedSignal") ] 89 | Elem.br [] 90 | Elem.span [ Ds.text "$ranges.rangedSignal" ] [] 91 | ] 92 | 93 | Elem.hr [] 94 | 95 | Elem.div [ Ds.signal (sp"classSignal", @"off") ] [ 96 | Elem.style [] [ Text.raw ".red { color:red }" ] 97 | Elem.h2 [ Ds.class' ("red", "$classSignal === 'on'") ] [ Text.raw "Ds.class'"; ] 98 | Elem.button 99 | // classSignal could be a bool, but we are demo'ing more complicated expressions 100 | [ Ds.onClick @"$classSignal = $classSignal === 'off' ? 'on' : 'off'" ] 101 | [ Text.raw "Click Me" ] 102 | ] 103 | 104 | Elem.hr [] 105 | 106 | Elem.div [ Ds.signal (sp"checkBoxSignal", false) ] [ 107 | Elem.h2 [] [ Text.raw "Ds.ref"; ] 108 | Elem.input [ Attr.id "checkbox"; Attr.typeCheckbox; Ds.bind (sp"checkBoxSignal") ] 109 | Elem.label [ Attr.for' "checkbox" ] [ Text.raw "I have two favorite numbers" ] 110 | Elem.br [] 111 | 112 | Elem.input [ Attr.id "r1"; Attr.typeRadio; Attr.name "radioGroup1" ] 113 | Elem.label [ Attr.for' "r1" ] [ Text.raw "One" ] 114 | Elem.input [ Attr.id "r2"; Attr.typeRadio; Attr.name "radioGroup1" ] 115 | Elem.label [ Attr.for' "r2" ] [ Text.raw "Two" ] 116 | Elem.input [ Attr.id "r3"; Attr.typeRadio; Attr.name "radioGroup1" ] 117 | Elem.label [ Attr.for' "r3" ] [ Text.raw "Three" ] 118 | Elem.br [] 119 | Elem.label [ Ds.show "$checkBoxSignal" ] [ Text.raw "Group2:" ] 120 | Elem.input [ Attr.id "r4"; Attr.typeRadio; Ds.ref (sp"r4"); Attr.name "radioGroup1" ] 121 | Elem.label [ Attr.for' "r4" ] [ Text.raw "Four" ] 122 | Elem.input [ Attr.id "r5"; Attr.typeRadio; Ds.ref (sp"r5"); Attr.name "radioGroup1" ] 123 | Elem.label [ Attr.for' "r5" ] [ Text.raw "Five" ] 124 | Elem.input [ Attr.id "r6"; Attr.typeRadio; Ds.ref (sp"r6"); Attr.name "radioGroup1" ] 125 | Elem.label [ Attr.for' "r6" ] [ Text.raw "Six" ] 126 | Elem.br [ 127 | // note that this must follow AFTER the refs are created above 128 | Ds.onSignalPatchFilter (sf"^checkBoxSignal$") 129 | Ds.onSignalPatch "$r4.name = $r5.name = $r6.name = ($checkBoxSignal ? 'radioGroup2' : 'radioGroup1')" 130 | ] 131 | ] 132 | 133 | Elem.hr [] 134 | 135 | Elem.div [] [ 136 | Elem.h2 [] [ Text.raw "Signal Debugging" ] 137 | Elem.pre [ Ds.jsonSignals ] [] 138 | ] 139 | ] 140 | ] 141 | Response.ofHtml html 142 | 143 | let handleClick : HttpHandler = 144 | let html = Elem.h2 [ Attr.id "hello" ] [ Text.raw "Hello, World from the Server!" ] 145 | Response.ofHtmlElements html 146 | 147 | let wapp = WebApplication.Create() 148 | wapp.UseStaticFiles() |> ignore 149 | 150 | let endpoints = 151 | [ 152 | get "/" handleIndex 153 | get "/click" handleClick 154 | ] 155 | 156 | wapp.UseRouting() 157 | .UseFalco(endpoints) 158 | .Run() 159 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | [Ss]amples/[Ss]andbox/ 13 | tests/TestResults/ 14 | 15 | # User-specific files (MonoDevelop/Xamarin Studio) 16 | *.userprefs 17 | 18 | # Mono auto generated files 19 | mono_crash.* 20 | 21 | # Build results 22 | [Dd]ebug/ 23 | [Dd]ebugPublic/ 24 | [Rr]elease/ 25 | [Rr]eleases/ 26 | x64/ 27 | x86/ 28 | [Aa][Rr][Mm]/ 29 | [Aa][Rr][Mm]64/ 30 | bld/ 31 | [Bb]in/ 32 | [Oo]bj/ 33 | [Ll]og/ 34 | [Ll]ogs/ 35 | 36 | # Visual Studio 2015/2017 cache/options directory 37 | .vs/ 38 | # Uncomment if you have tasks that create the project's static files in wwwroot 39 | #wwwroot/ 40 | 41 | # Visual Studio 2017 auto generated files 42 | Generated\ Files/ 43 | 44 | # MSTest test Results 45 | [Tt]est[Rr]esult*/ 46 | [Bb]uild[Ll]og.* 47 | 48 | # NUnit 49 | *.VisualState.xml 50 | TestResult.xml 51 | nunit-*.xml 52 | 53 | # Build Results of an ATL Project 54 | [Dd]ebugPS/ 55 | [Rr]eleasePS/ 56 | dlldata.c 57 | 58 | # Benchmark Results 59 | BenchmarkDotNet.Artifacts/ 60 | 61 | # .NET Core 62 | project.lock.json 63 | project.fragment.lock.json 64 | artifacts/ 65 | 66 | # StyleCop 67 | StyleCopReport.xml 68 | 69 | # Files built by Visual Studio 70 | *_i.c 71 | *_p.c 72 | *_h.h 73 | *.ilk 74 | *.meta 75 | *.obj 76 | *.iobj 77 | *.pch 78 | *.pdb 79 | *.ipdb 80 | *.pgc 81 | *.pgd 82 | *.rsp 83 | *.sbr 84 | *.tlb 85 | *.tli 86 | *.tlh 87 | *.tmp 88 | *.tmp_proj 89 | *_wpftmp.csproj 90 | *.log 91 | *.vspscc 92 | *.vssscc 93 | .builds 94 | *.pidb 95 | *.svclog 96 | *.scc 97 | 98 | # Chutzpah Test files 99 | _Chutzpah* 100 | 101 | # Visual C++ cache files 102 | ipch/ 103 | *.aps 104 | *.ncb 105 | *.opendb 106 | *.opensdf 107 | *.sdf 108 | *.cachefile 109 | *.VC.db 110 | *.VC.VC.opendb 111 | 112 | # Visual Studio profiler 113 | *.psess 114 | *.vsp 115 | *.vspx 116 | *.sap 117 | 118 | # Visual Studio Trace Files 119 | *.e2e 120 | 121 | # TFS 2012 Local Workspace 122 | $tf/ 123 | 124 | # Guidance Automation Toolkit 125 | *.gpState 126 | 127 | # ReSharper is a .NET coding add-in 128 | _ReSharper*/ 129 | *.[Rr]e[Ss]harper 130 | *.DotSettings.user 131 | 132 | # TeamCity is a build add-in 133 | _TeamCity* 134 | 135 | # DotCover is a Code Coverage Tool 136 | *.dotCover 137 | 138 | # AxoCover is a Code Coverage Tool 139 | .axoCover/* 140 | !.axoCover/settings.json 141 | 142 | # Visual Studio code coverage results 143 | *.coverage 144 | *.coveragexml 145 | 146 | # NCrunch 147 | _NCrunch_* 148 | .*crunch*.local.xml 149 | nCrunchTemp_* 150 | 151 | # MightyMoose 152 | *.mm.* 153 | AutoTest.Net/ 154 | 155 | # Web workbench (sass) 156 | .sass-cache/ 157 | 158 | # Installshield output folder 159 | [Ee]xpress/ 160 | 161 | # DocProject is a documentation generator add-in 162 | DocProject/buildhelp/ 163 | DocProject/Help/*.HxT 164 | DocProject/Help/*.HxC 165 | DocProject/Help/*.hhc 166 | DocProject/Help/*.hhk 167 | DocProject/Help/*.hhp 168 | DocProject/Help/Html2 169 | DocProject/Help/html 170 | 171 | # Click-Once directory 172 | publish/ 173 | 174 | # Publish Web Output 175 | *.[Pp]ublish.xml 176 | *.azurePubxml 177 | # Note: Comment the next line if you want to checkin your web deploy settings, 178 | # but database connection strings (with potential passwords) will be unencrypted 179 | *.pubxml 180 | *.publishproj 181 | 182 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 183 | # checkin your Azure Web App publish settings, but sensitive information contained 184 | # in these scripts will be unencrypted 185 | PublishScripts/ 186 | 187 | # NuGet Packages 188 | *.nupkg 189 | # NuGet Symbol Packages 190 | *.snupkg 191 | # The packages folder can be ignored because of Package Restore 192 | **/[Pp]ackages/* 193 | # except build/, which is used as an MSBuild target. 194 | !**/[Pp]ackages/build/ 195 | # Uncomment if necessary however generally it will be regenerated when needed 196 | #!**/[Pp]ackages/repositories.config 197 | # NuGet v3's project.json files produces more ignorable files 198 | *.nuget.props 199 | *.nuget.targets 200 | 201 | # Microsoft Azure Build Output 202 | csx/ 203 | *.build.csdef 204 | 205 | # Microsoft Azure Emulator 206 | ecf/ 207 | rcf/ 208 | 209 | # Windows Store app package directories and files 210 | AppPackages/ 211 | BundleArtifacts/ 212 | Package.StoreAssociation.xml 213 | _pkginfo.txt 214 | *.appx 215 | *.appxbundle 216 | *.appxupload 217 | 218 | # Visual Studio cache files 219 | # files ending in .cache can be ignored 220 | *.[Cc]ache 221 | # but keep track of directories ending in .cache 222 | !?*.[Cc]ache/ 223 | 224 | # Others 225 | ClientBin/ 226 | ~$* 227 | *~ 228 | *.dbmdl 229 | *.dbproj.schemaview 230 | *.jfm 231 | *.pfx 232 | *.publishsettings 233 | orleans.codegen.cs 234 | 235 | # Including strong name files can present a security risk 236 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 237 | #*.snk 238 | 239 | # Since there are multiple workflows, uncomment next line to ignore bower_components 240 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 241 | #bower_components/ 242 | 243 | # RIA/Silverlight projects 244 | Generated_Code/ 245 | 246 | # Backup & report files from converting an old project file 247 | # to a newer Visual Studio version. Backup files are not needed, 248 | # because we have git ;-) 249 | _UpgradeReport_Files/ 250 | Backup*/ 251 | UpgradeLog*.XML 252 | UpgradeLog*.htm 253 | ServiceFabricBackup/ 254 | *.rptproj.bak 255 | 256 | # SQL Server files 257 | *.mdf 258 | *.ldf 259 | *.ndf 260 | 261 | # Business Intelligence projects 262 | *.rdl.data 263 | *.bim.layout 264 | *.bim_*.settings 265 | *.rptproj.rsuser 266 | *- [Bb]ackup.rdl 267 | *- [Bb]ackup ([0-9]).rdl 268 | *- [Bb]ackup ([0-9][0-9]).rdl 269 | 270 | # Microsoft Fakes 271 | FakesAssemblies/ 272 | 273 | # GhostDoc plugin setting file 274 | *.GhostDoc.xml 275 | 276 | # Node.js Tools for Visual Studio 277 | .ntvs_analysis.dat 278 | node_modules/ 279 | 280 | # Visual Studio 6 build log 281 | *.plg 282 | 283 | # Visual Studio 6 workspace options file 284 | *.opt 285 | 286 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 287 | *.vbw 288 | 289 | # Visual Studio LightSwitch build output 290 | **/*.HTMLClient/GeneratedArtifacts 291 | **/*.DesktopClient/GeneratedArtifacts 292 | **/*.DesktopClient/ModelManifest.xml 293 | **/*.Server/GeneratedArtifacts 294 | **/*.Server/ModelManifest.xml 295 | _Pvt_Extensions 296 | 297 | # Paket dependency manager 298 | .paket/paket.exe 299 | paket-files/ 300 | 301 | # FAKE - F# Make 302 | .fake/ 303 | 304 | # CodeRush personal settings 305 | .cr/personal 306 | 307 | # Python Tools for Visual Studio (PTVS) 308 | __pycache__/ 309 | *.pyc 310 | 311 | # Cake - Uncomment if you are using it 312 | # tools/** 313 | # !tools/packages.config 314 | 315 | # Tabs Studio 316 | *.tss 317 | 318 | # Telerik's JustMock configuration file 319 | *.jmconfig 320 | 321 | # BizTalk build output 322 | *.btp.cs 323 | *.btm.cs 324 | *.odx.cs 325 | *.xsd.cs 326 | 327 | # OpenCover UI analysis results 328 | OpenCover/ 329 | 330 | # Azure Stream Analytics local run output 331 | ASALocalRun/ 332 | 333 | # MSBuild Binary and Structured Log 334 | *.binlog 335 | 336 | # NVidia Nsight GPU debugger configuration file 337 | *.nvuser 338 | 339 | # MFractors (Xamarin productivity tool) working folder 340 | .mfractor/ 341 | 342 | # Local History for Visual Studio 343 | .localhistory/ 344 | 345 | # BeatPulse healthcheck temp database 346 | healthchecksdb 347 | 348 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 349 | MigrationBackup/ 350 | 351 | # Ionide (cross platform F# VS Code tools) working folder 352 | .ionide/ 353 | .DS_Store 354 | 355 | # Rider (JetBrain's cross-platform .NET IDE) working folder 356 | .idea/ 357 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /src/Falco.Datastar/Types.fs: -------------------------------------------------------------------------------- 1 | namespace Falco.Datastar 2 | 3 | open System 4 | open System.Collections.Generic 5 | open System.Text 6 | open System.Text.Json 7 | open System.Text.Json.Nodes 8 | open System.Web 9 | open Falco.Markup 10 | open StarFederation.Datastar.FSharp 11 | 12 | module Constants = 13 | let mutable dataSlugPrefix = "data" 14 | 15 | type SignalsFilter = 16 | { IncludePattern : string voption 17 | ExcludePattern : string voption } 18 | static member None = { IncludePattern = ValueNone; ExcludePattern = ValueNone } 19 | static member Include pattern = { IncludePattern = ValueSome pattern; ExcludePattern = ValueNone } 20 | static member Exclude pattern = { IncludePattern = ValueNone; ExcludePattern = ValueSome pattern } 21 | static member Serialize (signalFilter:SignalsFilter) = 22 | if signalFilter = SignalsFilter.None then 23 | "" 24 | else 25 | StringBuilder() 26 | |> _.Append("{ ") 27 | |> (fun sb -> 28 | let filters = seq { 29 | if (signalFilter.IncludePattern <> ValueNone) then 30 | signalFilter.IncludePattern |> ValueOption.get |> (fun incStr -> $"include: /{incStr}/") 31 | if (signalFilter.ExcludePattern <> ValueNone) then 32 | signalFilter.ExcludePattern |> ValueOption.get |> (fun excStr -> $"exclude: /{excStr}/") 33 | } 34 | sb.AppendJoin(',', filters) 35 | ) 36 | |> _.Append(" }") 37 | |> _.ToString() 38 | 39 | module SignalsFilter = 40 | let sf (includePattern:string) = SignalsFilter.Include includePattern 41 | 42 | module SignalPath = 43 | let sp = SignalPath.create 44 | 45 | let getSignalFromJson<'T> (signalPath:SignalPath) (jsonDocument:JsonDocument) = 46 | let getSignalCore (jsonElement:JsonElement) (signalPath:SignalPath) = 47 | signalPath 48 | |> SignalPath.keys 49 | |> Seq.fold (fun (currentJsonElementOpt:JsonElement voption) (key:string) -> 50 | currentJsonElementOpt 51 | |> ValueOption.bind (fun (jsonElement:JsonElement) -> 52 | match jsonElement.TryGetProperty(key) with 53 | | false, _ -> ValueNone 54 | | true, jsonElement -> ValueSome jsonElement 55 | ) 56 | ) (ValueSome jsonElement) 57 | try 58 | getSignalCore jsonDocument.RootElement signalPath |> ValueOption.map _.Deserialize<'T>() 59 | with | _ -> ValueNone 60 | 61 | let createJsonNodeFromPathAndValue<'T> signalPath (signalValue:'T) = 62 | signalPath 63 | |> SignalPath.keys 64 | |> Seq.rev 65 | |> Seq.fold (fun json key -> 66 | JsonObject([ KeyValuePair (key, json) ]) :> JsonNode 67 | ) (JsonValue.Create(signalValue) :> JsonNode) 68 | 69 | module Selector = 70 | let sel = Selector.create 71 | 72 | type IntersectsVisibility = 73 | /// Triggers on exit 74 | | Exit 75 | /// Triggers when half of the element is visible 76 | | Half 77 | /// Triggers when the full element is visible 78 | | Full 79 | 80 | type BackendAction = 81 | | Get of url:string 82 | | Post of url:string 83 | | Put of url:string 84 | | Patch of url:string 85 | | Delete of url:string 86 | 87 | type ContentType = 88 | /// default, filtered signals; default 89 | | Json 90 | /// sends a custom object instead of the default, filtered signals 91 | | CustomJson of obj 92 | /// validates inputs of closest form and sends them to the backend 93 | | Form 94 | /// similar to Form, but specify the form id to send 95 | | SelectedForm of StarFederation.Datastar.FSharp.Selector 96 | 97 | type Retry = 98 | /// retry on network errors; default 99 | | OnAuto 100 | /// retries on 4xx and 5xx responses 101 | | OnError 102 | /// retries on all non-204 responses, except redirects 103 | | OnAlways 104 | /// disables retry 105 | | OnNever 106 | 107 | type RequestCancellation = 108 | /// cancels existing requests on the same element; default 109 | | Auto 110 | /// allows concurrent requests 111 | | Disabled 112 | /// an object name that can be aborted; https://data-star.dev/reference/actions#request-cancellation; 113 | /// creator should include '$', e.g. (AbortController "$controller") 114 | | AbortController of string 115 | with 116 | static member Serialize (requestCancellation:RequestCancellation) = 117 | match requestCancellation with 118 | | Auto -> "auto" 119 | | Disabled -> "disabled" 120 | | AbortController controller -> controller 121 | 122 | type ResponseOverrideMode = 123 | | Outer 124 | | Inner 125 | | Remove 126 | | Replace 127 | | Prepend 128 | | Append 129 | | Before 130 | | After 131 | 132 | /// Request Options for backend action plugins 133 | /// https://data-star.dev/reference/action_plugins 134 | type RequestOptions = { 135 | /// The type of content to send. A value of json sends all signals in a JSON request. 136 | /// A value of form tells the action to look for the closest form to the element on which it is placed 137 | /// (unless a selector option is provided), perform validation on the form elements, 138 | /// and send them to the backend using a form request (no signals are sent). Defaults to json. 139 | ContentType: ContentType 140 | 141 | /// Filter object utilizing regular expressions for which signals to send 142 | FilterSignals: SignalsFilter 143 | 144 | /// HTTP Headers to send with the request. 145 | Headers: (string * string) list 146 | 147 | /// Whether to keep the connection open when the page is hidden. Useful for dashboards 148 | /// but can cause a drain on battery life and other resources when enabled. Defaults to false. 149 | OpenWhenHidden: bool 150 | 151 | /// Determines on what to retry; auto, error, always, never 152 | Retry: Retry 153 | 154 | /// The retry interval in milliseconds. Defaults to 1 second 155 | RetryInterval: TimeSpan 156 | 157 | /// A numeric multiplier applied to scale retry wait times. Defaults to 2. 158 | RetryScaler: float 159 | 160 | /// The maximum allowable wait time in milliseconds between retries. Defaults to 30 seconds. 161 | RetryMaxWait: TimeSpan 162 | 163 | /// The maximum number of retry attempts. Defaults to 10. 164 | RetryMaxCount: int 165 | 166 | /// An AbortSignal object that can be used to cancel the request. 167 | /// https://data-star.dev/reference/actions#request-cancellation 168 | RequestCancellation: RequestCancellation 169 | } 170 | with 171 | static member Defaults = 172 | { ContentType = Json 173 | FilterSignals = SignalsFilter.None 174 | Headers = [] 175 | OpenWhenHidden = false 176 | Retry = Retry.OnAuto 177 | RetryInterval = TimeSpan.FromSeconds(1.0) 178 | RetryScaler = 2.0 179 | RetryMaxWait = TimeSpan.FromSeconds(30.0) 180 | RetryMaxCount = 10 181 | RequestCancellation = Auto } 182 | 183 | static member inline With contentType = { RequestOptions.Defaults with ContentType = contentType } 184 | 185 | static member internal Serialize (backendActionOptions:RequestOptions) = 186 | let jsonObject = JsonObject() 187 | 188 | match backendActionOptions.ContentType with 189 | | _ when backendActionOptions.ContentType = RequestOptions.Defaults.ContentType -> () 190 | | Form -> jsonObject.Add("contentType", "form") 191 | | SelectedForm formSelector -> 192 | jsonObject.Add("contentType", "form") 193 | jsonObject.Add("selector", formSelector) 194 | | CustomJson customJson -> 195 | let serializedOverride = JsonSerializer.Serialize(customJson, JsonSerializerOptions.SignalsDefault) 196 | jsonObject.Add("contentType", "json") 197 | jsonObject.Add("override", serializedOverride) 198 | | Json -> jsonObject.Add("contentType", "json") 199 | 200 | if backendActionOptions.FilterSignals <> RequestOptions.Defaults.FilterSignals then 201 | jsonObject.Add("filterSignals", backendActionOptions.FilterSignals |> SignalsFilter.Serialize |> JsonNode.Parse) 202 | 203 | if backendActionOptions.Headers.Length > 0 then 204 | let headerObject = JsonObject() 205 | backendActionOptions.Headers |> List.iter headerObject.Add 206 | jsonObject.Add("headers", headerObject) 207 | 208 | if backendActionOptions.OpenWhenHidden <> RequestOptions.Defaults.OpenWhenHidden then 209 | jsonObject.Add("openWhenHidden", backendActionOptions.OpenWhenHidden.ToString().ToLower()) 210 | 211 | if backendActionOptions.RetryInterval <> RequestOptions.Defaults.RetryInterval then 212 | jsonObject.Add("retryInterval", backendActionOptions.RetryInterval.TotalMilliseconds) 213 | 214 | if backendActionOptions.RetryScaler <> RequestOptions.Defaults.RetryScaler then 215 | jsonObject.Add("retryScaler", backendActionOptions.RetryScaler) 216 | 217 | if backendActionOptions.RetryMaxWait <> RequestOptions.Defaults.RetryMaxWait then 218 | jsonObject.Add("retryMaxWaitMs", backendActionOptions.RetryMaxWait.TotalMilliseconds) 219 | 220 | if backendActionOptions.RetryMaxCount <> RequestOptions.Defaults.RetryMaxCount then 221 | jsonObject.Add("retryMaxCount", backendActionOptions.RetryMaxCount) 222 | 223 | if backendActionOptions.RequestCancellation <> RequestOptions.Defaults.RequestCancellation then 224 | let requestCancellation = backendActionOptions.RequestCancellation |> RequestCancellation.Serialize 225 | jsonObject.Add("requestCancellation", requestCancellation) 226 | 227 | let options = JsonSerializerOptions() 228 | options.WriteIndented <- false 229 | HttpUtility.HtmlEncode(jsonObject.ToJsonString(options)) 230 | 231 | type Debounce = 232 | { TimeSpan:TimeSpan 233 | Leading:bool 234 | NoTrailing:bool } 235 | static member inline With (timeSpan:TimeSpan, ?leading:bool, ?noTrailing:bool) = 236 | { TimeSpan = timeSpan; Leading = (defaultArg leading false); NoTrailing = (defaultArg noTrailing false) } 237 | static member inline With (milliseconds:float, ?leading:bool, ?noTrailing:bool) = 238 | { TimeSpan = TimeSpan.FromMilliseconds(milliseconds); Leading = (defaultArg leading false); NoTrailing = (defaultArg noTrailing false) } 239 | 240 | type Throttle = 241 | { TimeSpan:TimeSpan 242 | NoLeading:bool 243 | Trailing:bool } 244 | static member inline With (timeSpan:TimeSpan, ?noLeading:bool, ?trailing:bool) = 245 | { TimeSpan = timeSpan; NoLeading = (defaultArg noLeading false); Trailing = (defaultArg trailing false) } 246 | static member inline With (milliseconds:float, ?noLeading:bool, ?trailing:bool) = 247 | { TimeSpan = TimeSpan.FromMilliseconds(milliseconds); NoLeading = (defaultArg noLeading false); Trailing = (defaultArg trailing false) } 248 | 249 | type OnEventModifier = 250 | /// Trigger event once. Can only be used with the built-in events 251 | | Once 252 | /// Do not call `preventDefault` on the event listener. Can only be used with the built-in events 253 | | Passive 254 | /// Use a capture event listener. Can only be used with the built-in events 255 | | Capture 256 | /// Delay the event listener by a Timespan 257 | | Delay of TimeSpan 258 | /// Delay the event listener by milliseconds 259 | | DelayMs of int 260 | /// Debounce the event listener; new events after an initial event, within a TimeSpan, are ignored. 261 | | Debounce of Debounce 262 | /// Throttle the event listener; only fires the last event within a TimeSpan. 263 | | Throttle of Throttle 264 | /// Attaches the event listener to the window element. 265 | | Window 266 | /// Triggers the event when it occurs outside the element. 267 | | Outside 268 | /// Call `preventDefault` on the event listener 269 | | Prevent 270 | /// Calls `stopPropagation` on the event listener. 271 | | Stop 272 | /// Wrap the expression in document.startViewTransition(), if View Transition API is available 273 | | ViewTransition 274 | 275 | /// 276 | /// Modifier for a DsAttr. <data-...__Name.Tag.Tag=...> 277 | /// 278 | type DsAttrModifier = 279 | { Name:string 280 | Tags:string list } 281 | with 282 | static member inline Delay (delay:TimeSpan) = 283 | { Name = "delay"; Tags = [ $"{delay.TotalMilliseconds}ms" ] } 284 | 285 | static member inline DelayMs (delay:int) = 286 | { Name = "delay"; Tags = [ $"{delay}ms" ] } 287 | 288 | static member inline DurationMs (duration:int, leading:bool) = 289 | { Name = "duration" 290 | Tags = [ 291 | $"{duration}ms" 292 | if leading then "leading" 293 | ] } 294 | 295 | static member inline Throttle (throttle:Throttle) = 296 | { Name = "throttle" 297 | Tags = [ 298 | $"{throttle.TimeSpan.TotalMilliseconds}ms" 299 | if throttle.NoLeading then "noleading" 300 | if throttle.Trailing then "trailing" 301 | ] } 302 | 303 | static member inline Debounce (debounce:Debounce) = 304 | { Name = "debounce" 305 | Tags = [ 306 | $"{debounce.TimeSpan.TotalMilliseconds}ms" 307 | if debounce.Leading then "leading" 308 | if debounce.NoTrailing then "notrailing" 309 | ] } 310 | 311 | static member inline Threshold (threshold:int) = 312 | { Name = "threshold" 313 | Tags = [ $"{threshold}" ] } 314 | 315 | static member inline OnEventModifier (onEventModifier:OnEventModifier) = 316 | match onEventModifier with 317 | | Once -> { Name = "once"; Tags = [] } 318 | | Passive -> { Name = "passive"; Tags = [] } 319 | | Capture -> { Name = "capture"; Tags = [] } 320 | | Delay delay -> (DsAttrModifier.Delay delay) 321 | | DelayMs ms -> { Name = "delay"; Tags = [ $"{ms}ms" ] } 322 | | Debounce debounce -> (DsAttrModifier.Debounce debounce) 323 | | Throttle throttle -> (DsAttrModifier.Throttle throttle) 324 | | ViewTransition -> { Name = "viewtransition"; Tags = [] } 325 | | Window -> { Name = "window"; Tags = [] } 326 | | Outside -> { Name = "outside"; Tags = [] } 327 | | Prevent -> { Name = "prevent"; Tags = [] } 328 | | Stop -> { Name = "stop"; Tags = [] } 329 | 330 | /// 331 | /// <data-Name-Target__Modifiers="Value"> 332 | /// 333 | type DsAttr = 334 | { Name:string 335 | Target:string voption 336 | Modifiers:DsAttrModifier list 337 | HasCaseModifier:bool 338 | Value:string voption } 339 | with 340 | static member inline start name = 341 | { Name = name; Target = ValueNone; Modifiers = []; Value = ValueNone; HasCaseModifier = false } 342 | 343 | static member inline startEvent eventName = 344 | { Name = $"on"; Target = ValueSome eventName; Modifiers = []; Value = ValueNone; HasCaseModifier = false } 345 | 346 | static member inline addTarget name dsAttr= 347 | { dsAttr with Target = ValueSome name } 348 | 349 | static member inline addSignalPathTarget (signalPath:SignalPath) = 350 | signalPath 351 | |> SignalPath.keys 352 | |> Seq.map SignalPath.kebabValue 353 | |> String.concat "." 354 | |> DsAttr.addTarget 355 | 356 | static member inline addModifier modifier dsAttr = 357 | { dsAttr with Modifiers = (modifier :: dsAttr.Modifiers) } 358 | 359 | static member inline addModifierOption modifierOption dsAttr = 360 | match modifierOption with 361 | | ValueSome modifier -> DsAttr.addModifier modifier dsAttr 362 | | ValueNone -> dsAttr 363 | 364 | static member inline addModifierName modifierName = 365 | DsAttr.addModifier { Name = modifierName; Tags = [] } 366 | 367 | static member inline addModifierNameIf modifierName bool dsAttr = 368 | if bool 369 | then DsAttr.addModifierName modifierName dsAttr 370 | else dsAttr 371 | 372 | static member inline addValue (value:string) dsAttr = 373 | { dsAttr with Value = ValueSome value } 374 | 375 | static member inline generateKey dsAttr = 376 | StringBuilder() 377 | |> _.Append(Constants.dataSlugPrefix) |> _.Append('-') 378 | |> _.Append(dsAttr.Name) 379 | |> (fun sb -> 380 | match dsAttr.Target with 381 | | ValueNone -> sb 382 | | ValueSome target -> sb.Append(':') |> _.Append(target) 383 | ) 384 | |> (fun sb -> 385 | match dsAttr.Modifiers with 386 | | [] -> sb 387 | | modifiers -> 388 | for modifier in modifiers do 389 | sb.Append("__") |> _.Append(modifier.Name) |> ignore 390 | for tag in modifier.Tags do 391 | sb.Append('.') |> _.Append(tag) |> ignore 392 | sb 393 | ) 394 | |> _.ToString() 395 | 396 | static member inline create dsAttr = 397 | let dsAttrKey = dsAttr |> DsAttr.generateKey 398 | match dsAttr.Value with 399 | | ValueSome value -> Attr.create dsAttrKey value 400 | | ValueNone -> Attr.createBool dsAttrKey 401 | 402 | static member inline create (name, ?targetName, ?value, ?hasCaseModifier) = 403 | { Name = name 404 | Target = targetName |> Option.toValueOption 405 | Modifiers = [] 406 | Value = value |> Option.toValueOption 407 | HasCaseModifier = (defaultArg hasCaseModifier false) } 408 | |> DsAttr.create 409 | 410 | static member inline createSp (name, signalPath, ?value, ?hasCaseModifier) = 411 | { Name = name 412 | Target = 413 | signalPath 414 | |> SignalPath.kebabValue 415 | |> ValueSome 416 | Modifiers = [] 417 | Value = value |> Option.toValueOption 418 | HasCaseModifier = (defaultArg hasCaseModifier false) } 419 | |> DsAttr.create 420 | 421 | -------------------------------------------------------------------------------- /src/Falco.Datastar/Ds.fs: -------------------------------------------------------------------------------- 1 | namespace Falco.Datastar 2 | 3 | open System 4 | open System.Text.Json 5 | open System.Text.Json.Nodes 6 | open System.Web 7 | open Falco.Markup 8 | open StarFederation.Datastar.FSharp 9 | 10 | [] 11 | type Ds = 12 | static member cdnSrc = 13 | @"https://cdn.jsdelivr.net/gh/starfederation/datastar@1.0.0-RC.7/bundles/datastar.js" 14 | 15 | /// 16 | /// Shorthand for `Elem.script [ Attr.type' "module"; Attr.src cdnSrc ] []` 17 | /// 18 | /// Attribute 19 | static member cdnScript = 20 | Elem.script [ Attr.type' "module"; Attr.src Ds.cdnSrc ] [] 21 | 22 | /// 23 | /// Patches a signal into the existing signals with the given value. 24 | /// Has an optional ifMissing flag. https://data-star.dev/reference/attributes#data-signals 25 | /// 26 | /// The path to add. Prefix with underscore to keep the signal local to the browser, and not returned in a @get, @post, etc 27 | /// The initial value to set the signal 28 | /// Signal is only merged if it doesn't already exist 29 | /// Attribute 30 | static member inline signal<'T> (signalPath:SignalPath, signalValue:'T, ?ifMissing) = 31 | DsAttr.start "signals" 32 | |> DsAttr.addSignalPathTarget signalPath 33 | |> DsAttr.addModifierNameIf "ifmissing" (defaultArg ifMissing false) 34 | |> DsAttr.addValue ( 35 | match typeof<'T> with 36 | | t when t = typeof -> "'" + signalValue.ToString() + "'" 37 | | _ -> JsonValue.Create<'T>(signalValue).ToJsonString()) 38 | |> DsAttr.create 39 | 40 | /// 41 | /// Patches one or more signals into the existing signals. 42 | /// https://data-star.dev/reference/attributes#data-signals 43 | /// 44 | /// An object that will be serialized via System.Text.JsonSerializer.Serialize() 45 | /// Prefix signal paths with underscore to keep the signal local t the browser 46 | /// Signals are only merged if it doesn't already exist 47 | /// Optional options to be passed to the JSON serializer 48 | /// Attribute 49 | static member signals (signals, ?ifMissing, ?options:JsonSerializerOptions) = 50 | let options' = defaultArg options JsonSerializerOptions.SignalsDefault 51 | DsAttr.start "signals" 52 | |> DsAttr.addModifierNameIf "ifmissing" (defaultArg ifMissing false) 53 | |> DsAttr.addValue (HttpUtility.HtmlEncode(JsonSerializer.Serialize (signals, options'))) 54 | |> DsAttr.create 55 | 56 | /// 57 | /// Bind an element's attribute value to an expression. 58 | /// https://data-star.dev/reference/attributes#data-attr 59 | /// 60 | /// An HTML element attribute 61 | /// Expression to be evaluated and assigned to the attribute, https://data-star.dev/guide/datastar_expressions 62 | /// Attribute 63 | static member inline attr' (attributeName, expression) = 64 | DsAttr.create ("attr", targetName = attributeName, value = expression) 65 | 66 | /// 67 | /// Binds a signal to an element's value. Can be added to any element on which data can be input. 68 | /// input, textarea, select, checkbox, radio, and web components. 69 | /// https://data-star.dev/reference/attributes#data-bind 70 | /// 71 | /// The signal to bind to 72 | /// Attribute 73 | static member inline bind signalPath = 74 | DsAttr.createSp ("bind", signalPath) 75 | 76 | /// 77 | /// Adds or removes a class from the element based on an expression. 78 | /// https://data-star.dev/reference/attributes#data-class 79 | /// 80 | /// Name of the class to add or remove 81 | /// Expression to evaluate; if true, then the class is added; otherwise, removed. https://data-star.dev/guide/datastar_expressions 82 | /// Attribute 83 | static member class' (className, boolExpression) = 84 | DsAttr.start "class" 85 | |> DsAttr.addTarget className 86 | |> DsAttr.addValue boolExpression 87 | |> DsAttr.create 88 | 89 | /// 90 | /// Sets the value of the inline CSS styles on an element based on an expression 91 | /// 92 | /// The style to set, https://www.w3schools.com/cssref/index.php 93 | /// Expression to be evaluated and assigned to the style property, https://data-star.dev/guide/datastar_expressions 94 | static member inline style (styleProperty, propertyValueExpression) = 95 | DsAttr.create ("style", targetName = styleProperty, value = propertyValueExpression) 96 | 97 | /// 98 | /// Bind the content text of the element to an expression. 99 | /// https://data-star.dev/reference/attributes#data-text 100 | /// 101 | /// Expression to be evaluated, https://data-star.dev/guide/datastar_expressions 102 | /// Attribute 103 | static member inline text expression = 104 | DsAttr.create ("text", value = expression) 105 | 106 | /// 107 | /// Creates a readonly signal that is computed based on an expression. 108 | /// The signalPath must not be used as for performing actions; use Ds.effect instead. 109 | /// https://data-star.dev/reference/attributes#data-computed 110 | /// 111 | /// Name of signal to contain the expression 112 | /// Expression to be evaluated, https://data-star.dev/guide/datastar_expressions 113 | /// Attribute 114 | static member computed (signalPath, expression) = 115 | DsAttr.start "computed" 116 | |> DsAttr.addSignalPathTarget signalPath 117 | |> DsAttr.addValue expression 118 | |> DsAttr.create 119 | 120 | /// 121 | /// Create a signal that refers to the HTML element it is assigned to; after a data-ref is created, you can access attributes of the element. 122 | /// e.g. data-on:click="$signalRefName.value='newValue'". 123 | /// Note: that if an element's attribute changes, the expressions containing this signal will not fire. 124 | /// https://data-star.dev/reference/attributes#data-ref 125 | /// 126 | /// Name of signal to contain the HTML element 127 | /// Attribute 128 | static member ref signalPath = 129 | DsAttr.start "ref" 130 | |> DsAttr.addValue signalPath 131 | |> DsAttr.create 132 | 133 | /// 134 | /// Show or hides an element based on an expressions "true-ness". 135 | /// https://data-star.dev/reference/attributes#data-show 136 | /// 137 | /// The expression that will be evaluated; if true = the element is visible, https://data-star.dev/guide/datastar_expressions 138 | /// Attribute 139 | static member inline show boolExpression = 140 | DsAttr.create ("show", value = boolExpression) 141 | 142 | /// 143 | /// Execute an expression on page load and whenever any signals in the expression change. 144 | /// 145 | /// The expression to fire 146 | /// Attribute 147 | static member inline effect (expression:string) = 148 | DsAttr.create ("effect", value = expression) 149 | 150 | /// 151 | /// This will create a signal and set its value to `true` while a server request is in flight, otherwise `false`. 152 | /// Place this in the same element as a Ds.get, Ds.post, etc 153 | /// https://data-star.dev/reference/attributes#data-indicator 154 | /// 155 | /// The name of the signal to create 156 | /// Attribute 157 | static member indicator signalPath = 158 | DsAttr.start "indicator" 159 | |> DsAttr.addSignalPathTarget signalPath 160 | |> DsAttr.create 161 | 162 | /// 163 | /// Preserves the value of an attribute when morphing DOM elements. 164 | /// https://data-star.dev/reference/attributes#data-preserve-attr 165 | /// 166 | /// Space delimited list of attributes you want retained on patch elements 167 | static member preserveAttr attributeName = 168 | DsAttr.start "preserve-attr" 169 | |> DsAttr.addValue attributeName 170 | |> DsAttr.create 171 | 172 | /// 173 | /// Datastar walks the entire DOM and applies plugins to each element it encounters. 174 | /// It’s possible to tell Datastar to ignore an element and its descendants by placing a data-star-ignore attribute on it. 175 | /// This can be useful for preventing naming conflicts with third-party libraries. 176 | /// https://data-star.dev/reference/attributes#data-ignore 177 | /// 178 | /// Attribute 179 | static member inline ignore = 180 | DsAttr.create "ignore" 181 | 182 | /// 183 | /// Datastar walks the entire DOM and applies plugins to each element it encounters. 184 | /// It’s possible to tell Datastar to ignore an element and its descendants by placing a data-star-ignore attribute on it. 185 | /// This can be useful for preventing naming conflicts with third-party libraries. 186 | /// This only ignores the element it is attached to. 187 | /// https://data-star.dev/reference/attributes#data-ignore 188 | /// 189 | /// Attribute 190 | static member ignoreSelf = 191 | DsAttr.start "ignore" 192 | |> DsAttr.addModifier { Name="self"; Tags = [] } 193 | |> DsAttr.create 194 | 195 | /// 196 | /// Similar to the Ds.ignore, the data-ignore-morph attribute tells the PatchElements watcher to skip processing an element and its children when morphing elements. 197 | /// https://data-star.dev/reference/attributes#data-ignore-morph 198 | /// 199 | /// Attribute 200 | static member inline ignoreMorph = 201 | DsAttr.create "ignore-morph" 202 | 203 | /// 204 | /// Sets the text content of an element to a reactive JSON stringified version of signals. Useful for troubleshooting. 205 | /// https://data-star.dev/reference/attributes#data-json-signals 206 | /// 207 | static member inline jsonSignals = 208 | DsAttr.create "json-signals" 209 | 210 | /// 211 | /// Sets the text content of an element to a reactive JSON stringified version of signals. Useful for troubleshooting. 212 | /// https://data-star.dev/reference/attributes#data-json-signals 213 | /// 214 | /// Regex of signal paths to be included and excluded 215 | /// Single line output 216 | static member jsonSignalsOptions (?signalsFilter:SignalsFilter, ?terse:bool) = 217 | let addSignalsFilter signalsFilter dsAttr = 218 | if signalsFilter <> SignalsFilter.None 219 | then dsAttr |> DsAttr.addValue (signalsFilter |> SignalsFilter.Serialize) 220 | else dsAttr 221 | DsAttr.start "json-signals" 222 | |> DsAttr.addModifierNameIf "terse" (defaultArg terse false) 223 | |> addSignalsFilter (defaultArg signalsFilter SignalsFilter.None) 224 | |> DsAttr.create 225 | 226 | /// 227 | /// Attaches an event listener to an element, executing the expression whenever the event is triggered. 228 | /// https://data-star.dev/reference/attributes#data-on 229 | /// 230 | /// The event to listen to, i.e. https://developer.mozilla.org/en-US/docs/Web/Events 231 | /// The expression to evaluate when the event is triggered; https://data-star.dev/guide/datastar_expressions 232 | /// To modify the behavior of the event 233 | /// Attribute 234 | static member onEvent (eventName, expression, ?eventModifiers:OnEventModifier list) = 235 | DsAttr.startEvent eventName 236 | |> (fun dsAttr -> // event modifiers 237 | (defaultArg eventModifiers []) 238 | |> List.fold (fun dsAttr eventModifier -> DsAttr.addModifier (DsAttrModifier.OnEventModifier eventModifier) dsAttr) dsAttr 239 | ) 240 | |> DsAttr.addValue expression 241 | |> DsAttr.create 242 | 243 | /// 244 | /// Adds an on-click listener to the element and executes the expression. 245 | /// https://data-star.dev/reference/attributes#data-on 246 | /// 247 | /// The expression to evaluate when the event is triggered; https://data-star.dev/guide/datastar_expressions 248 | /// To modify the behavior of the event 249 | /// Attribute 250 | static member onClick (expression, ?eventModifiers) = 251 | Ds.onEvent ("click", expression, ?eventModifiers = eventModifiers) 252 | 253 | /// 254 | /// Fires the expression when the element is loaded. 255 | /// https://data-star.dev/reference/attributes#data-init 256 | /// 257 | /// The expression to evaluate when the event is triggered; https://data-star.dev/guide/datastar_expressions 258 | /// The time to wait before executing the expression in milliseconds; default = 0 259 | /// Wrap expression in document.startViewTransition(); default = false 260 | /// Attribute 261 | static member onInit (expression, ?delayMs, ?viewTransition) = 262 | DsAttr.start "init" 263 | |> DsAttr.addModifierOption (delayMs |> Option.toValueOption |> ValueOption.map DsAttrModifier.DelayMs) 264 | |> DsAttr.addModifierNameIf "viewtransition" (defaultArg viewTransition false) 265 | |> DsAttr.addValue expression 266 | |> DsAttr.create 267 | 268 | /// 269 | /// Evaluates the expression on a steady interval 270 | /// 271 | /// The expression to evaluate when the event is triggered; https://data-star.dev/guide/datastar_expressions 272 | /// The time between each evaluation; default = 1000 273 | /// Execute the first interval immediately; default = false 274 | /// Wrap expression in document.startViewTransition(); default = false 275 | /// Attribute 276 | static member onInterval (expression, intervalMs, ?leading, ?viewTransition) = 277 | DsAttr.start "on-interval" 278 | |> DsAttr.addModifierNameIf "viewtransition" (defaultArg viewTransition false) 279 | |> DsAttr.addModifier (DsAttrModifier.DurationMs (intervalMs, (defaultArg leading false))) 280 | |> DsAttr.addValue expression 281 | |> DsAttr.create 282 | 283 | /// 284 | /// Fires the expression when a signal is changed. Filter using Ds.filterOnSignalPatch 285 | /// https://data-star.dev/reference/attributes#data-on-signal-patch 286 | /// 287 | /// The expression to evaluate when the event is triggered; https://data-star.dev/guide/datastar_expressions 288 | /// The time to wait before executing the expression in milliseconds; default = 0 289 | /// 290 | /// 291 | /// Attribute 292 | static member onSignalPatch (expression, ?delayMs:int, ?debounce:Debounce, ?throttle:Throttle) = 293 | DsAttr.start "on-signal-patch" 294 | |> DsAttr.addModifierOption (delayMs |> Option.toValueOption |> ValueOption.map DsAttrModifier.DelayMs) 295 | |> DsAttr.addModifierOption (debounce |> Option.toValueOption |> ValueOption.map DsAttrModifier.Debounce) 296 | |> DsAttr.addModifierOption (throttle |> Option.toValueOption |> ValueOption.map DsAttrModifier.Throttle) 297 | |> DsAttr.addValue expression 298 | |> DsAttr.create 299 | 300 | /// 301 | /// Filter the signals that cause Ds.onSignalPatch to fire 302 | /// https://data-star.dev/reference/attributes#data-on-signal-patch-filter 303 | /// 304 | /// Regex of signal paths to be included and excluded 305 | /// Attribute 306 | static member onSignalPatchFilter (signalsFilter:SignalsFilter) = 307 | DsAttr.start "on-signal-patch-filter" 308 | |> DsAttr.addValue (signalsFilter |> SignalsFilter.Serialize) 309 | |> DsAttr.create 310 | 311 | /// 312 | /// Runs an expression when the element intersects with the viewport. 313 | /// https://data-star.dev/reference/attributes#data-on-intersect 314 | /// 315 | /// Expression to run when element is intersected 316 | /// Sets it to trigger only if the element is exited, or half or fully viewed 317 | /// Only triggers the event once 318 | /// The time to wait before executing the expression in milliseconds; default = 0 319 | /// Debounce the event listener 320 | /// Throttle the event listener 321 | /// Wrap expression in document.startViewTransition(); default = false 322 | /// Triggers when the element is visible by a certain percentage (0-100) 323 | /// Attribute 324 | static member onIntersect (expression, ?visibility, ?onlyOnce, ?delayMs:int, ?debounce:Debounce, ?throttle:Throttle, ?viewTransition:bool, ?threshold:int) = 325 | DsAttr.start "on-intersect" 326 | |> (fun dsAttr -> 327 | match visibility with 328 | | Some vis when vis = IntersectsVisibility.Exit -> DsAttr.addModifierName "exit" dsAttr 329 | | Some vis when vis = IntersectsVisibility.Full -> DsAttr.addModifierName "full" dsAttr 330 | | Some vis when vis = IntersectsVisibility.Half -> DsAttr.addModifierName "half" dsAttr 331 | | _ -> dsAttr 332 | ) 333 | |> DsAttr.addModifierNameIf "once" (defaultArg onlyOnce false) 334 | |> DsAttr.addModifierNameIf "viewtransition" (defaultArg viewTransition false) 335 | |> DsAttr.addModifierOption (delayMs |> Option.toValueOption |> ValueOption.map DsAttrModifier.DelayMs) 336 | |> DsAttr.addModifierOption (debounce |> Option.toValueOption |> ValueOption.map DsAttrModifier.Debounce) 337 | |> DsAttr.addModifierOption (throttle |> Option.toValueOption |> ValueOption.map DsAttrModifier.Throttle) 338 | |> DsAttr.addModifierOption (threshold |> Option.toValueOption |> ValueOption.map (fun vv -> DsAttrModifier.Threshold (Math.Clamp(vv, 0, 100)))) 339 | |> DsAttr.addValue expression 340 | |> DsAttr.create 341 | 342 | /// 343 | /// Actions 344 | /// 345 | static member private backendAction actionOptions action = 346 | match (action, actionOptions) with 347 | | Get url, ValueNone -> $@"@get('{url}')" 348 | | Get url, ValueSome options -> $"@get('{url}',{options |> RequestOptions.Serialize})" 349 | | Post url, ValueNone -> $@"@post('{url}')" 350 | | Post url, ValueSome options -> $"@post('{url}',{options |> RequestOptions.Serialize})" 351 | | Put url, ValueNone -> $@"@put('{url}')" 352 | | Put url, ValueSome options -> $"@put('{url}',{options |> RequestOptions.Serialize})" 353 | | Patch url, ValueNone -> $@"@patch('{url}')" 354 | | Patch url, ValueSome options -> $"@patch('{url}',{options |> RequestOptions.Serialize})" 355 | | Delete url, ValueNone -> $@"@delete('{url}')" 356 | | Delete url, ValueSome options -> $"@delete('{url}',{options |> RequestOptions.Serialize})" 357 | 358 | /// 359 | /// Creates a @get action for an expression with options. The action sends a GET request with the given url. 360 | /// Signals will be sent as a query parameter. 361 | /// https://data-star.dev/reference/actions#get 362 | /// https://data-star.dev/reference/actions#options 363 | /// 364 | /// Expression 365 | static member get (url, ?options) = 366 | Ds.backendAction (options |> Option.toValueOption) (Get url) 367 | 368 | /// 369 | /// Creates a @post action for an expression. The action sends a POST request to the given url. 370 | /// Signals are sent with the body of the request. 371 | /// https://data-star.dev/reference/actions#post 372 | /// https://data-star.dev/reference/actions#options 373 | /// 374 | /// Expression 375 | static member post (url, ?options) = 376 | Ds.backendAction (options |> Option.toValueOption) (Post url) 377 | 378 | /// 379 | /// Creates a @put action for an expression. The action sends a PUT request to the given url. 380 | /// Signals are sent with the body of the request. 381 | /// https://data-star.dev/reference/actions#put 382 | /// https://data-star.dev/reference/actions#options 383 | /// 384 | /// Expression 385 | static member put (url, ?options) = 386 | Ds.backendAction (options |> Option.toValueOption) (Put url) 387 | 388 | /// 389 | /// Creates a @patch action for an expression. The action sends a PATCH request to the given url. 390 | /// Signals are sent with the body of the request. 391 | /// https://data-star.dev/reference/actions#patch 392 | /// https://data-star.dev/reference/actions#options 393 | /// 394 | /// Expression 395 | static member patch (url, ?options) = 396 | Ds.backendAction (options |> Option.toValueOption) (Patch url) 397 | 398 | /// 399 | /// Creates a @delete action for an expression. The action sends a DELETE request to the given url. 400 | /// Signals are sent with the body of the request. 401 | /// https://data-star.dev/reference/actions#delete 402 | /// https://data-star.dev/reference/actions#options 403 | /// 404 | /// Expression 405 | static member delete (url, ?options) = 406 | Ds.backendAction (options |> Option.toValueOption) (Delete url) 407 | 408 | /// 409 | /// @setall(), set all the signals that start with the prefix to the expression provided. 410 | /// https://data-star.dev/reference/actions#setall 411 | /// 412 | /// All signals to set that have this prefix, e.g. 'foo.' 413 | /// Value to set 414 | /// Expression 415 | static member inline setAll<'T> (signalsPathPrefix:string, value:'T) = 416 | match box value with 417 | | :? Boolean as value' -> $"@setAll('{signalsPathPrefix}', {value'.ToString().ToLower()})" 418 | | :? string as value' -> $"@setAll('{signalsPathPrefix}', '{value'}')" 419 | | value' -> $"@setAll('{signalsPathPrefix}', '{value'}')" 420 | 421 | /// 422 | /// @toggleAll(), toggle all the signals that start with the prefix. 423 | /// https://data-star.dev/reference/actions#toggleall 424 | /// 425 | /// All signals to toggle that have this prefix, e.g. 'foo.' 426 | /// Expression 427 | static member toggleAll (signalsPathPrefix:string) = 428 | $"@toggleAll('{signalsPathPrefix}')" 429 | 430 | /// 431 | /// Method for joining strings with " ; " to simplify multi-line expressions 432 | /// 433 | /// 434 | static member expression (expressions:string seq) = 435 | expressions |> String.concat " ; " 436 | 437 | /// 438 | /// An attribute that should be added to the <body> when creating a streaming app to avoid the issue explained here: 439 | /// https://stackoverflow.com/questions/8788802/prevent-safari-loading-from-cache-when-back-button-is-clicked 440 | /// 441 | static member safariStreamingFix = 442 | Attr.create "data-on:pageshow.window" "evt?.persisted && window.location.reload()" 443 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Falco.Datastar 2 | 3 | [![NuGet Version](https://img.shields.io/nuget/v/Falco.Datastar.svg)](https://www.nuget.org/packages/Falco.Datastar) 4 | [![build](https://github.com/falcoframework/Falco.Datastar/actions/workflows/build.yml/badge.svg)](https://github.com/falcoframework/Falco.Datastar/actions/workflows/build.yml) 5 | 6 | ```fsharp 7 | open Falco.Markup 8 | open Falco.Datastar 9 | 10 | let demo = 11 | Elem.button 12 | [ Attr.id "replace_me" 13 | Ds.onClick (Ds.get "/click-me") ] 14 | [ Text.raw "Reset" ] 15 | ``` 16 | 17 | [Falco.Datastar](https://github.com/falcoframework/Falco.Datastar) brings type-safe [Datastar](https://data-star.dev) support to [Falco](https://github.com/falcoframework/Falco). 18 | It provides a complete mapping of all [attribute plugins](https://data-star.dev/reference/attributes) and [action plugins](https://data-star.dev/reference/actions). 19 | As well as helpers for retrieving the signals and responding with Datastar Server Side Events. 20 | 21 | ## Key Features 22 | - Idiomatic mapping of `data-*` attributes (e.g. `data-text`, `data-bind`, `data-signals`, etc.). 23 | - Helper functions for reading signals and responding with Datastar Server Side Events. 24 | 25 | ## Design Goals 26 | - Create a self-documenting way to integrate Datastar into Falco applications. 27 | - Provide type safety without over-abstracting. 28 | 29 | ## Getting Started 30 | 31 | First off, for any questions or criticisms of this library or [Datastar](http://data-star.dev) in general, 32 | please join our [Discord](https://discord.com/channels/1296224603642925098/1334541716497109042), where we are definitely not a cult. 33 | 34 | This guide assumes you have a [Falco](https://github.com/falcoframework/Falco) project setup. If you don't, you can create a new Falco project using the following commands. 35 | The full code for this guide can be found in the [Hello World example](examples/HelloWorld). 36 | 37 | ```shell 38 | > dotnet new web -lang F# -o HelloWorld 39 | > cd HelloWorld 40 | ``` 41 | 42 | Install the nuget package: 43 | ```shell 44 | > dotnet add package Falco 45 | > dotnet add package Falco.Datastar 46 | ``` 47 | 48 | Remove any `*.fs` files created automatically, crate a new file name `Program.fs` and set the contents to the following: 49 | 50 | ```fsharp 51 | open Falco 52 | open Falco.Markup 53 | open Falco.Routing 54 | open Falco.Datastar 55 | open Microsoft.AspNetCore.Builder 56 | 57 | let wapp = WebApplication.Create() 58 | 59 | let endpoints = [ ] 60 | 61 | wapp.UseRouting() 62 | .UseFalco(endpoints) 63 | .Run() 64 | ``` 65 | 66 | Now, let's incorporate Datastar into our Falco application. First, we'll define a simple route that returns a button that, when clicked, will 67 | merge an HTML fragment from a GET request. 68 | 69 | ```fsharp 70 | let handleIndex : HttpHandler = 71 | let html = 72 | Elem.html [] [ 73 | Elem.head [] [ Ds.cdnScript ] 74 | Elem.body [] [ 75 | Text.h1 "Example: Hello World" 76 | Elem.button 77 | [ Attr.id "hello"; Ds.onClick (Ds.get "/click") ] 78 | [ Text.raw "Click Me" ] 79 | ] 80 | ] 81 | Response.ofHtml html 82 | ``` 83 | 84 | Next, we'll define a handler for the click event that will return an HTML element from the server to replace the HTML of the button; note the `#hello`. 85 | 86 | ```fsharp 87 | let handleClick : HttpHandler = 88 | let html = Elem.h2 [ Attr.id "hello" ] [ Text.raw "Hello, World, from the Server!" ] 89 | Response.ofHtmlElements html 90 | ``` 91 | 92 | And lastly, we'll make Falco aware of these routes by adding them to the `endpoints` list. 93 | 94 | ```fsharp 95 | let endpoints = 96 | [ get "/" handleIndex 97 | get "/click" handleClick ] 98 | ``` 99 | 100 | Save the file and run the application: 101 | 102 | ```shell 103 | dotnet run 104 | ``` 105 | 106 | Navigate to `https://localhost:5001` in your browser and click the button. You should see the text "Hello, World, from the Server!" appear in the place of the button. 107 | 108 | Jump to [Signal Reading and Server Side Events](#reading-signals-and-server-side-events). 109 | 110 | ## Signals and Expressions 111 | 112 | Datastar uses signals to manage state. Signals are reactive variables that automatically track and 113 | propagate changes in [Datastar expressions](https://data-star.dev/guide/datastar_expressions). 114 | They can be created and modified using data attributes on the frontend, or events sent from the backend. 115 | 116 | [Datastar expressions](https://data-star.dev/guide/datastar_expressions) are strings that are evaluated by bindings, events, and triggers. 117 | Updating a signal value in an expression will cause other bindings and expressions to update elsewhere. 118 | 119 | Some important notes: Signals defined later in the DOM tree override those defined earlier. 120 | `data-*` attributes are [evaluated in the order they appear in the DOM](https://data-star.dev/examples/plugin_order); meaning that signals need to be specified before they can be used. 121 | 122 | #### Sections: 123 | 124 | - [Index](#attribute-index) 125 | - [Creating Signals](#creating-signals) 126 | - [Binding to Signals](#signal-binding) 127 | - [Events and Triggers](#events-and-triggers) 128 | - [Actions and Functions](#actions-and-functions) 129 | - [When to $](#when-to-) 130 | 131 | ## _Attribute Index_ 132 | 133 | - [data-attr](#dsattr--data-attr) 134 | - [data-bind](#dsbind--data-bind) 135 | - [data-class](#dsclass--data-class) 136 | - [data-computed](#dscomputed--data-computed) 137 | - [data-effect](#dseffect--data-effect) 138 | - [data-ignore](#dsignore--dsignoreself--dsignoremorph--data-star-ignore) 139 | - [data-indicator](#dsindicator--data-indicator) 140 | - [data-json-signals](#dssignals--dssignal--data-signals) 141 | - [data-init](#dsinit--data-init) 142 | - [data-on](#dsonevent--data-on) 143 | - [data-on-intersect](#dsonintersect--data-on-intersect) 144 | - [data-on-interval](#dsoninterval--data-on-interval) 145 | - [data-on-signal-patch](#dsonsignalpatch--dsonsignalpatchfilter--data-on-signal-patch) 146 | - [data-ref](#dsref--data-ref) 147 | - [data-show](#dsshow--data-show) 148 | - [data-signals](#dssignals--dssignal--data-signals) 149 | - [data-style](#dsstyle--data-style) 150 | - [data-text](#dstext--data-text) 151 | 152 | ## _Creating Signals_ 153 | 154 | Create signals, which are reactive variables that automatically propagate their value to all references of the signal. 155 | Important: Never use hyphens when naming signals. 156 | 157 | ### [Ds.signals / Ds.signal : `data-signals`](https://data-star.dev/reference/attributes#data-signals) 158 | 159 | Serializes the passed object with [`System.Text.Json.JsonSerializer`](https://learn.microsoft.com/en-us/dotnet/api/system.text.json.jsonserializer) 160 | and will merge the signals with the existing signals. 161 | 162 | ```fsharp 163 | type MySignals() = 164 | member val firstName = "Don" with get, set 165 | member val lastName = "Syme" with get, set 166 | 167 | let signals = MySignals() 168 | 169 | Elem.div [ Ds.signals signals ] [] 170 | ``` 171 | 172 | As a convenience, you can create a single signal with the option to add it only if it is missing. 173 | 174 | **Important note**: if you use kebab-case, it will be returned in pascal-case. 175 | 176 | ```fsharp 177 | Elem.div [ Ds.signal (sp"signalPath", "signalValue", ifMissing = true) ] [] 178 | ``` 179 | 180 | ### [Ds.computed : `data-computed`](https://data-star.dev/reference/attributes#data-computed) 181 | 182 | Creates a read-only signal that is computed based on a [Datastar expression](https://data-star.dev/guide/datastar_expressions). [`data-text`](#dstext--data-text) is used 183 | here to bind and display the signal value. **Important:** Computed signal expressions must not be used for performing actions. 184 | If you need to perform an action in response to a signal change, use the [`data-effect`](#dseffect--data-effect) attribute. 185 | 186 | ```fsharp 187 | Elem.div [ Ds.computed (sp"foo", "$bar + $baz") ] [] 188 | Elem.div [ Ds.text "$foo" ] [] 189 | ``` 190 | 191 | ### [Ds.ref : `data-ref`](https://data-star.dev/reference/attributes#data-ref) 192 | 193 | Creates a new signal that is a reference to the element on which the data attribute is placed. [`data-text`](#dstext--data-text) 194 | is used here to bind and display the signal value. 195 | 196 | ```fsharp 197 | Elem.div [ Ds.ref "foo" ] [] 198 | Elem.div [ Ds.text "$foo.tagName" ] [] 199 | ``` 200 | 201 | ### [Ds.indicator : `data-indicator`](https://data-star.dev/reference/attributes#data-indicator) 202 | 203 | Creates a signal and sets its value to true while an SSE request is in flight, otherwise false. 204 | As an example, the signal can be used to show a loading indicator. 205 | 206 | ```fsharp 207 | Elem.button [ 208 | Ds.onClick (Ds.get "/fetchBigData") // make a request to the backend, making fetch happen 209 | Ds.indicator "fetching" // the signal we are creating 210 | Ds.attr' ("disabled", "$fetching") // assigns the "disabled" attribute if the `fetching` signal value is true 211 | ] [ Text.raw "Fetch!" ] 212 | 213 | Elem.div 214 | [ Ds.show "$fetching" ] // show or hide this
if the `fetching` signal value is true or false, respectively 215 | [ Text.raw "Fetching" ] 216 | ``` 217 | 218 | The previous example uses a couple functions we haven't covered yet. [`Ds.onClick`](#dsonevent--data-on) firing a [`Ds.get action`](), which sends a GET request to the server. 219 | [`Ds.attr'`](#dsattr--data-attr) and [`Ds.show`](#dsshow--data-show) are evaluating the Datastar expression `$fetching` and are assigning `disabled` attribute and 220 | show/hiding the div, respectively, based on the `fetching` signal value's "true-ness". 221 | 222 | ## _Signal Binding_ 223 | 224 | Binding to a signal means tying an attribute or value of an element to a value that can be modified by another effect. 225 | Example: setting the innerText of a `
` to a value that is updated by a server; or, changing the `class` attribute on an element. 226 | 227 | ### [Ds.bind : `data-bind`](https://data-star.dev/reference/attributes#data-bind) 228 | 229 | Creates a two-way binding from a signal to the "value" of an HTML "input" element. Can be placed on any HTML element on which data can be input or choices 230 | selected (e.g. `input`, `textarea`, `select`, `checkbox` and `radio` elements, as well as web components. Although not necessary, you can find the `switch` statement in the 231 | [source](https://github.com/starfederation/datastar/blob/main/library/src/plugins/attributes/bind.ts) to see how signals are translated). 232 | The signal will be created if it does not already exist. And the type of the signal is preserved during binding; if an element's value changes, 233 | the signal value is automatically converted to match the original (see the [documentation](https://data-star.dev/reference/attributes#data-bind) for an example.) 234 | 235 | ```fsharp 236 | Elem.input [ Attr.type' "text"; Ds.bind "firstName" ] 237 | ``` 238 | 239 | ### [Ds.text : `data-text`](https://data-star.dev/reference/attributes#data-text) 240 | 241 | Binds the `text` value of an element to a [Datastar expression](https://data-star.dev/guide/datastar_expressions). The value in `$foo` will be automatically set to the `divs` innerText. 242 | 243 | ```fsharp 244 | Elem.div [ Ds.text "$foo" ] [] 245 | ``` 246 | 247 | ### [Ds.attr' : `data-attr`](https://data-star.dev/reference/attributes#data-attr) 248 | 249 | Binds the value of an HTML attribute to an expression. 250 | 251 | ```fsharp 252 | Elem.div [ Ds.attr' ("title", "$foo") ] [] 253 | ``` 254 | 255 | ### [Ds.show : `data-show`](https://data-star.dev/reference/attributes#data-show) 256 | 257 | Show or hides an element based on whether a [Datastar expression](https://data-star.dev/guide/datastar_expressions) evaluates to true or false. 258 | For anything with custom requirements, use [`data-class`](#dsclass--data-class) instead. 259 | 260 | ```fsharp 261 | Elem.div [ Ds.show "$foo" ] [] 262 | ``` 263 | 264 | ### [Ds.class' : `data-class`](https://data-star.dev/reference/attributes#data-class) 265 | 266 | Adds or removes a class to or from an element based on the "true-ness" of a [Datastar expression](https://data-star.dev/guide/datastar_expressions). 267 | 268 | ```fsharp 269 | Elem.div [ Ds.class' "hidden" "$foo" ] [] // add the 'hidden' class when $foo evaluates to true 270 | ``` 271 | 272 | ### [Ds.style : `data-style`](https://data-star.dev/reference/attributes#data-style) 273 | 274 | Sets the value of inline CSS styles on an element based on an expression, and keeps them in sync. 275 | 276 | ```fsharp 277 | Elem.div [ Ds.style "backgroundColor" "$usingRed ? 'red' : 'blue'" ] [ Text.raw "Red of Blue" ] 278 | 279 | Elem.div [ Ds.style "display" "$hiding && 'none'" ] [ Text.raw "Might be hiding" ] 280 | ``` 281 | 282 | ## _Events and Triggers_ 283 | 284 | Events and triggers result in [Datastar expressions](https://data-star.dev/guide/datastar_expressions) being executed. This can result in signal changes and other expressions being run. 285 | Example: clicking a button to send a request or an element scrolling into view. 286 | 287 | ### [Ds.init : `data-init`](https://data-star.dev/reference/attributes#data-init) 288 | 289 | Runs an expression when the element is loaded into the DOM. **Important:** when patching elements, 290 | `ElementPatchMode.Replace` the [Datastar expression](https://data-star.dev/guide/datastar_expressions) 291 | will be fired a second time, but will not with `ElementPatchMode.Outer`. 292 | 293 | ```fsharp 294 | Elem.div [ Ds.init (Ds.get "/moreAgents") ] [] 295 | ``` 296 | 297 | ### [Ds.onEvent : `data-on`](https://data-star.dev/reference/attributes#data-on) 298 | 299 | Attaches an event listener to an element, executing a [Datastar expression](https://data-star.dev/guide/datastar_expressions) whenever the event is triggered. 300 | An `evt` variable that represents the event object is available in the expression. 301 | 302 | ```fsharp 303 | Elem.div [ Ds.onEvent("mouseup", "$selection = document.getSelection().toString()") ] [ Text.raw "Highlight some of me!" ] 304 | Elem.div [ Ds.onEvent("mouseenter", "$show = !$show"); Ds.onEvent("mouseexit", "$show = !$show") ] [] 305 | ``` 306 | 307 | ```fsharp 308 | Elem.button [ Ds.onClick "$show = !$show" ] [ Text.raw "Peek-a-boo!" ] 309 | Elem.div [ Ds.init (Ds.get "/edit") ] [] 310 | ``` 311 | 312 | #### `data-on` Modifiers 313 | 314 | Modifiers allow you to alter the behavior when events are triggered. (Modifiers with a '*' can only be used with the [built-in events](https://developer.mozilla.org/en-US/docs/Web/Events)). 315 | 316 | ```fsharp 317 | type OnEventModifier = 318 | | Once // * - can only be used with built-in events 319 | | Passive // * - can only be used with built-in events 320 | | Capture // * - can only be used with built-in events 321 | | Delay of TimeSpan 322 | | DelayMs of int // identical to Delay, but using milliseconds instead 323 | | Debounce of Debounce // timespan, leading, and notrailing 324 | | Throttle of Throttle // timepan, noleading, and trailing 325 | | ViewTransition 326 | | Window 327 | | Outside 328 | | Prevent 329 | | Stop 330 | ``` 331 | 332 | As an example: 333 | ```fsharp 334 | Elem.div [ 335 | Ds.onEvent ("click", "$foo = ''", [ Window; Debounce.With(1000, leading = true) ]) 336 | ] [] 337 | ``` 338 | 339 | Results in: 340 | ```html 341 |
342 | ``` 343 | 344 | ### [Ds.effect : `data-effect`](https://data-star.dev/reference/attributes#data-effect) 345 | 346 | Executes an expression on page load and whenever any signals in the expression change. This is useful for performing 347 | side effects, such as updating other signals, making requests to the backend, or manipulating the DOM. 348 | 349 | ```fsharp 350 | Elem.div [ Ds.effect @"$foo = $bar + $baz" ] [] 351 | Elem.div [ Ds.text "$foo" ] [] 352 | ``` 353 | 354 | ### [Ds.onIntersect : `data-on-intersect`](https://data-star.dev/reference/attributes#data-on-intersect) 355 | 356 | Runs an expression when the element intersects with the viewport. 357 | 358 | ```fsharp 359 | Elem.div [ Ds.onIntersect "$intersected = true" ] [] 360 | 361 | Elem.div [ Ds.onIntersect ("$intersected = true", visibility = Full) ] [] 362 | 363 | Elem.div [ Ds.onIntersect ("$intersected = true", visibility = Half, onlyOnce = true) ] [] 364 | 365 | Elem.div [ Ds.onIntersect ("$intersected = true", visibility = Half, onlyOnce = true, debounce = Debounce.With(TimeSpan.FromSeconds(1.0))) ] [] 366 | 367 | Elem.div [ Ds.onIntersect ("$intersected = true", visibility = Half, onlyOnce = true, throttle = Throttle.With(TimeSpan.FromSeconds(1.0))) ] [] 368 | ``` 369 | 370 | ### [Ds.onSignalPatch | Ds.onSignalPatchFilter : `data-on-signal-patch`](https://data-star.dev/reference/attributes#data-on-signal-patch) 371 | 372 | Runs an expression any signal changes. This should be used sparingly, as it is cost intensive. 373 | 374 | ```fsharp 375 | Elem.div [ Ds.onSignalPatch "$show = !$show" ] [] 376 | 377 | Elem.div [ Ds.onSignalPatchFilter (SignalsFilter.Include "/foo/") ] [] 378 | ``` 379 | 380 | ### [Ds.onInterval : `data-on-interval`](https://data-star.dev/reference/attributes#data-on-interval) 381 | 382 | Runs an expression at a regular interval. The interval duration defaults to 1 second and can be modified by passing a `TimeSpan` 383 | 384 | ```fsharp 385 | Elem.div [ 386 | Ds.signal (sp"intervalSignalOneSecond", false) 387 | Ds.onInterval "$intervalSignalOneSecond = !$intervalSignalOneSecond" 388 | Ds.text "'One Second Interval = ' + $intervalSignalOneSecond" 389 | ] [] 390 | 391 | Elem.div [ 392 | Ds.signal (sp"intervalSignalFiveSecond", false) 393 | Ds.onInterval ("$intervalSignalFiveSecond = !$intervalSignalFiveSecond", TimeSpan.FromSeconds(5.0), leading = true) 394 | Ds.text "'Five Second Interval = ' + $intervalSignalFiveSecond" 395 | ] [] 396 | ``` 397 | 398 | ## _Actions and Functions_ 399 | 400 | Datastar provides a number of actions and functions that can be used in [Datastar expressions](https://data-star.dev/guide/datastar_expressions) 401 | for making server requests and manipulating signals. 402 | 403 | ### [@get | @post | @put | @patch | @delete](https://data-star.dev/reference/actions#backend-plugins) 404 | 405 | These actions make requests to any backend service that supports Server Side Events (SSE). 406 | Luckily an F#-friendly [SDK exists](https://data-star.dev/reference/sdks#dotnet) and `Falco.Datastar` has several [helper methods](#reading-signals-and-server-side-events) 407 | 408 | All signals, that do not have an underscore prefix, are sent in the request. 409 | `@get` will send the signal values as query parameters. All others are sent within a JSON body. 410 | 411 | ```fsharp 412 | Elem.div [ Ds.init (Ds.get "/get") ] [] 413 | 414 | Elem.button [ Ds.onClick (Ds.post "/post") ] [ Text.raw "Post" ] 415 | 416 | Elem.button [ Ds.onClick (Ds.put "/put") ] [ Text.raw "Put" ] 417 | 418 | Elem.button [ Ds.onClick (Ds.patch "/patch") ] [ Text.raw "Patch" ] 419 | 420 | Elem.button [ Ds.onClick (Ds.delete "/delete") ] [ Text.raw "Delete" ] 421 | ``` 422 | 423 | The majority of the above examples are fired from a button click, but remember that these are 424 | [Datastar expressions](https://data-star.dev/guide/datastar_expressions) and any [event or trigger](#_events-and-triggers_) 425 | could activate them. 426 | 427 | Each request action can also be provided a number of options, explained in depth [here](https://data-star.dev/reference/actions#options): 428 | 429 | ```fsharp 430 | Elem.button [ Ds.onClick (Ds.get ("/endpoint", 431 | { RequestOptions.Defaults with 432 | Headers = [ ("X-Csrf-Token", "JImikTbsoCYQ9...") ] 433 | OpenWhenHidden = true } 434 | )) ] [ Text.raw "Push the Button" ] 435 | ``` 436 | 437 | ### [`@setAll`](https://data-star.dev/reference/actions#setall) 438 | 439 | Sets all the signals that start with the prefix to the expression provided in the second argument. 440 | This is useful for setting all the values of a signal namespace at once. 441 | 442 | ```fsharp 443 | Elem.div [ Ds.onEvent (OnEvent.SignalsChanged, (Ds.setAll "foo." true)) ] [] 444 | ``` 445 | 446 | ### [`@toggleAll`](https://data-star.dev/reference/actions#toggleall) 447 | 448 | Toggles all the signals that start with the prefix. This is useful for toggling all the values of a signal namespace at once. 449 | 450 | ```fsharp 451 | Elem.div [ Ds.onEvent (OnEvent.SignalsChanged, (Ds.toggleAll "foo.")) ] [] 452 | ``` 453 | 454 | ### [Ds.ignore | Ds.ignoreSelf | Ds.ignoreMorph : `data-star-ignore`](https://data-star.dev/reference/attributes#data-ignore) 455 | 456 | Datastar walks the entire DOM and applies plugins to each element it encounters. 457 | It’s possible to tell Datastar to ignore an element and its descendants by placing a data-star-ignore attribute on it. 458 | This can be useful for preventing naming conflicts with third-party libraries. 459 | 460 | `Ds.ignore` will force Datastar to ignore the element and all child elements. 461 | `Ds.ignoreSelf` only affects the attribute it is attached to. 462 | 463 | ```fsharp 464 | Elem.div [ Ds.ignore ] [ 465 | Elem.div [ Ds.text "ignoredAsWell" ] [] 466 | ] 467 | 468 | Elem.div [ Ds.ignoreSelf ] [ 469 | Elem.div [ Ds.text "thisIsNotIgnored" ] [] 470 | ] 471 | 472 | Elem.div [ Ds.ignoreMorph ] [ 473 | Elem.div [ Ds.text "thisWillNotBeMorphed" ] [] 474 | ] 475 | ``` 476 | 477 | ### [Ds.jsonSignals | Ds.jsonSignalsOptions : `data-json-signals`](https://data-star.dev/reference/attributes#data-json-signals) 478 | 479 | Sets the text content of an element to a reactive JSON stringified version of signals. Useful when troubleshooting an 480 | issue. Has options for restricting the signals displayed. 481 | 482 | ```fsharp 483 | Elem.pre [ Ds.jsonSignals ] [] 484 | 485 | Elem.pre [ Ds.jsonSignalsOptions (SignalsFilter.Include "/foo/") ] [] 486 | ``` 487 | 488 | ## _When to `$`_ 489 | 490 | You may have noticed in the sample code that the `$` is used in some places, but not others. At first, it might be 491 | confusing when a `$` is required, but it really isn't all that complicated when you think of it as either being a signal path or not. 492 | 493 | The `$` symbol is a shorthand to get the value of the signal (e.g. `$count` -> `count.value`), so when the `$` is elided, you are referring to the signal directly. 494 | [`Ds.bind signalPath`](#dsbind--data-bind) is two-way binding to the signal, so it requires the signal path, no `$`. 495 | [`Ds.text`](#dstext--data-text) is replacing the element's innerText, so it needs the value, via `$`. 496 | [`Ds.computed (signalPath, expression)`](#dscomputed--data-computed) needs both a signal path AND an expression, e.g. `Ds.computed ("countPlusTen", "$count + 10")`. 497 | 498 | If you want to be certain you are doing it correctly, then there is a helper method `SignalPath.sp` 499 | that will throw an exception at startup, if a signal path contains any invalid symbols, such as `$`. 500 | 501 | ```fsharp 502 | open StarFederation.Datastar.SignalPath 503 | ... 504 | Elem.input [ Attr.typeCheckbox; Ds.bind (sp"checkBoxSignal") ] 505 | ``` 506 | 507 | ## Reading Signals and Server Side Events 508 | 509 | [Falco.Datastar](https://github.com/falcoframework/Falco.Datastar) has a number of Request and Response functions for reading the [Datastar signal](https://data-star.dev/guide/going_deeper#2-signals) values and responding 510 | with [Datastar Server Side Events (SSEs)](https://data-star.dev/reference/sse_events). 511 | 512 | Sections: 513 | - [Reading Signal Values](#reading-signal-values) 514 | - [Responding with Signals](#responding-with-signals) 515 | - [Responding with HTML Elements](#responding-with-html-fragments) 516 | - [Streaming Server Side Events](#streaming-server-side-events) 517 | 518 | ## _Reading Signal Values_ 519 | 520 | All requests are sent with a JSON `{datastar: *}` block containing the current signals (you can keep signals local to the client 521 | by prefixing the name with an underscore). When using a `GET` request, the signals are sent as a query parameter; otherwise, 522 | they are sent as a JSON body. Luckily, with [Falco.Datastar](https://github.com/falcoframework/Falco.Datastar), you don't have to worry about any of that. 523 | Signals are streamed, so you do have to worry about not calling the getSignals methods a second time. 524 | 525 | ### `Request.getSignals<'T>` 526 | 527 | Will use [`System.Text.Json.JsonSerializer`](https://learn.microsoft.com/en-us/dotnet/api/system.text.json.jsonserializer.deserialize) to deserialize the signals into a `'T`. 528 | If there are no signals, then the default values will be returned. 529 | 530 | ```fsharp 531 | [] 532 | type MySignals = 533 | { firstName : string 534 | lastName : string 535 | email : string } 536 | ... 537 | let httpHandler : HttpHandler = (fun ctx -> task { 538 | let! signals : MySignals voption = Request.getSignals (ctx) 539 | ... 540 | }) 541 | ``` 542 | 543 | ### `Request.getSignalsJson` 544 | 545 | Will return a `System.Text.Json.JsonDocument` of the signals. 546 | 547 | ```fsharp 548 | let httpHandler : HttpHandler = (fun ctx -> task { 549 | let! jsonDocument = Request.getSignals (ctx) 550 | ... 551 | }) 552 | ``` 553 | 554 | ## _Responding with Signals_ 555 | 556 | ### `Response.ofPatchSignals<'T>` 557 | 558 | Serializes signals with [`System.Text.Json.JsonSerializer`](https://learn.microsoft.com/en-us/dotnet/api/system.text.json.jsonserializer) and sends to client where Datastar will merge them. 559 | 560 | ```fsharp 561 | Response.ofPatchSignals (MySignals()) 562 | ``` 563 | 564 | ### `Response.ofPatchSignal<'T>` 565 | 566 | Updates a single signal on the client. 567 | 568 | ```fsharp 569 | Response.ofPatchSignal (sp"user.firstName", "Don") 570 | ``` 571 | 572 | ### `Response.ofPatchSignals` 573 | 574 | Takes the signals as a JSON string and sends them to the client where Datastar will merge them. 575 | 576 | ```fsharp 577 | Response.ofPatchSignals @" { ""firstName"": ""Don"", ""lastName"": ""Syme"" } " 578 | ``` 579 | 580 | ### `Response.ofRemoveSignals` 581 | 582 | Given a `seq` of signal paths, will remove that signals from the client. 583 | 584 | ```fsharp 585 | Response.ofRemoveSignals [ sp"user.firstName"; sp"user.lastName" ] 586 | ``` 587 | 588 | ## _Responding with HTML Elements_ 589 | 590 | HTML elements are sent to client and replace the current element (matching on the `id` attribute) with the one that is sent. 591 | The following functions are `HttpHandler`s that will send down a single Server Sent Event. 592 | 593 | ### `Response.ofHtmlElements` 594 | 595 | Will render an XMLNode and send it to the client. Client Datastar will replace the element with the matching `id` attribute (or optionally provided selector) 596 | 597 | ```fsharp 598 | Response.ofHtml ( Elem.h2 [ Attr.id "hello" ] [ Text.raw "Hello, World from the Server!" ] ) 599 | ``` 600 | 601 | ### `Response.ofHtmlStringElements` 602 | 603 | Will send HTML fragments to the client. Client Datastar will replace the element with the matching `id` attribute (or optionally provided selector) 604 | 605 | ```fsharp 606 | Response.ofHtmlStringElements @"

Hello, World from the Server!" 607 | ``` 608 | 609 | ### `Response.ofRemoveElement` 610 | 611 | Will send a command to client Datastar to remove fragments with the matching selector. 612 | 613 | ```fsharp 614 | Response.ofRemoveElements [ sel"hello" ] 615 | ``` 616 | 617 | ## _Streaming Server Side Events_ 618 | 619 | Within the `Response` module there are the `of` methods that are for sending single server side events and then closing the connection. 620 | But, [Datastar's](https://data-star.dev) true power is unlocked when the client keeps a connection open to the server 621 | and updates are streamed to all clients as they are received by the server. This is a much more efficient alternative 622 | to having all the clients poll the server every few moments, and provides much greater control over back-pressure. 623 | 624 | The [progress bar example](https://data-star.dev/examples/progress_bar) is a great and simple demonstration of what can be achieved with [Datastar](https://data-star.dev); no polling necessary. 625 | 626 | All the functions in [Responding with Signals](#responding-with-signals) and [Responding with HTML Elements](#responding-with-html-fragments) 627 | are mirrored with a function with `sse` as their prefix instead of `of`. 628 | 629 | ```fsharp 630 | let handleStream = (fun ctx -> task { 631 | do! Response.sseStartResponse ctx // make sure this is called first; sends the appropriate headers 632 | 633 | let mutable counter = 0 634 | 635 | while true do // all Datastar methods will throw on ctx.RequestAborted 636 | do! Response.ssePatchSignal ctx (sp"counter") counter 637 | do! Response.sseHtmlElements ctx ( Elem.pre [ Attr.id "counterId" ] [ Text.raw counter.ToString() ] ) 638 | do! Task.Delay(TimeSpan.FromSeconds 1L, ctx.RequestAborted) 639 | counter <- counter + 1 640 | }) 641 | ``` 642 | 643 | See the [Streaming example](examples/Streaming) for more. 644 | --------------------------------------------------------------------------------