packages feed

datastar-hs-0.1.0.1: src/Hypermedia/Datastar/ExecuteScript.hs

{- |
Module      : Hypermedia.Datastar.ExecuteScript
Description : Execute JavaScript in the browser via SSE

Sometimes you need a one-shot browser-side effect that doesn't fit neatly
into DOM patching or signal updates — redirecting to another page, focusing
an input, triggering a download, or calling a browser API.
'executeScript' lets the server push arbitrary JavaScript to the browser.

Under the hood, Datastar appends a @\<script\>@ tag to @\<body\>@. By default
the script tag removes itself from the DOM after executing (see 'esAutoRemove').

Note: per the Datastar protocol, script execution uses the
@datastar-patch-elements@ event type — there is no separate event type for
scripts.

@
sendExecuteScript gen (executeScript \"window.location = \\\"/dashboard\\\"\")
@

To add attributes to the generated @\<script\>@ tag:

@
sendExecuteScript gen
  (executeScript \"import(\\\"/modules\/chart.js\\\").then(m => m.render())\")
    { esAttributes = [\"type=\\\"module\\\"\"] }
@
-}
module Hypermedia.Datastar.ExecuteScript where

import Data.Text (Text)
import Data.Text qualified as T
import Hypermedia.Datastar.Types

{- | Configuration for executing a script in the browser.

Construct values with 'executeScript', then customise with record updates.
-}
data ExecuteScript = ExecuteScript
  { esScript :: Text
  -- ^ The JavaScript code to execute.
  , esAutoRemove :: Bool
  {- ^ Whether the @\<script\>@ tag should remove itself from the DOM after
  executing. Default: 'True'. Set to 'False' if the script defines
  functions or variables that need to persist in the page.
  -}
  , esAttributes :: [Text]
  {- ^ Extra attributes to add to the @\<script\>@ tag. For example,
  @[\"type=\\\"module\\\"\"]@ to use ES module imports, or
  @[\"nonce=\\\"abc123\\\"\"]@ for CSP compliance.
  -}
  , esEventId :: Maybe Text
  -- ^ Optional SSE event ID for reconnection.
  , esRetryDuration :: Int
  -- ^ SSE retry interval in milliseconds. Default: @1000@.
  }
  deriving (Eq, Show)

{- | Build an 'ExecuteScript' event with sensible defaults.

The argument is the JavaScript source code to run in the browser.

@
executeScript \"document.getElementById(\\\"name\\\").focus()\"
@
-}
executeScript :: Text -> ExecuteScript
executeScript js =
  ExecuteScript
    { esScript = js
    , esAutoRemove = defaultAutoRemove
    , esAttributes = []
    , esEventId = Nothing
    , esRetryDuration = defaultRetryDuration
    }

toDatastarEvent :: ExecuteScript -> DatastarEvent
toDatastarEvent es =
  DatastarEvent
    { eventType = EventPatchElements -- Correct, there is no EventExecuteScript, see the ADR
    , eventId = esEventId es
    , retry = esRetryDuration es
    , dataLines =
        [ "selector body"
        , "mode append"
        ]
          <> buildScriptLines es
    }

buildScriptLines :: ExecuteScript -> [Text]
buildScriptLines es =
  case T.lines (esScript es) of
    [] -> ["elements " <> openTag <> closeTag]
    [single] -> ["elements " <> openTag <> single <> closeTag]
    multiple ->
      ["elements " <> openTag]
        <> map ("elements " <>) multiple
        <> ["elements " <> closeTag]
 where
  openTag :: Text
  openTag =
    "<script"
      <> (if esAutoRemove es then " data-effect=\"el.remove()\"" else "")
      <> foldMap (" " <>) (esAttributes es)
      <> ">"

  closeTag :: Text
  closeTag = "</script>"