packages feed

yesod-vite-0.2.0.0: src/Yesod/Vite.hs

{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE TypeFamilies #-}

-- |
-- Module      : Yesod.Vite
-- Description : Vite Integration for the Yesod Web Framework
-- Copyright   : (c) Ian Kollipara, 2026
-- License     : BSD-2-Clause
-- Maintainer  : ian.kollipara@gmail.com
-- Stability   : experimental
-- Portability : POSIX
--
-- This module provides an integration to @[vitejs](https://vite.dev)@, a modern frontend build tool,
-- to the "Yesod" web framework. This integration would allow the use of @vite@, setup in backend mode,
-- to work seamlessly with "Yesod".
--
-- = Quick Start
-- The assumption here is that your setup is similar to the default Yesod scaffold.
-- You will need to have installed both @yesod-static@ and @yesod-vite@ for this to work.
-- In addition, you will need the static subsite configured, as the route constructor is required.
--
-- - Run @npm init -y@ or the equivalent for your build tool.
--
-- - Run @npm i -D vite@ or the equivalent for your build tool.
--
-- - Create a @vite.config.js@ to include the following:
--
--
-- > import { defineConfig } from "vite";
-- > import * as path from "node:path";
-- >
-- > export default defineConfig({
-- >   plugins: [],
-- >   base: "static",
-- >   build: {
-- >     outDir: path.resolve("./static"),
-- >     manifest: "manifest.json",
-- >     rollupOptions: {
-- >       input: path.resolve("./assets/src/app.js"),
-- >     },
-- >   },
-- > });
--
--
-- - Install and configure "Yesod.Static" for your application.
--
-- - Setup 'YesodVite' as the following:
--
--
-- > instance YesodVite App where
-- >   viteBuildDir :: app -> IO FilePath
-- >   vitebuildDir app = return $ appStaticDir app
-- >
-- >   viteInDev :: app -> IO Bool
-- >   viteinDev app = return . const True
-- >
-- >   viteRoute :: Route Static -> Route site
-- >   viteRoute = StaticR
--
--
-- - Add the @viteAsset \<asset-name\>@ to your default layout.
module Yesod.Vite
  ( YesodVite (..),
    decodeManifest,
    gatherAllCSS,
    gatherAllModules,
    ViteManifestChunk,
    ViteManifest,
  )
where

import Data.Aeson
  ( FromJSON (parseJSON),
    eitherDecode,
    withObject,
    (.!=),
    (.:),
    (.:?),
  )
import qualified Data.ByteString.Lazy.Char8 as BS
import Data.Functor (($>))
import Data.List (nub)
import qualified Data.Map as M
import qualified Data.Text as T
import GHC.Generics (Generic)
import Yesod.Core
  ( MonadIO (liftIO),
    RenderRoute (Route),
    WidgetFor,
    Yesod,
    getYesod,
    whamlet,
  )
import Yesod.Static (Route (StaticRoute), Static)

-- * Types

-- | A manifest chunk as described by the vite configuration.
data ViteManifestChunk
  = ViteManifestChunk
  { src :: Maybe FilePath,
    file :: FilePath,
    css :: [FilePath],
    assets :: [FilePath],
    isEntry :: Bool,
    name :: Maybe T.Text,
    isDynamicEntry :: Bool,
    imports :: [T.Text],
    dynamicImports :: [T.Text]
  }
  deriving (Show, Generic)

instance FromJSON ViteManifestChunk where
  parseJSON = withObject "ViteManifestChunk" $ \o ->
    ViteManifestChunk
      <$> o .:? "src"
      <*> o .: "file"
      <*> o .:? "css" .!= mempty
      <*> o .:? "assets" .!= mempty
      <*> o .:? "isEntry" .!= False
      <*> o .:? "name"
      <*> o .:? "isDynamicEntry" .!= False
      <*> o .:? "imports" .!= mempty
      <*> o .:? "dynamicImports" .!= mempty

-- | A wrapper around a map to use for parsing the manifest file as described by vite.
type ViteManifest = M.Map T.Text ViteManifestChunk

-- | Attempt to decode a manifest
decodeManifest :: BS.ByteString -> Either String ViteManifest
decodeManifest = eitherDecode

-- | Recursively traverse the manifest, starting at the @assetName@ to find all unique css values.
gatherAllCSS ::
  -- | The entry in the manifest.json. This is specified by your @vite.config.js@ as your input
  T.Text ->
  -- | The actual manifest to traverse.
  ViteManifest ->
  -- | The paths to all the css.
  [FilePath]
gatherAllCSS
  entry
  man =
    let gatherAllCSS' :: T.Text -> [FilePath]
        gatherAllCSS' k' =
          case man M.!? k' of
            Nothing -> []
            Just e -> css e <> concat [gatherAllCSS' k | k <- imports e]
     in nub $ gatherAllCSS' entry

-- | Recursively traverse the manifest, starting at the @assetName@ to find all unique js modules.
gatherAllModules ::
  -- | The entry in the manifest.json. This is specified by your @vite.config.js@ as your input
  T.Text ->
  -- | The actual manifest to traverse.
  ViteManifest ->
  -- | The paths to all the js modules.
  [FilePath]
gatherAllModules entry man =
  let gatherAllModules' :: T.Text -> [FilePath]
      gatherAllModules' k' =
        case man M.!? k' of
          Nothing -> []
          Just e -> file e : concat [gatherAllModules' k | k <- imports e]
   in nub $ gatherAllModules' entry

-- | = YesodVite
-- This typeclass is used for configuring the uses of @vite@ in your "Yesod" application.
class (Yesod site) => YesodVite site where
  -- | The directory that vite builds to. This should match the output field in your @vite.config.js@.
  viteBuildDir :: site -> IO FilePath

  -- | Whether or not the site is in development. This is used to either serve production or development assets.
  viteInDev :: site -> IO Bool

  -- | How to create a static route. Typically you can set this to just `StaticR`.
  viteRoute :: Route Static -> Route site

  {-# MINIMAL viteBuildDir, viteInDev, viteRoute #-}

  -- | The url to serve development resources from.
  -- Defaults to @\http://127.0.0.1:5173\@
  viteDevUrl :: site -> IO String
  viteDevUrl = return . const "http://127.0.0.1:5173"

  -- | The path to the built manifest. Defaults to `buildDir` + @/.vite/manifest.json@
  viteManifest :: site -> IO FilePath
  viteManifest site_ = do
    basePath <- viteBuildDir site_
    return $ basePath <> "/.vite/manifest.json"

  -- | A helper used by @vite@ for @react@-based sites.
  -- This is entirely optional, unless you are using a @react@-based frontend site.
  -- This will be empty in production, but will enable hot reload in development.
  viteEnableReactRefresh :: WidgetFor site ()
  viteEnableReactRefresh = do
    site_ <- getYesod
    devUrl <- liftIO $ viteDevUrl site_
    inDev <- liftIO $ viteInDev site_
    if inDev
      then
        [whamlet|
        <script type="module">
          import RefreshRuntime from '#{devUrl}/@react-refresh';
          RefreshRuntime.injectIntoGlobalHook(window);
          window.$RefreshReg$ = () => {};
          window.$RefreshSig$ = () => (type) => type;
          window.__vite_plugin_react_preamble_installed__ = true;
      |]
      else
        return mempty

  -- | Insert a @vite@ asset into your site.
  -- In development this will point at the vite development server.
  -- In production, this will traverse the manifest and insert the correct tags
  -- to your static assets.
  viteAsset :: T.Text -> WidgetFor site ()
  viteAsset assetName = do
    site_ <- getYesod
    inDev <- liftIO $ viteInDev site_
    if inDev
      then viteDevAsset assetName site_
      else viteProductionAsset assetName site_

-- = Internal

-- | Handle the development asset
viteDevAsset :: (YesodVite site, Yesod site) => T.Text -> site -> WidgetFor site ()
viteDevAsset assetName s = do
  devUrl <- liftIO $ viteDevUrl s
  [whamlet|
          <script type="module" src="#{devUrl}/@vite/client">
          <script type="module" src="#{devUrl}/#{assetName}">
  |]

-- | Handle the production asset
viteProductionAsset :: (YesodVite site, Yesod site) => T.Text -> site -> WidgetFor site ()
viteProductionAsset assetName s = do
  manifestPath <- liftIO $ viteManifest s
  manifestContents <- liftIO $ BS.readFile manifestPath
  let manifestE = decodeManifest manifestContents
  case manifestE of
    Left err -> error err $> mempty
    Right manifest -> do
      let isJS = T.isSuffixOf ".js" assetName
          allCss = T.splitOn "/" . T.pack <$> gatherAllCSS assetName manifest
          allModules = T.splitOn "/" . T.pack <$> gatherAllModules assetName manifest
          assetUrl = T.splitOn "/" assetName
      [whamlet|
            $if isJS
              <script type="module" src="@{viteRoute $ StaticRoute assetUrl []}">
            $else
              <link ref="stylesheet" href="@{viteRoute $ StaticRoute assetUrl []}">
            $forall css <- allCss
              <link ref="stylesheet" href="@{viteRoute $ StaticRoute css []}">
            $forall module_ <- allModules
              <link ref="modulepreload" href="@{viteRoute $ StaticRoute module_ []}">
          |]