packages feed

datastar-hs-0.1.0.0: 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>"