diff --git a/src/Common/Common.fs b/src/Common/Common.fs
index 1f08f4f..79500cc 100644
--- a/src/Common/Common.fs
+++ b/src/Common/Common.fs
@@ -103,3 +103,9 @@ module HxSwap =
/// Extension
[]
let Upsert = "upsert"
+
+ /// Specify that the target of the htmx request should be downloaded
+ /// This requires the hx-download extension (included in the htmax bundle)
+ /// Documentation
+ []
+ let Download = "download"
diff --git a/src/Htmx/Htmx.fs b/src/Htmx/Htmx.fs
index 2b3d312..e4bbab6 100644
--- a/src/Htmx/Htmx.fs
+++ b/src/Htmx/Htmx.fs
@@ -30,6 +30,11 @@ type IHeaderDictionary with
/// true if the request is for history restoration after a miss in the local history cache
member this.HxHistoryRestoreRequest
with get () = hdr this "HX-History-Restore-Request" |> Option.map bool.Parse
+
+ /// true if the request has been fired by the preload extension
+ /// preload is part of the htmax htmx-plus-extensions bundle
+ member this.HxPreloaded
+ with get () = hdr this "HX-Preloaded" |> Option.map bool.Parse
/// The user response to an hx-prompt
[]
@@ -40,6 +45,11 @@ type IHeaderDictionary with
member this.HxRequest
with get () = hdr this "HX-Request" |> Option.map bool.Parse
+ /// The ID of the request (WebSocket extension requests only)
+ /// hx-ws is part of the htmax htmx-plus-extensions bundle
+ member this.HxRequestId
+ with get () = hdr this "HX-Request-ID"
+
/// The request type sent by htmx
///
member this.HxRequestType
@@ -110,6 +120,14 @@ module Handlers =
open Giraffe.Htmx.Common
+ /// Instruct htmx to download a response from another path / URL
+ /// The path or URL where the downloadable content is found
+ /// An HTTP handler with the HX-Download header set
+ /// This requires the client-side hx-download extension (included in the htmax bundle)
+ /// Documentation
+ let withHxDownload (path: string) : HttpHandler =
+ setHttpHeader "HX-Download" path
+
/// Instruct htmx to perform a client-side redirect for content
/// The path where the content should be found
/// An HTTP handler with the HX-Location header set
diff --git a/src/Tests/Common.fs b/src/Tests/Common.fs
index 3c318a6..a760e01 100644
--- a/src/Tests/Common.fs
+++ b/src/Tests/Common.fs
@@ -63,6 +63,9 @@ let swap =
test "Upsert is correct" {
Expect.equal HxSwap.Upsert "upsert" "Upsert swap value incorrect"
}
+ test "Download is correct" {
+ Expect.equal HxSwap.Download "download" "Download swap value incorrect"
+ }
]
/// All tests for this module
diff --git a/src/Tests/Htmx.fs b/src/Tests/Htmx.fs
index 5eeb409..abbd0ab 100644
--- a/src/Tests/Htmx.fs
+++ b/src/Tests/Htmx.fs
@@ -73,6 +73,30 @@ let dictExtensions =
ctx.Request.Headers.HxHistoryRestoreRequest.Value "The header should have been false"
}
]
+ testList "HxPreloaded" [
+ test "succeeds when the header is not present" {
+ let ctx = Substitute.For()
+ ctx.Request.Headers.ReturnsForAnyArgs(HeaderDictionary()) |> ignore
+ Expect.isNone ctx.Request.Headers.HxPreloaded "There should not have been a header returned"
+ }
+ test "succeeds when the header is present and true" {
+ let ctx = Substitute.For()
+ let dic = HeaderDictionary()
+ dic.Add("HX-Preloaded", "true")
+ ctx.Request.Headers.ReturnsForAnyArgs dic |> ignore
+ Expect.isSome ctx.Request.Headers.HxPreloaded "There should be a header present"
+ Expect.isTrue ctx.Request.Headers.HxPreloaded.Value "The header should have been true"
+ }
+ // This is not a condition fired by the extension; the header will be either absent or true
+ test "succeeds when the header is present and false" {
+ let ctx = Substitute.For()
+ let dic = HeaderDictionary()
+ dic.Add("HX-Preloaded", "false")
+ ctx.Request.Headers.ReturnsForAnyArgs dic |> ignore
+ Expect.isSome ctx.Request.Headers.HxPreloaded "There should be a header present"
+ Expect.isFalse ctx.Request.Headers.HxPreloaded.Value "The header should have been false"
+ }
+ ]
testList "HxRequest" [
test "succeeds when the header is not present" {
let ctx = Substitute.For()
@@ -96,6 +120,21 @@ let dictExtensions =
Expect.isFalse ctx.Request.Headers.HxRequest.Value "The header should have been false"
}
]
+ testList "HxRequestId" [
+ test "succeeds when the header is not present" {
+ let ctx = Substitute.For()
+ ctx.Request.Headers.ReturnsForAnyArgs(HeaderDictionary()) |> ignore
+ Expect.isNone ctx.Request.Headers.HxRequestId "There should not have been a header returned"
+ }
+ test "succeeds when the header is present" {
+ let ctx = Substitute.For()
+ let dic = HeaderDictionary()
+ dic.Add("HX-Request-ID", "abcd1234")
+ ctx.Request.Headers.ReturnsForAnyArgs dic |> ignore
+ Expect.isSome ctx.Request.Headers.HxRequestId "There should be a header present"
+ Expect.equal ctx.Request.Headers.HxRequestId.Value "abcd1234" "The header value was incorrect"
+ }
+ ]
testList "HxRequestType" [
test "succeeds when the header is not present" {
let ctx = Substitute.For()
@@ -253,6 +292,14 @@ let next (ctx: HttpContext) = Task.FromResult(Some ctx)
/// Tests for the HttpHandler functions provided in the Handlers module
let handlers =
testList "HandlerTests" [
+ testTask "withHxDownload succeeds" {
+ let ctx = Substitute.For()
+ let dic = HeaderDictionary()
+ ctx.Response.Headers.ReturnsForAnyArgs dic |> ignore
+ let! _ = withHxDownload "/files/stuff.pdf" next ctx
+ Expect.isTrue (dic.ContainsKey "HX-Download") "The HX-Download header should be present"
+ Expect.equal dic["HX-Download"].[0] "/files/stuff.pdf" "The HX-Download value was incorrect"
+ }
testTask "withHxLocation succeeds" {
let ctx = Substitute.For()
let dic = HeaderDictionary()
diff --git a/src/Tests/ViewEngineMax.fs b/src/Tests/ViewEngineMax.fs
index 17fa3d7..35ce814 100644
--- a/src/Tests/ViewEngineMax.fs
+++ b/src/Tests/ViewEngineMax.fs
@@ -3,6 +3,118 @@ module ViewEngineMax
open Expecto
open Giraffe.ViewEngine.Htmax
+/// Tests for the HxBrowserIndicatorConfigItem type
+let hxBrowserIndicatorConfigItem =
+ testList "HxBrowserIndicatorConfigItem" [
+ testList "ToHcon" [
+ test "succeeds for BoostBrowserIndicator" {
+ Expect.equal
+ ((BoostBrowserIndicator false).ToHcon())
+ "boostBrowserIndicator:false"
+ "The HCON value was incorrect"
+ }
+ ]
+ ]
+
+/// Tests for the HxPreloadConfigItem type
+let hxPreloadConfigItem =
+ testList "HxPreloadConfigItem" [
+ testList "ToHcon" [
+ test "succeeds for AutoBoost" {
+ Expect.equal ((AutoBoost false).ToHcon()) "autoBoost:false" "The HCON value was incorrect"
+ }
+ test "succeeds for BoostEvent" {
+ Expect.equal ((BoostEvent "mouseover").ToHcon()) "boostEvent:mouseover" "The HCON value was incorrect"
+ }
+ test "succeeds for BoostTimeout" {
+ Expect.equal ((BoostTimeout 30_000).ToHcon()) "boostTimeout:30000" "The HCON value was incorrect"
+ }
+ ]
+ ]
+
+/// Tests for the HxSseWsConfigItem type
+let hxSseWsConfigItem =
+ testList "HxSseWsConfigItem" [
+ testList "ToHcon" [
+ test "succeeds for Reconnect" {
+ Expect.equal ((Reconnect true).ToHcon()) "reconnect:true" "The HCON value was incorrect"
+ }
+ testList "ReconnectDelay" [
+ test "succeeds for numeric value" {
+ Expect.equal ((ReconnectDelay "3000").ToHcon()) "reconnectDelay:3000" "The HCON value was incorrect"
+ }
+ test "succeeds for value with units" {
+ Expect.equal ((ReconnectDelay "3s").ToHcon()) "reconnectDelay:3s" "The HCON value was incorrect"
+ }
+ ]
+ testList "ReconnectMaxDelay" [
+ test "succeeds for numeric value" {
+ Expect.equal
+ ((ReconnectMaxDelay "30000").ToHcon()) "reconnectMaxDelay:30000" "The HCON value was incorrect"
+ }
+ test "succeeds for value with units" {
+ Expect.equal
+ ((ReconnectMaxDelay "50s").ToHcon()) "reconnectMaxDelay:50s" "The HCON value was incorrect"
+ }
+ ]
+ testList "ReconnectMaxAttempts" [
+ test "succeeds for negative 1" {
+ Expect.equal
+ ((ReconnectMaxAttempts -1).ToHcon())
+ "reconnectMaxAttempts:Infinity"
+ "The HCON value was incorrect"
+ }
+ test "succeeds for zero" {
+ Expect.equal
+ ((ReconnectMaxAttempts 0).ToHcon()) "reconnectMaxAttempts:0" "The HCON value was incorrect"
+ }
+ test "succeeds for positive number" {
+ Expect.equal
+ ((ReconnectMaxAttempts 7).ToHcon()) "reconnectMaxAttempts:7" "The HCON value was incorrect"
+ }
+ ]
+ test "succeeds for ReconnectJitter" {
+ Expect.equal ((ReconnectJitter 0.7).ToHcon()) "reconnectJitter:0.7" "The HCON value was incorrect"
+ }
+ test "succeeds for PauseOnBackground" {
+ Expect.equal
+ ((PauseOnBackground false).ToHcon()) "pauseOnBackground:false" "The HCON value was incorrect"
+ }
+ test "succeeds for PendingRequestTtl" {
+ Expect.equal ((PendingRequestTtl 5000).ToHcon()) "pendingRequestTTL:5000" "The HCON value was incorrect"
+ }
+ ]
+ ]
+
+/// Tests for the HxConfig module
+let hxConfig =
+ testList "HxConfig" [
+ test "hxBrowserIndicatorConfig succeeds" {
+ Expect.equal
+ (hxBrowserIndicatorConfig [ BoostBrowserIndicator true ])
+ "browser-indicator.boostBrowserIndicator:true"
+ "Config settings not correct"
+ }
+ test "hxPreloadConfig succeeds" {
+ Expect.equal
+ (hxPreloadConfig [ AutoBoost true; BoostTimeout 3_000 ])
+ "preload.autoBoost:true preload.boostTimeout:3000"
+ "Config settings not correct"
+ }
+ test "hxSseConfig succeeds" {
+ Expect.equal
+ (hxSseConfig [ ReconnectDelay "5s"; PauseOnBackground false ])
+ "sse.reconnectDelay:5s sse.pauseOnBackground:false"
+ "Config settings not correct"
+ }
+ test "hxWsConfig succeeds" {
+ Expect.equal
+ (hxWsConfig [ Reconnect false; PendingRequestTtl 40_000 ])
+ "ws.reconnect:false ws.pendingRequestTTL:40000"
+ "Config settings not correct"
+ }
+ ]
+
/// Tests for the HxEvent module
let hxEvent =
testList "HxEvent" [
@@ -110,6 +222,37 @@ let hxEvent =
(BeforeWsRequest.ToHxOnString()) "before:ws:request" "BeforeWsRequest hx-on event name not correct"
}
]
+ testList "DownloadComplete" [
+ test "ToString succeeds" {
+ Expect.equal (string DownloadComplete) "downloadComplete" "DownloadComplete event name not correct"
+ }
+ test "ToHxOnString succeeds" {
+ Expect.equal
+ (DownloadComplete.ToHxOnString())
+ "download:complete"
+ "DownloadComplete hx-on event name not correct"
+ }
+ ]
+ testList "DownloadProgress" [
+ test "ToString succeeds" {
+ Expect.equal (string DownloadProgress) "downloadProgress" "DownloadProgress event name not correct"
+ }
+ test "ToHxOnString succeeds" {
+ Expect.equal
+ (DownloadProgress.ToHxOnString())
+ "download:progress"
+ "DownloadProgress hx-on event name not correct"
+ }
+ ]
+ testList "DownloadStart" [
+ test "ToString succeeds" {
+ Expect.equal (string DownloadStart) "downloadStart" "DownloadStart event name not correct"
+ }
+ test "ToHxOnString succeeds" {
+ Expect.equal
+ (DownloadStart.ToHxOnString()) "download:start" "DownloadStart hx-on event name not correct"
+ }
+ ]
testList "SseClose" [
test "ToString succeeds" {
Expect.equal (string SseClose) "sseClose" "SseClose event name not correct"
@@ -138,10 +281,25 @@ let shouldRender expected node =
let attributes =
testList "Attributes" [
+ test "_hxBrowserIndicator succeeds" {
+ a [ _hxBrowserIndicator ] [] |> shouldRender """"""
+ }
+ test "_hxLive succeeds" {
+ main [ _hxLive "q().doStuff()" ] [] |> shouldRender """"""
+ }
test "_hxOnMax succeeds" {
body [ _hxOnMax SseClose "alert(done)" ] []
|> shouldRender """"""
}
+ testList "_hxPreload" [
+ test "succeeds when given an event" {
+ blockquote [ _hxPreload (Some "focus") ] []
+ |> shouldRender """"""
+ }
+ test "succeeds when not given an event" {
+ em [ _hxPreload None ] [] |> shouldRender """"""
+ }
+ ]
test "_hxSseClose succeeds" {
form [ _hxSseClose "fin" ] [] |> shouldRender """"""
}
@@ -149,6 +307,9 @@ let attributes =
div [ _hxSseConnect "/path/to/resource" ] []
|> shouldRender """"""
}
+ test "_hxTargets succeeds" {
+ dl [ _hxTargets ".ephemeral" ] [] |> shouldRender """
"""
+ }
test "_hxWsConnect succeeds" {
p [ _hxWsConnect "/over/here" ] [] |> shouldRender """"""
}
@@ -165,6 +326,10 @@ let attributes =
let allTests =
testList "ViewEngine.Htmax" [
+ hxBrowserIndicatorConfigItem
+ hxPreloadConfigItem
+ hxSseWsConfigItem
+ hxConfig
hxEvent
attributes
]
diff --git a/src/ViewEngine.Htmx/Htmax.fs b/src/ViewEngine.Htmx/Htmax.fs
index 6df6d75..3453652 100644
--- a/src/ViewEngine.Htmx/Htmax.fs
+++ b/src/ViewEngine.Htmx/Htmax.fs
@@ -1,6 +1,104 @@
/// Types and functions supporting htmax-bundled extension attributes in Giraffe View Engine
module Giraffe.ViewEngine.Htmax
+/// Items which can be configured for the hx-browser-indicator extension
+/// Documentation
+type HxBrowserIndicatorConfigItem =
+ /// Whether to show the browser's native loading indicator for hx-boosted links
+ | BoostBrowserIndicator of bool
+
+ /// Get the HCON representation of this configuration value
+ member this.ToHcon() =
+ match this with
+ | BoostBrowserIndicator it -> "boostBrowserIndicator:" + (string it).ToLowerInvariant()
+
+
+/// Items which can be configured for the hx-preload extension
+/// Documentation
+type HxPreloadConfigItem =
+ /// Whether items subject to hx-boost are automatically preloaded (default true)
+ | AutoBoost of bool
+
+ /// The event to use for hx-boost auto-preloaded content (default "mousedown")
+ | BoostEvent of string
+
+ /// The timeout (in ms) for hx-boost auto-preloaded requests (default 50_000)
+ | BoostTimeout of int
+
+ /// Get the HCON representation of this configuration value
+ member this.ToHcon() =
+ match this with
+ | AutoBoost it -> "autoBoost:" + (string it).ToLowerInvariant()
+ | BoostEvent it -> $"boostEvent:{it}"
+ | BoostTimeout it -> $"boostTimeout:{it}"
+
+
+/// Items which can be configured for the hx-sse or hx-ws extensions
+/// SSE Documentation
+/// WebSockets Documentation
+type HxSseWsConfigItem =
+ /// Whether to automatically reconnect on stream end (default true)
+ | Reconnect of bool
+
+ /// Delay for reconnect attempts (ms, or numbers with units "1s", "2m"; default "500")
+ | ReconnectDelay of string
+
+ /// Maximum delay for reconnect attempts (ms, or numbers with units "1s, "2m"; default "60000")
+ | ReconnectMaxDelay of string
+
+ /// Reconnect maximum attempts (use -1 for Infinity; default Infinity)
+ | ReconnectMaxAttempts of int
+
+ /// Jitter to use when reconnecting (decimal between 0.0 and 1.0; default 0.3)
+ | ReconnectJitter of double
+
+ /// Whether to pause when the current tab is in the background (default true)
+ | PauseOnBackground of bool
+
+ /// Time-to-Live (TTL) for pending requests (hx-ws only)
+ | PendingRequestTtl of int
+
+ /// Get the HCON representation of this configuration value
+ member this.ToHcon() =
+ match this with
+ | Reconnect it -> "reconnect:" + (string it).ToLowerInvariant()
+ | ReconnectDelay it -> $"reconnectDelay:{it}"
+ | ReconnectMaxDelay it -> $"reconnectMaxDelay:{it}"
+ | ReconnectMaxAttempts it -> "reconnectMaxAttempts:" + if it = -1 then "Infinity" else string it
+ | ReconnectJitter it -> $"reconnectJitter:{it}"
+ | PauseOnBackground it -> "pauseOnBackground:" + (string it).ToLowerInvariant()
+ | PendingRequestTtl it -> $"pendingRequestTTL:{it}"
+
+
+/// Helpers for generating extension configuration parameters
+[]
+module HxConfig =
+
+ /// Create an HCON section suitable for hx-config
+ let private hconSettings title values =
+ values |> Seq.map (sprintf "%s.%s" title) |> String.concat " "
+
+ /// Generate configuration items for preload suitable for hx-config
+ /// The configuration items
+ let hxBrowserIndicatorConfig (items: HxBrowserIndicatorConfigItem seq) =
+ items |> Seq.map _.ToHcon() |> hconSettings "browser-indicator"
+
+ /// Generate configuration items for preload suitable for hx-config
+ /// The configuration items
+ let hxPreloadConfig (items: HxPreloadConfigItem seq) =
+ items |> Seq.map _.ToHcon() |> hconSettings "preload"
+
+ /// Generate configuration items for SSE suitable for hx-config
+ /// The configuration items
+ let hxSseConfig (items: HxSseWsConfigItem seq) =
+ items |> Seq.map _.ToHcon() |> hconSettings "sse"
+
+ /// Generate configuration items for WebSockets suitable for hx-config
+ /// The configuration items
+ let hxWsConfig (items: HxSseWsConfigItem seq) =
+ items |> Seq.map _.ToHcon() |> hconSettings "ws"
+
+
/// The events recognized by htmax-bundled extensions
[]
type HxEvent =
@@ -29,6 +127,15 @@ type HxEvent =
/// Fired before a WebSocket connection is established (cancelable)
| BeforeWsConnection
+ /// Fired when a download handled by hx-download is complete
+ | DownloadComplete
+
+ /// Fired when a download handled by hx-download is complete
+ | DownloadProgress
+
+ /// Fired when a chunk is received for a download handled by hx-download
+ | DownloadStart
+
/// Fired before a received WebSocket message is processed (cancelable)
| BeforeWsMessage
@@ -53,6 +160,9 @@ type HxEvent =
BeforeWsConnection, ("beforeWsConnection", "before:ws:connection")
BeforeWsMessage, ("beforeWsMessage", "before:ws:message")
BeforeWsRequest, ("beforeWsRequest", "before:ws:request")
+ DownloadComplete, ("downloadComplete", "download:complete")
+ DownloadProgress, ("downloadProgress", "download:progress")
+ DownloadStart, ("downloadStart", "download:start")
SseClose, ("sseClose", "sse:close")
SseError, ("sseError", "sse:error")
]
@@ -68,6 +178,19 @@ type HxEvent =
[]
module HtmaxAttrs =
+ /// Display the browser's native loading indicator when an htmx request is in flight
+ /// A configured hx-browser-indicator attribute
+ /// Documentation
+ let _hxBrowserIndicator =
+ attr "hx-browser-indicator" "true"
+
+ /// Run script each time the DOM changes
+ /// The script to be run each time the DOM changes (may use q helper)
+ /// A configured hx-live attribute
+ /// Documentation
+ let _hxLive script =
+ attr "hx-live" script
+
/// Generate an hx-on:htmx event for an htmax-bundled extension event
/// The HxEvent to be handled
/// The script to be executed when the event occurs
@@ -76,6 +199,14 @@ module HtmaxAttrs =
let _hxOnMax (event: HxEvent) handler =
Htmx.HtmxAttrs._hxOnEvent $"htmx:{event.ToHxOnString()}" handler
+ /// Identify a resource as one that should be preloaded
+ /// The DOM event or htmx trigger which should cause the preload (optional; default behavior is
+ /// preloading on mousedown)
+ /// A configured hx-preload attribute
+ /// Documentation
+ let _hxPreload evt =
+ match evt with Some it -> attr "hx-preload" it | None -> flag "hx-preload"
+
/// Define an SSE message which indicates the connection should be closed
/// The text of the message which indicates the SSE connection should be closed
/// A configured hx-sse:close attribute
@@ -90,6 +221,13 @@ module HtmaxAttrs =
let _hxSseConnect url =
attr "hx-sse:connect" url
+ /// Replace multiple targets with the same content
+ /// The CSS selector to identify targets for the replacement
+ /// A configured hx-targets attribute
+ /// Documentation
+ let _hxTargets selector =
+ attr "hx-targets" selector
+
/// Connect to a WebSocket URL
/// The URL to which a WebSocket connection should be established
/// A configured hx-ws:connect attribute
diff --git a/src/ViewEngine.Htmx/Htmx.fs b/src/ViewEngine.Htmx/Htmx.fs
index e3d1fae..0d8e03c 100644
--- a/src/ViewEngine.Htmx/Htmx.fs
+++ b/src/ViewEngine.Htmx/Htmx.fs
@@ -93,7 +93,7 @@ type HxEvent =
| [] AfterSseMessage
/// Triggered after a Server Sent Events (SSE) stream is closed
- | [] AfterSseStream
+ | [] AfterSseStream
/// Triggered after new content has been swapped in
| AfterSwap
@@ -144,7 +144,7 @@ type HxEvent =
| [] BeforeSseReconnect
/// Triggered before a Server Sent Events (SSE) stream is opened
- | [] BeforeSseStream
+ | [] BeforeSseStream
/// Triggered before a swap is done, allows you to configure the swap
| BeforeSwap