packages feed

ema 0.6.0.0 → 0.8.0.0

raw patch · 48 files changed

+3391/−807 lines, 48 filesdep +emadep +file-embeddep +fsnotifydep ~basedep ~reludePVP ok

version bump matches the API change (PVP)

Dependencies added: ema, file-embed, fsnotify, generic-optics, generics-sop, mtl, optics-core, pandoc, pandoc-types, raw-strings-qq, sop-core, template-haskell, unionmount

Dependency ranges changed: base, relude

API changes (from Hackage documentation)

- Ema: allRoutes :: (Ema model route, Bounded route, Enum route) => model -> [route]
- Ema: class Ema model route | route -> model
- Ema: decodeRoute :: Ema model route => model -> FilePath -> Maybe route
- Ema: encodeRoute :: Ema model route => model -> route -> FilePath
- Ema: runEma :: forall model route b. (Ema model route, Show route) => (Some Action -> model -> route -> Asset LByteString) -> (forall m. (MonadIO m, MonadUnliftIO m, MonadLoggerIO m) => Some Action -> LVar model -> m b) -> IO (Either b (DSum Action Identity))
- Ema: runEmaPure :: (Some Action -> LByteString) -> IO ()
- Ema: runEmaWithCli :: forall model route b. (Ema model route, Show route) => Cli -> (Some Action -> model -> route -> Asset LByteString) -> (forall m. (MonadIO m, MonadUnliftIO m, MonadLoggerIO m) => Some Action -> LVar model -> m b) -> IO (Either b (DSum Action Identity))
+ Ema: -- <a>siteInput</a> function consumes as argument.
+ Ema: -- LByteString` but it can be anything.
+ Ema: -- file, or command-line arguments) that your Dynamic-producing
+ Ema: -- | Type of the value returned by <a>siteOutput</a>. Usually `Asset
+ Ema: Dynamic :: (a, (a -> m ()) -> m ()) -> Dynamic m a
+ Ema: class IsRoute r => EmaSite r where {
+ Ema: class IsRoute r where {
+ Ema: fromPrism_ :: Prism_ s a -> Prism' s a
+ Ema: newtype Dynamic m a
+ Ema: routePrism :: IsRoute r => RouteModel r -> Prism_ FilePath r
+ Ema: routeUniverse :: IsRoute r => RouteModel r -> [r]
+ Ema: runSite :: forall r. (Show r, Eq r, EmaStaticSite r) => SiteArg r -> IO [FilePath]
+ Ema: runSiteWithCli :: forall r. (Show r, Eq r, EmaStaticSite r) => Cli -> SiteArg r -> IO (RouteModel r, DSum Action Identity)
+ Ema: runSite_ :: forall r. (Show r, Eq r, EmaStaticSite r) => SiteArg r -> IO ()
+ Ema: siteInput :: forall m. (EmaSite r, MonadIO m, MonadUnliftIO m, MonadLoggerIO m) => Some Action -> SiteArg r -> m (Dynamic m (RouteModel r))
+ Ema: siteOutput :: forall m. (EmaSite r, MonadIO m, MonadLoggerIO m) => Prism' FilePath r -> RouteModel r -> r -> m (SiteOutput r)
+ Ema: toPrism_ :: Prism' s a -> Prism_ s a
+ Ema: type EmaStaticSite r = (EmaSite r, SiteOutput r ~ Asset LByteString)
+ Ema: type SiteArg r = ();
+ Ema: type SiteOutput r = Asset LByteString;
+ Ema: type family SiteOutput r :: Type;
+ Ema: }
+ Ema.App: runSite :: forall r. (Show r, Eq r, EmaStaticSite r) => SiteArg r -> IO [FilePath]
+ Ema.App: runSiteWithCli :: forall r. (Show r, Eq r, EmaStaticSite r) => Cli -> SiteArg r -> IO (RouteModel r, DSum Action Identity)
+ Ema.App: runSite_ :: forall r. (Show r, Eq r, EmaStaticSite r) => SiteArg r -> IO ()
+ Ema.Asset: AssetGenerated :: Format -> a -> Asset a
+ Ema.Asset: AssetStatic :: FilePath -> Asset a
+ Ema.Asset: Html :: Format
+ Ema.Asset: Other :: Format
+ Ema.Asset: data Asset a
+ Ema.Asset: data Format
+ Ema.Asset: instance GHC.Base.Functor Ema.Asset.Asset
+ Ema.Asset: instance GHC.Classes.Eq Ema.Asset.Format
+ Ema.Asset: instance GHC.Classes.Ord Ema.Asset.Format
+ Ema.Asset: instance GHC.Generics.Generic Ema.Asset.Format
+ Ema.Asset: instance GHC.Show.Show Ema.Asset.Format
+ Ema.Asset: instance forall a. GHC.Classes.Eq a => GHC.Classes.Eq (Ema.Asset.Asset a)
+ Ema.Asset: instance forall a. GHC.Classes.Ord a => GHC.Classes.Ord (Ema.Asset.Asset a)
+ Ema.Asset: instance forall a. GHC.Generics.Generic (Ema.Asset.Asset a)
+ Ema.Asset: instance forall a. GHC.Show.Show a => GHC.Show.Show (Ema.Asset.Asset a)
+ Ema.CLI: Host :: Text -> Host
+ Ema.CLI: [unHost] :: Host -> Text
+ Ema.CLI: [verbose] :: Cli -> Bool
+ Ema.CLI: crash :: (MonadLoggerIO m, MonadFail m) => LogSource -> Text -> m a
+ Ema.CLI: getLogger :: Cli -> Logger
+ Ema.CLI: hostParser :: Parser Host
+ Ema.CLI: instance Data.Default.Class.Default Ema.CLI.Cli
+ Ema.CLI: instance Data.Default.Class.Default Ema.CLI.Host
+ Ema.CLI: instance Data.String.IsString Ema.CLI.Host
+ Ema.CLI: instance GHC.Classes.Eq Ema.CLI.Host
+ Ema.CLI: instance GHC.Classes.Ord Ema.CLI.Host
+ Ema.CLI: instance GHC.Show.Show Ema.CLI.Host
+ Ema.CLI: newtype Host
+ Ema.CLI: portParser :: Parser Port
+ Ema.Dynamic: Dynamic :: (a, (a -> m ()) -> m ()) -> Dynamic m a
+ Ema.Dynamic: instance forall (m :: Type -> Type). (Control.Monad.IO.Unlift.MonadUnliftIO m, Control.Monad.Logger.MonadLogger m) => GHC.Base.Applicative (Ema.Dynamic.Dynamic m)
+ Ema.Dynamic: instance forall (m :: Type -> Type). GHC.Base.Functor (Ema.Dynamic.Dynamic m)
+ Ema.Dynamic: newtype Dynamic m a
+ Ema.Generate: generateSiteFromModel :: forall r m. (MonadIO m, MonadLoggerIO m, MonadFail m, Eq r, Show r, IsRoute r, EmaStaticSite r) => FilePath -> RouteModel r -> m [FilePath]
+ Ema.Generate: generateSiteFromModel' :: forall r m. (MonadIO m, MonadLoggerIO m, MonadError Text m, Eq r, Show r, EmaStaticSite r) => FilePath -> RouteModel r -> m [FilePath]
+ Ema.Generate: instance GHC.Exception.Type.Exception Ema.Generate.StaticAssetMissing
+ Ema.Generate: instance GHC.Show.Show Ema.Generate.StaticAssetMissing
+ Ema.Route.Class: class IsRoute r where {
+ Ema.Route.Class: instance Ema.Route.Class.IsRoute ()
+ Ema.Route.Class: routePrism :: IsRoute r => RouteModel r -> Prism_ FilePath r
+ Ema.Route.Class: routeUniverse :: IsRoute r => RouteModel r -> [r]
+ Ema.Route.Class: type family RouteModel r :: Type;
+ Ema.Route.Class: }
+ Ema.Route.Generic: FileRoute :: () -> FileRoute (filename :: Symbol)
+ Ema.Route.Generic: FolderRoute :: r -> FolderRoute (prefix :: Symbol) r
+ Ema.Route.Generic: GenericRoute :: r -> GenericRoute r (opts :: [Type])
+ Ema.Route.Generic: class GenericRouteOpt (r :: Type) (opt :: Type) where {
+ Ema.Route.Generic: class HasSubRoutes r => HasSubModels r
+ Ema.Route.Generic: class HasSubRoutes r
+ Ema.Route.Generic: data WithModel (r :: Type)
+ Ema.Route.Generic: data WithSubModels (subModels :: [Type])
+ Ema.Route.Generic: data WithSubRoutes (subRoutes :: [Type])
+ Ema.Route.Generic: instance forall r (opts :: [Type]) mr (mm :: [Type]). (Ema.Route.Generic.Verification.VerifyRoutes (Ema.Route.Generic.RGeneric.RCode r) (Ema.Route.Generic.SubRoute.SubRoutes @{Type} (Ema.Route.Generic.GenericRoute r opts)), Ema.Route.Generic.SubRoute.HasSubRoutes @{Type} r, Ema.Route.Generic.SubModel.HasSubModels r, Ema.Route.Generic.SubRoute.ValidSubRoutes r (Ema.Route.Generic.SubRoute.SubRoutes @{Type} r), Ema.Route.Generic.RGeneric.RGeneric r, (mr :: Type) GHC.Types.~ (Ema.Route.Lib.Multi.MultiRoute (Ema.Route.Generic.SubRoute.SubRoutes @{Type} r) :: Type), (mm :: [Type]) GHC.Types.~ (Ema.Route.Lib.Multi.MultiModel (Ema.Route.Generic.SubRoute.SubRoutes @{Type} r) :: [Type]), (Ema.Route.Class.RouteModel r :: Type) GHC.Types.~ (Ema.Route.Generic.OptModel r opts :: Type), (Ema.Route.Class.RouteModel mr :: Type) GHC.Types.~ (Data.SOP.NP.NP @Type Data.SOP.BasicFunctors.I mm :: Type), Ema.Route.Class.IsRoute mr, Ema.Route.Generic.GenericRouteOpts r opts) => Ema.Route.Class.IsRoute (Ema.Route.Generic.GenericRoute r opts)
+ Ema.Route.Generic: instance forall r (opts :: [Type]). (Ema.Route.Generic.GenericRouteOpts r opts, Ema.Route.Generic.RGeneric.RGeneric r, Ema.Route.Generic.SubRoute.ValidSubRoutes r (Ema.Route.Generic.OptSubRoutes r opts)) => Ema.Route.Generic.SubRoute.HasSubRoutes @{Type} (Ema.Route.Generic.GenericRoute r opts)
+ Ema.Route.Generic: instance forall r (opts :: [Type]). (Ema.Route.Generic.Verification.VerifyModels (Ema.Route.Class.RouteModel (Ema.Route.Generic.GenericRoute r opts)) (Ema.Route.Lib.Multi.MultiModel (Ema.Route.Generic.SubRoute.SubRoutes @{Type} (Ema.Route.Generic.GenericRoute r opts))) (Ema.Route.Generic.OptSubModels r opts), Ema.Route.Generic.Verification.VerifyRoutes (Ema.Route.Generic.RGeneric.RCode r) (Ema.Route.Generic.SubRoute.SubRoutes @{Type} (Ema.Route.Generic.GenericRoute r opts)), Ema.Route.Generic.SubModel.GSubModels @Type (Ema.Route.Class.RouteModel (Ema.Route.Generic.GenericRoute r opts)) (Ema.Route.Lib.Multi.MultiModel (Ema.Route.Generic.OptSubRoutes r opts)) (Ema.Route.Generic.OptSubModels r opts), Ema.Route.Generic.SubRoute.HasSubRoutes @{Type} (Ema.Route.Generic.GenericRoute r opts), Ema.Route.Generic.GenericRouteOpts r opts) => Ema.Route.Generic.SubModel.HasSubModels (Ema.Route.Generic.GenericRoute r opts)
+ Ema.Route.Generic: instance forall r (opts :: [Type]). GHC.Generics.Generic (Ema.Route.Generic.GenericRoute r opts)
+ Ema.Route.Generic: instance forall r (t :: [Type]). Ema.Route.Generic.GenericRouteOpt r (Ema.Route.Generic.WithSubModels t)
+ Ema.Route.Generic: instance forall r (t :: [Type]). Ema.Route.Generic.GenericRouteOpt r (Ema.Route.Generic.WithSubRoutes t)
+ Ema.Route.Generic: instance forall r t. Ema.Route.Generic.GenericRouteOpt r (Ema.Route.Generic.WithModel t)
+ Ema.Route.Generic: newtype FileRoute (filename :: Symbol)
+ Ema.Route.Generic: newtype FolderRoute (prefix :: Symbol) r
+ Ema.Route.Generic: newtype GenericRoute r (opts :: [Type])
+ Ema.Route.Generic: subModels :: HasSubModels r => RouteModel r -> NP I (MultiModel (SubRoutes r))
+ Ema.Route.Generic: type OptModelM r opt = 'Nothing;
+ Ema.Route.Generic: type OptSubModelsM r opt = 'Nothing;
+ Ema.Route.Generic: type OptSubRoutesM r opt = 'Nothing;
+ Ema.Route.Generic: type family OptSubModelsM r opt :: Maybe [Type];
+ Ema.Route.Generic: }
+ Ema.Route.Generic.RGeneric: class (Generic r, HasDatatypeInfo r) => RGeneric r where {
+ Ema.Route.Generic.RGeneric: class (RGeneric r, HasDatatypeInfo r) => RHasDatatypeInfo r where {
+ Ema.Route.Generic.RGeneric: instance Ema.Route.Generic.RGeneric.RGeneric' ('[] @[Type])
+ Ema.Route.Generic.RGeneric: instance Ema.Route.Generic.RGeneric.RouteNP ('[] @Type)
+ Ema.Route.Generic.RGeneric: instance forall (xss :: [[Type]]) (xs :: [Type]). (Ema.Route.Generic.RGeneric.RGeneric' xss, Ema.Route.Generic.RGeneric.RouteNP xs) => Ema.Route.Generic.RGeneric.RGeneric' ((':) @[Type] xs xss)
+ Ema.Route.Generic.RGeneric: instance forall r. (Ema.Route.Generic.RGeneric.RGeneric r, Generics.SOP.Universe.HasDatatypeInfo r) => Ema.Route.Generic.RGeneric.RHasDatatypeInfo r
+ Ema.Route.Generic.RGeneric: instance forall r. (Generics.SOP.Universe.Generic r, Generics.SOP.Universe.HasDatatypeInfo r, Ema.Route.Generic.RGeneric.RGeneric' (Generics.SOP.Universe.Code r), Data.SOP.Constraint.All @[Type] Ema.Route.Generic.RGeneric.RouteNP (Generics.SOP.Universe.Code r)) => Ema.Route.Generic.RGeneric.RGeneric r
+ Ema.Route.Generic.RGeneric: instance forall x x' (xs :: [Type]). GHC.TypeLits.Extra.TypeErr ('GHC.TypeLits.Text "MultiRoute: too many arguments") => Ema.Route.Generic.RGeneric.RouteNP ((':) @Type x ((':) @Type x' xs))
+ Ema.Route.Generic.RGeneric: instance forall x. Ema.Route.Generic.RGeneric.RouteNP ((':) @Type x ('[] @Type))
+ Ema.Route.Generic.RGeneric: rfrom :: RGeneric r => r -> NS I (RCode r)
+ Ema.Route.Generic.RGeneric: rto :: RGeneric r => NS I (RCode r) -> r
+ Ema.Route.Generic.RGeneric: type family RConstructorNames r :: [ConstructorName];
+ Ema.Route.Generic.RGeneric: }
+ Ema.Route.Generic.SubModel: class GSubModels m (ms :: [Type]) (lookups :: [k])
+ Ema.Route.Generic.SubModel: class HasSubRoutes r => HasSubModels r
+ Ema.Route.Generic.SubModel: gsubModels :: GSubModels m ms lookups => m -> NP I ms
+ Ema.Route.Generic.SubModel: instance forall k (s :: k) m t (ms :: [Type]) (ss :: [k]). (Data.Generics.Product.Any.HasAny @{k} s m m t t, Ema.Route.Generic.SubModel.GSubModels @k m ms ss) => Ema.Route.Generic.SubModel.GSubModels @k m ((':) @Type t ms) ((':) @k s ss)
+ Ema.Route.Generic.SubModel: instance forall k (sel :: k) s t a b. Data.Generics.Product.Any.HasAny @{k} sel s t a b => Data.Generics.Product.Any.HasAny @{Type} (Data.Proxy.Proxy @{k} sel) s t a b
+ Ema.Route.Generic.SubModel: instance forall k m. Ema.Route.Generic.SubModel.GSubModels @k m ('[] @Type) ('[] @k)
+ Ema.Route.Generic.SubModel: instance forall s. Data.Generics.Product.Any.HasAny @{Type} () s s () ()
+ Ema.Route.Generic.SubModel: subModels :: HasSubModels r => RouteModel r -> NP I (MultiModel (SubRoutes r))
+ Ema.Route.Generic.SubRoute: -- | The sub-routes in the <tt>r</tt> (for each constructor).
+ Ema.Route.Generic.SubRoute: class HasSubRoutes r where {
+ Ema.Route.Generic.SubRoute: gfromSubRoutes :: forall r subRoutes. (RGeneric r, ValidSubRoutes r subRoutes) => MultiRoute subRoutes -> NS I (RCode r)
+ Ema.Route.Generic.SubRoute: gtoSubRoutes :: forall r subRoutes. (RGeneric r, ValidSubRoutes r subRoutes) => NS I (RCode r) -> MultiRoute subRoutes
+ Ema.Route.Generic.SubRoute: subRoutesIso :: forall r. (RGeneric r, HasSubRoutes r, ValidSubRoutes r (SubRoutes r)) => Iso' r (MultiRoute (SubRoutes r))
+ Ema.Route.Generic.SubRoute: type ValidSubRoutes r subRoutes = (SameShapeAs (RCode r) subRoutes, SameShapeAs subRoutes (RCode r), All Top (RCode r), All Top subRoutes, AllZipF Coercible (RCode r) subRoutes, AllZipF Coercible subRoutes (RCode r))
+ Ema.Route.Generic.SubRoute: type family GSubRoutes (name :: DatatypeName) (constrs :: [ConstructorName]) (xs :: [Type]) :: [Type]
+ Ema.Route.Generic.SubRoute: }
+ Ema.Route.Generic.TH: deriveGeneric :: Name -> Q [Dec]
+ Ema.Route.Generic.TH: deriveIsRoute :: Name -> TypeQ -> Q [Dec]
+ Ema.Route.Generic.Verification: type family VerifyRoutes (rcode :: [Type]) (subRoutes :: [Type]) :: Constraint
+ Ema.Route.Lib.Extra.PandocRoute: Arg :: FilePath -> Set String -> ReaderOptions -> WriterOptions -> Arg
+ Ema.Route.Lib.Extra.PandocRoute: Model :: Arg -> Map PandocRoute Pandoc -> Model
+ Ema.Route.Lib.Extra.PandocRoute: PandocError_Missing :: Text -> PandocError
+ Ema.Route.Lib.Extra.PandocRoute: PandocError_RenderError :: Text -> PandocError
+ Ema.Route.Lib.Extra.PandocRoute: PandocHtml :: Text -> PandocHtml
+ Ema.Route.Lib.Extra.PandocRoute: PandocRoute :: SlugRoute Pandoc -> PandocRoute
+ Ema.Route.Lib.Extra.PandocRoute: [argBaseDir] :: Arg -> FilePath
+ Ema.Route.Lib.Extra.PandocRoute: [argFormats] :: Arg -> Set String
+ Ema.Route.Lib.Extra.PandocRoute: [argReaderOpts] :: Arg -> ReaderOptions
+ Ema.Route.Lib.Extra.PandocRoute: [argWriterOpts] :: Arg -> WriterOptions
+ Ema.Route.Lib.Extra.PandocRoute: [modelArg] :: Model -> Arg
+ Ema.Route.Lib.Extra.PandocRoute: [modelPandocs] :: Model -> Map PandocRoute Pandoc
+ Ema.Route.Lib.Extra.PandocRoute: [unPandocHtml] :: PandocHtml -> Text
+ Ema.Route.Lib.Extra.PandocRoute: [unPandocRoute] :: PandocRoute -> SlugRoute Pandoc
+ Ema.Route.Lib.Extra.PandocRoute: data Arg
+ Ema.Route.Lib.Extra.PandocRoute: data Model
+ Ema.Route.Lib.Extra.PandocRoute: data PandocError
+ Ema.Route.Lib.Extra.PandocRoute: instance Data.Default.Class.Default Ema.Route.Lib.Extra.PandocRoute.Arg
+ Ema.Route.Lib.Extra.PandocRoute: instance Data.String.IsString Ema.Route.Lib.Extra.PandocRoute.PandocRoute
+ Ema.Route.Lib.Extra.PandocRoute: instance Ema.Route.Class.IsRoute Ema.Route.Lib.Extra.PandocRoute.PandocRoute
+ Ema.Route.Lib.Extra.PandocRoute: instance Ema.Route.Generic.SubModel.HasSubModels Ema.Route.Lib.Extra.PandocRoute.PandocRoute
+ Ema.Route.Lib.Extra.PandocRoute: instance Ema.Route.Generic.SubRoute.HasSubRoutes @{Type} Ema.Route.Lib.Extra.PandocRoute.PandocRoute
+ Ema.Route.Lib.Extra.PandocRoute: instance Ema.Site.EmaSite Ema.Route.Lib.Extra.PandocRoute.PandocRoute
+ Ema.Route.Lib.Extra.PandocRoute: instance GHC.Classes.Eq Ema.Route.Lib.Extra.PandocRoute.PandocError
+ Ema.Route.Lib.Extra.PandocRoute: instance GHC.Classes.Eq Ema.Route.Lib.Extra.PandocRoute.PandocHtml
+ Ema.Route.Lib.Extra.PandocRoute: instance GHC.Classes.Eq Ema.Route.Lib.Extra.PandocRoute.PandocRoute
+ Ema.Route.Lib.Extra.PandocRoute: instance GHC.Classes.Eq Ema.Route.Lib.Extra.PandocRoute.UnsupportedPandocFormat
+ Ema.Route.Lib.Extra.PandocRoute: instance GHC.Classes.Ord Ema.Route.Lib.Extra.PandocRoute.PandocRoute
+ Ema.Route.Lib.Extra.PandocRoute: instance GHC.Exception.Type.Exception Ema.Route.Lib.Extra.PandocRoute.PandocError
+ Ema.Route.Lib.Extra.PandocRoute: instance GHC.Exception.Type.Exception Ema.Route.Lib.Extra.PandocRoute.UnsupportedPandocFormat
+ Ema.Route.Lib.Extra.PandocRoute: instance GHC.Generics.Generic Ema.Route.Lib.Extra.PandocRoute.Arg
+ Ema.Route.Lib.Extra.PandocRoute: instance GHC.Generics.Generic Ema.Route.Lib.Extra.PandocRoute.Model
+ Ema.Route.Lib.Extra.PandocRoute: instance GHC.Generics.Generic Ema.Route.Lib.Extra.PandocRoute.PandocHtml
+ Ema.Route.Lib.Extra.PandocRoute: instance GHC.Generics.Generic Ema.Route.Lib.Extra.PandocRoute.PandocRoute
+ Ema.Route.Lib.Extra.PandocRoute: instance GHC.Show.Show Ema.Route.Lib.Extra.PandocRoute.PandocError
+ Ema.Route.Lib.Extra.PandocRoute: instance GHC.Show.Show Ema.Route.Lib.Extra.PandocRoute.PandocRoute
+ Ema.Route.Lib.Extra.PandocRoute: instance GHC.Show.Show Ema.Route.Lib.Extra.PandocRoute.UnsupportedPandocFormat
+ Ema.Route.Lib.Extra.PandocRoute: instance Generics.SOP.Universe.Generic Ema.Route.Lib.Extra.PandocRoute.PandocRoute
+ Ema.Route.Lib.Extra.PandocRoute: instance Generics.SOP.Universe.HasDatatypeInfo Ema.Route.Lib.Extra.PandocRoute.PandocRoute
+ Ema.Route.Lib.Extra.PandocRoute: lookupPandocRoute :: Model -> PandocRoute -> Maybe (Pandoc, Pandoc -> PandocHtml)
+ Ema.Route.Lib.Extra.PandocRoute: mkPandocRoute :: FilePath -> Maybe (String, PandocRoute)
+ Ema.Route.Lib.Extra.PandocRoute: newtype PandocHtml
+ Ema.Route.Lib.Extra.PandocRoute: newtype PandocRoute
+ Ema.Route.Lib.Extra.SlugRoute: data SlugRoute (a :: Type)
+ Ema.Route.Lib.Extra.SlugRoute: instance forall a. Ema.Route.Class.IsRoute (Ema.Route.Lib.Extra.SlugRoute.SlugRoute a)
+ Ema.Route.Lib.Extra.SlugRoute: instance forall a. GHC.Classes.Eq (Ema.Route.Lib.Extra.SlugRoute.SlugRoute a)
+ Ema.Route.Lib.Extra.SlugRoute: instance forall a. GHC.Classes.Ord (Ema.Route.Lib.Extra.SlugRoute.SlugRoute a)
+ Ema.Route.Lib.Extra.SlugRoute: instance forall a. GHC.Generics.Generic (Ema.Route.Lib.Extra.SlugRoute.SlugRoute a)
+ Ema.Route.Lib.Extra.SlugRoute: instance forall a. GHC.Show.Show (Ema.Route.Lib.Extra.SlugRoute.SlugRoute a)
+ Ema.Route.Lib.Extra.SlugRoute: mkSlugRoute :: forall a. FilePath -> Maybe (String, SlugRoute a)
+ Ema.Route.Lib.Extra.StaticRoute: Model :: Some Action -> Map FilePath UTCTime -> Model
+ Ema.Route.Lib.Extra.StaticRoute: [modelCliAction] :: Model -> Some Action
+ Ema.Route.Lib.Extra.StaticRoute: [modelFiles] :: Model -> Map FilePath UTCTime
+ Ema.Route.Lib.Extra.StaticRoute: data Model
+ Ema.Route.Lib.Extra.StaticRoute: data StaticRoute (baseDir :: Symbol)
+ Ema.Route.Lib.Extra.StaticRoute: instance GHC.Classes.Eq Ema.Route.Lib.Extra.StaticRoute.MissingStaticFile
+ Ema.Route.Lib.Extra.StaticRoute: instance GHC.Classes.Eq Ema.Route.Lib.Extra.StaticRoute.Model
+ Ema.Route.Lib.Extra.StaticRoute: instance GHC.Exception.Type.Exception Ema.Route.Lib.Extra.StaticRoute.MissingStaticFile
+ Ema.Route.Lib.Extra.StaticRoute: instance GHC.Generics.Generic Ema.Route.Lib.Extra.StaticRoute.Model
+ Ema.Route.Lib.Extra.StaticRoute: instance GHC.Show.Show Ema.Route.Lib.Extra.StaticRoute.MissingStaticFile
+ Ema.Route.Lib.Extra.StaticRoute: instance GHC.Show.Show Ema.Route.Lib.Extra.StaticRoute.Model
+ Ema.Route.Lib.Extra.StaticRoute: instance forall (baseDir :: GHC.Types.Symbol). Ema.Route.Class.IsRoute (Ema.Route.Lib.Extra.StaticRoute.StaticRoute baseDir)
+ Ema.Route.Lib.Extra.StaticRoute: instance forall (baseDir :: GHC.Types.Symbol). GHC.Classes.Eq (Ema.Route.Lib.Extra.StaticRoute.StaticRoute baseDir)
+ Ema.Route.Lib.Extra.StaticRoute: instance forall (baseDir :: GHC.Types.Symbol). GHC.Classes.Ord (Ema.Route.Lib.Extra.StaticRoute.StaticRoute baseDir)
+ Ema.Route.Lib.Extra.StaticRoute: instance forall (baseDir :: GHC.Types.Symbol). GHC.Generics.Generic (Ema.Route.Lib.Extra.StaticRoute.StaticRoute baseDir)
+ Ema.Route.Lib.Extra.StaticRoute: instance forall (baseDir :: GHC.Types.Symbol). GHC.Show.Show (Ema.Route.Lib.Extra.StaticRoute.StaticRoute baseDir)
+ Ema.Route.Lib.Extra.StaticRoute: instance forall (baseDir :: GHC.Types.Symbol). GHC.TypeLits.KnownSymbol baseDir => Ema.Site.EmaSite (Ema.Route.Lib.Extra.StaticRoute.StaticRoute baseDir)
+ Ema.Route.Lib.Extra.StaticRoute: staticRouteUrl :: forall s baseDir. (IsString s, HasCallStack) => Prism' FilePath (StaticRoute baseDir) -> RouteModel (StaticRoute baseDir) -> FilePath -> s
+ Ema.Route.Lib.File: FileRoute :: () -> FileRoute (filename :: Symbol)
+ Ema.Route.Lib.File: instance forall (filename :: GHC.Types.Symbol). GHC.Classes.Eq (Ema.Route.Lib.File.FileRoute filename)
+ Ema.Route.Lib.File: instance forall (filename :: GHC.Types.Symbol). GHC.Classes.Ord (Ema.Route.Lib.File.FileRoute filename)
+ Ema.Route.Lib.File: instance forall (filename :: GHC.Types.Symbol). GHC.Generics.Generic (Ema.Route.Lib.File.FileRoute filename)
+ Ema.Route.Lib.File: instance forall (filename :: GHC.Types.Symbol). GHC.Show.Show (Ema.Route.Lib.File.FileRoute filename)
+ Ema.Route.Lib.File: instance forall (fn :: GHC.Types.Symbol). GHC.TypeLits.KnownSymbol fn => Ema.Route.Class.IsRoute (Ema.Route.Lib.File.FileRoute fn)
+ Ema.Route.Lib.File: newtype FileRoute (filename :: Symbol)
+ Ema.Route.Lib.Folder: FolderRoute :: r -> FolderRoute (prefix :: Symbol) r
+ Ema.Route.Lib.Folder: [unFolderRoute] :: FolderRoute (prefix :: Symbol) r -> r
+ Ema.Route.Lib.Folder: instance forall (prefix :: GHC.Types.Symbol) r. GHC.Classes.Eq r => GHC.Classes.Eq (Ema.Route.Lib.Folder.FolderRoute prefix r)
+ Ema.Route.Lib.Folder: instance forall (prefix :: GHC.Types.Symbol) r. GHC.Classes.Ord r => GHC.Classes.Ord (Ema.Route.Lib.Folder.FolderRoute prefix r)
+ Ema.Route.Lib.Folder: instance forall (prefix :: GHC.Types.Symbol) r. GHC.Generics.Generic r => GHC.Generics.Generic (Ema.Route.Lib.Folder.FolderRoute prefix r)
+ Ema.Route.Lib.Folder: instance forall r (prefix :: GHC.Types.Symbol). (Ema.Route.Class.IsRoute r, GHC.TypeLits.KnownSymbol prefix) => Ema.Route.Class.IsRoute (Ema.Route.Lib.Folder.FolderRoute prefix r)
+ Ema.Route.Lib.Folder: instance forall r (prefix :: GHC.Types.Symbol). (Ema.Site.EmaStaticSite r, GHC.TypeLits.KnownSymbol prefix) => Ema.Site.EmaSite (Ema.Route.Lib.Folder.FolderRoute prefix r)
+ Ema.Route.Lib.Folder: instance forall r (prefix :: GHC.Types.Symbol). (GHC.Show.Show r, GHC.TypeLits.KnownSymbol prefix) => GHC.Show.Show (Ema.Route.Lib.Folder.FolderRoute prefix r)
+ Ema.Route.Lib.Folder: newtype FolderRoute (prefix :: Symbol) r
+ Ema.Route.Lib.Folder: prefixRoutePrism :: forall prefix r. KnownSymbol prefix => (RouteModel r -> Prism_ FilePath r) -> RouteModel r -> Prism_ FilePath (FolderRoute prefix r)
+ Ema.Route.Lib.Multi: instance Ema.Route.Class.IsRoute (Ema.Route.Lib.Multi.MultiRoute ('[] @Type))
+ Ema.Route.Lib.Multi: instance Ema.Site.EmaSite (Ema.Route.Lib.Multi.MultiRoute ('[] @Type))
+ Ema.Route.Lib.Multi: instance forall r (rs :: [Type]). (Ema.Route.Class.IsRoute r, Ema.Route.Class.IsRoute (Ema.Route.Lib.Multi.MultiRoute rs), (Ema.Route.Class.RouteModel (Ema.Route.Lib.Multi.MultiRoute rs) :: Type) GHC.Types.~ (Data.SOP.NP.NP @Type Data.SOP.BasicFunctors.I (Ema.Route.Lib.Multi.MultiModel rs) :: Type)) => Ema.Route.Class.IsRoute (Ema.Route.Lib.Multi.MultiRoute ((':) @Type r rs))
+ Ema.Route.Lib.Multi: instance forall r (rs :: [Type]). (Ema.Site.EmaStaticSite r, Ema.Site.EmaStaticSite (Ema.Route.Lib.Multi.MultiRoute rs), (Ema.Site.SiteArg (Ema.Route.Lib.Multi.MultiRoute rs) :: Type) GHC.Types.~ (Data.SOP.NP.NP @Type Data.SOP.BasicFunctors.I (Ema.Route.Lib.Multi.MultiSiteArg rs) :: Type), (Ema.Route.Class.RouteModel (Ema.Route.Lib.Multi.MultiRoute rs) :: Type) GHC.Types.~ (Data.SOP.NP.NP @Type Data.SOP.BasicFunctors.I (Ema.Route.Lib.Multi.MultiModel rs) :: Type)) => Ema.Site.EmaSite (Ema.Route.Lib.Multi.MultiRoute ((':) @Type r rs))
+ Ema.Route.Lib.Multi: type MultiRoute (rs :: [Type]) = NS I rs
+ Ema.Route.Lib.Multi: type family MultiModel (rs :: [Type]) :: [Type]
+ Ema.Route.Prism: eitherRoutePrism :: (a -> Prism_ FilePath r1) -> (b -> Prism_ FilePath r2) -> (a, b) -> Prism_ FilePath (Either r1 r2)
+ Ema.Route.Prism: htmlSuffixPrism :: Prism' FilePath FilePath
+ Ema.Route.Prism: showReadPrism :: (Show a, Read a) => Prism' String a
+ Ema.Route.Prism: stringIso :: (ToString a, IsString a) => Iso' String a
+ Ema.Route.Prism.Check: checkRoutePrismGivenFilePath :: (HasCallStack, Eq r, Show r) => (a -> Prism_ FilePath r) -> a -> FilePath -> Either (r, [(FilePath, Text)]) (Maybe r)
+ Ema.Route.Prism.Check: checkRoutePrismGivenRoute :: (HasCallStack, Eq r, Show r) => (a -> Prism_ FilePath r) -> a -> r -> Either Text ()
+ Ema.Route.Prism.Type: fromPrism_ :: Prism_ s a -> Prism' s a
+ Ema.Route.Prism.Type: mapRoutePrism :: (pr `Is` A_Prism, pf `Is` A_Prism) => Optic' pf NoIx FilePath FilePath -> Optic' pr NoIx r1 r2 -> (b -> a) -> (a -> Prism_ FilePath r1) -> b -> Prism_ FilePath r2
+ Ema.Route.Prism.Type: toPrism_ :: Prism' s a -> Prism_ s a
+ Ema.Route.Prism.Type: type Prism_ s a = (a -> s, s -> Maybe a)
+ Ema.Route.Url: UrlDirect :: UrlStrategy
+ Ema.Route.Url: UrlPretty :: UrlStrategy
+ Ema.Route.Url: data UrlStrategy
+ Ema.Route.Url: instance Data.Aeson.Types.FromJSON.FromJSON Ema.Route.Url.UrlStrategy
+ Ema.Route.Url: instance GHC.Classes.Eq Ema.Route.Url.UrlStrategy
+ Ema.Route.Url: instance GHC.Classes.Ord Ema.Route.Url.UrlStrategy
+ Ema.Route.Url: instance GHC.Generics.Generic Ema.Route.Url.UrlStrategy
+ Ema.Route.Url: instance GHC.Show.Show Ema.Route.Url.UrlStrategy
+ Ema.Route.Url: routeUrl :: HasCallStack => Prism' FilePath r -> r -> Text
+ Ema.Route.Url: routeUrlWith :: HasCallStack => UrlStrategy -> Prism' FilePath r -> r -> Text
+ Ema.Route.Url: urlToFilePath :: Text -> FilePath
+ Ema.Server: BadRouteEncoding :: FilePath -> r -> [(FilePath, Text)] -> BadRouteEncoding r
+ Ema.Server: [_bre_checkLog] :: BadRouteEncoding r -> [(FilePath, Text)]
+ Ema.Server: [_bre_decodedRoute] :: BadRouteEncoding r -> r
+ Ema.Server: [_bre_urlFilePath] :: BadRouteEncoding r -> FilePath
+ Ema.Server: badRouteEncodingMsg :: Show r => BadRouteEncoding r -> Text
+ Ema.Server: data BadRouteEncoding r
+ Ema.Server: decodeRouteNothingMsg :: Text
+ Ema.Server: emaErrorHtmlResponse :: Text -> LByteString
+ Ema.Server: instance forall r. GHC.Show.Show r => GHC.Show.Show (Ema.Server.BadRouteEncoding r)
+ Ema.Server: mkHtmlErrorMsg :: Text -> LByteString
+ Ema.Server: pathInfoFromWsMsg :: Text -> [Text]
+ Ema.Server: runServerWithWebSocketHotReload :: forall r m. (Show r, MonadIO m, MonadUnliftIO m, MonadLoggerIO m, Eq r, IsRoute r, EmaStaticSite r) => Host -> Maybe Port -> LVar (RouteModel r) -> m ()
+ Ema.Server: wsClientHtml :: ByteString
+ Ema.Server: wsClientJS :: LByteString
+ Ema.Server: wsClientJSShim :: Text
+ Ema.Site: -- <a>siteInput</a> function consumes as argument.
+ Ema.Site: -- LByteString` but it can be anything.
+ Ema.Site: -- file, or command-line arguments) that your Dynamic-producing
+ Ema.Site: -- | Type of the value returned by <a>siteOutput</a>. Usually `Asset
+ Ema.Site: class IsRoute r => EmaSite r where {
+ Ema.Site: siteInput :: forall m. (EmaSite r, MonadIO m, MonadUnliftIO m, MonadLoggerIO m) => Some Action -> SiteArg r -> m (Dynamic m (RouteModel r))
+ Ema.Site: siteOutput :: forall m. (EmaSite r, MonadIO m, MonadLoggerIO m) => Prism' FilePath r -> RouteModel r -> r -> m (SiteOutput r)
+ Ema.Site: type EmaStaticSite r = (EmaSite r, SiteOutput r ~ Asset LByteString)
+ Ema.Site: type SiteArg r = ();
+ Ema.Site: type SiteOutput r = Asset LByteString;
+ Ema.Site: type family SiteOutput r :: Type;
+ Ema.Site: }
- Ema: routeUrl :: forall r model. Ema model r => model -> r -> Text
+ Ema: routeUrl :: HasCallStack => Prism' FilePath r -> r -> Text
- Ema: routeUrlWith :: forall r model. Ema model r => UrlStrategy -> model -> r -> Text
+ Ema: routeUrlWith :: HasCallStack => UrlStrategy -> Prism' FilePath r -> r -> Text
- Ema.CLI: Cli :: Some Action -> Cli
+ Ema.CLI: Cli :: Some Action -> Bool -> Cli
- Ema.CLI: [Run] :: (Host, Port) -> Action ()
+ Ema.CLI: [Run] :: (Host, Maybe Port) -> Action ()
- Ema.CLI: data Action res
+ Ema.CLI: data Action result

Files

CHANGELOG.md view
@@ -1,5 +1,26 @@ # Revision history for ema +## 0.8.0.0 (2022-08-19)++This releases brings a significant rewrite of Ema. If you choose to upgrade your apps from 0.6, see https://ema.srid.ca/start/upgrade for guidance.++- GHC 9.x support+- Better handling of URL anchors (#83; #87)+- `routeUrl` uses `UrlDirect` by default. Use `routeUrlWith` if you want to change that.+- Ema status indicator now works independently (requires no Tailwind)+- Multisite rewrite (Ema has been mostly rewritten for better composability) ([\#82](https://github.com/EmaApps/ema/pull/81))+  - Represent route encoding using `Prism'` from optics-core; add `IsRoute` class to define them.+    - Optional generic deriving of route prisms, so you do not have to hand-write them.+    - Automatic isomorphism checks ensures that encoding and decoding are isomorphic (route prisms are lawful)+  - Composable Ema apps +    - There are two ways of composing Ema apps. Using heterogenous lists (see `Ema.Route.Lib.Multi`), or by defining a top-level route type (see `Ex04_Multi.hs`).+  - Replace `LVar` with `Dynamic`.+    - Ema still uses `LVar` internally (for live server updates), but on the user-side one only needs to provide a `Dynamic` which is a tuple of initial value and an updating function. The [unionmount](https://github.com/srid/unionmount/pull/1) library was changed to provide this tuple.+  - Add `EmaSite` typeclass to "connect them all"+    - `SiteArg`: Type of value to pass from the environment.+    - `siteInput`: Define the `Dynamic` model for the site.+    - `siteOutput`: Asset (eg: HTML) to produce for each route.+ ## 0.6.0.0 -- 2022-02-05  - Websocket API: Add `ema.switchRoute` to switch to other routes in live server.
README.md view
@@ -3,7 +3,6 @@ <img width="10%" src="https://ema.srid.ca/favicon.svg">  [![Hackage](https://img.shields.io/hackage/v/ema.svg?logo=haskell)](https://hackage.haskell.org/package/ema)-[![FAIR](https://img.shields.io/badge/FAIR-pledge-blue)](https://www.fairforall.org/about/)  Ema is a next-gen **Haskell** library toolkit for building [jamstack-style](https://jamstack.org/) static sites, with fast hot reload. See [ema.srid.ca](https://ema.srid.ca/) for further information. @@ -11,8 +10,19 @@  ## Hacking -Run `bin/run` (or <kbd>Ctrl+Shift+B</kbd> in VSCode). This runs the clock example (which updates every second, only to demonstrate hot reload); modify `./.ghcid` to run a different example. +*NOTE: `flake.nix` uses GHC 9.2 which is not yet the default in `nixpkgs`, so you may want to use the [garnix cache](https://garnix.io/docs/caching) to avoid long compilation times.* +Run `bin/run`. This runs the Ex04_Multi example.++To run the docs, run `nix run github:EmaApps/emanote -- -L ./docs`.+ ## Getting Started  https://ema.srid.ca/start++## Discussion++To discuss the Ema project, [join Matrix][matrix] or post in [GitHub Discussions][ghdiscuss].++[matrix]: https://matrix.to/#/#ema:matrix.org+[ghdiscuss]: https://github.com/EmaApps/ema/discussions
ema.cabal view
@@ -1,6 +1,6 @@ cabal-version:      2.4 name:               ema-version:            0.6.0.0+version:            0.8.0.0 license:            AGPL-3.0-only copyright:          2021 Sridhar Ratnakumar maintainer:         srid@srid.ca@@ -20,11 +20,67 @@   LICENSE   README.md +data-dir:           www+data-files:+  ema-error.html+  ema-indicator.html+  ema-shim.js++-- This flag is enabled by default just so `bin/run` can work on macOS M1.+-- When disabling, ensure that macOS build doesn't break. flag with-examples   description: Include examples and their dependencies-  default:     False+  default:     True +flag with-extra+  description: Include non-core functionality+  default:     True++common extensions+  default-extensions:+    NoStarIsType+    BangPatterns+    ConstraintKinds+    DataKinds+    DeriveDataTypeable+    DeriveFoldable+    DeriveFunctor+    DeriveGeneric+    DeriveLift+    DeriveTraversable+    DerivingStrategies+    DerivingVia+    EmptyCase+    EmptyDataDecls+    EmptyDataDeriving+    ExistentialQuantification+    ExplicitForAll+    FlexibleContexts+    FlexibleInstances+    GADTSyntax+    GeneralisedNewtypeDeriving+    ImportQualifiedPost+    KindSignatures+    LambdaCase+    MultiParamTypeClasses+    MultiWayIf+    NumericUnderscores+    OverloadedStrings+    PolyKinds+    PostfixOperators+    RankNTypes+    ScopedTypeVariables+    StandaloneDeriving+    StandaloneKindSignatures+    TupleSections+    TypeApplications+    TypeFamilies+    TypeOperators+    ViewPatterns+ library+  import:           extensions+   -- Modules included in this executable, other than Main.   -- other-modules: @@ -40,15 +96,22 @@     , dependent-sum     , dependent-sum-template     , directory+    , file-embed     , filepath     , filepattern+    , generic-optics+    , generics-sop     , http-types     , lvar     , monad-logger     , monad-logger-extras+    , mtl     , neat-interpolation+    , optics-core     , optparse-applicative-    , relude                  >=0.7      && <1.0+    , relude                  >=1.0+    , sop-core+    , template-haskell     , text     , unliftio     , url-slug@@ -62,8 +125,16 @@     build-depends:       , blaze-html       , blaze-markup+      , fsnotify       , time +  if flag(with-extra)+    build-depends:+      , pandoc+      , pandoc-types+      , time+      , unionmount+   mixins:     base hiding (Prelude),     relude (Relude as Prelude, Relude.Container.One),@@ -73,69 +144,76 @@     -Wall -Wincomplete-record-updates -Wincomplete-uni-patterns     -Wmissing-deriving-strategies -Wunused-foralls -Wunused-foralls     -fprint-explicit-foralls -fprint-explicit-kinds--  default-extensions:-    NoStarIsType-    BangPatterns-    ConstraintKinds-    DataKinds-    DeriveDataTypeable-    DeriveFoldable-    DeriveFunctor-    DeriveGeneric-    DeriveLift-    DeriveTraversable-    DerivingStrategies-    DerivingVia-    EmptyCase-    EmptyDataDecls-    EmptyDataDeriving-    ExistentialQuantification-    ExplicitForAll-    FlexibleContexts-    FlexibleInstances-    GADTSyntax-    GeneralisedNewtypeDeriving-    ImportQualifiedPost-    KindSignatures-    LambdaCase-    MultiParamTypeClasses-    MultiWayIf-    NumericUnderscores-    OverloadedStrings-    PolyKinds-    PostfixOperators-    RankNTypes-    ScopedTypeVariables-    StandaloneDeriving-    StandaloneKindSignatures-    TupleSections-    TypeApplications-    TypeFamilies-    TypeOperators-    ViewPatterns+    -fprint-potential-instances    exposed-modules:     Ema-    Ema.CLI--  other-modules:     Ema.App     Ema.Asset-    Ema.Class+    Ema.CLI+    Ema.Dynamic     Ema.Generate-    Ema.Route+    Ema.Route.Class+    Ema.Route.Generic+    Ema.Route.Generic.RGeneric+    Ema.Route.Generic.SubModel+    Ema.Route.Generic.SubRoute+    Ema.Route.Generic.TH+    Ema.Route.Generic.Verification+    Ema.Route.Lib.File+    Ema.Route.Lib.Folder+    Ema.Route.Lib.Multi+    Ema.Route.Prism+    Ema.Route.Prism.Check+    Ema.Route.Prism.Type+    Ema.Route.Url     Ema.Server+    Ema.Site -  if flag(with-examples)+  other-modules:    GHC.TypeLits.Extra++  if flag(with-extra)+    exposed-modules:+      Ema.Route.Lib.Extra.PandocRoute+      Ema.Route.Lib.Extra.SlugRoute+      Ema.Route.Lib.Extra.StaticRoute++  if impl(ghc >=9.2)+    other-modules: GHC.TypeLits.Extra.Symbol++  if (flag(with-examples) && impl(ghc >=9.2))     other-modules:       Ema.Example.Common-      Ema.Example.Ex01_HelloWorld-      Ema.Example.Ex02_Basic-      Ema.Example.Ex03_Clock+      Ema.Example.Ex00_Hello+      Ema.Example.Ex01_Basic+      Ema.Example.Ex02_Clock+      Ema.Example.Ex03_Store+      Ema.Example.Ex04_Multi+      Ema.Example.Ex05_MultiRoute+      Ema.Route.Generic.Example -  hs-source-dirs:     src-  default-language:   Haskell2010+  hs-source-dirs:   src+  default-language: Haskell2010++  if impl(ghc >=8.10)+    ghc-options: -Wunused-packages++test-suite test-type-errors+  import:           extensions+  build-depends:+    , base+    , ema+    , generics-sop+    , raw-strings-qq+    , template-haskell+    , text+    , url-slug++  other-modules:    Deriving+  type:             exitcode-stdio-1.0+  main-is:          Spec.hs+  hs-source-dirs:   test/type-errors+  default-language: Haskell2010    if impl(ghc >=8.10)     ghc-options: -Wunused-packages
src/Ema.hs view
@@ -1,12 +1,18 @@-module Ema-  ( module X,-  )-where+module Ema (+  module X,+) where  import Ema.App as X import Ema.Asset as X-import Ema.Class as X-import Ema.Route as X-import Ema.Server as X-  ( emaErrorHtmlResponse,-  )+import Ema.Dynamic as X+import Ema.Route.Class as X+import Ema.Route.Prism as X (fromPrism_, toPrism_)+import Ema.Route.Url as X (+  UrlStrategy (UrlDirect, UrlPretty),+  routeUrl,+  routeUrlWith,+ )+import Ema.Server as X (+  emaErrorHtmlResponse,+ )+import Ema.Site as X
src/Ema/App.hs view
@@ -1,130 +1,93 @@-module Ema.App-  ( runEma,-    runEmaPure,-    runEmaWithCli,-  )-where+{-# LANGUAGE AllowAmbiguousTypes #-} +module Ema.App (+  runSite,+  runSite_,+  runSiteWithCli,+) where+ import Control.Concurrent (threadDelay)-import Control.Concurrent.Async (race)-import Control.Monad.Logger (MonadLoggerIO, logInfoN)-import Control.Monad.Logger.Extras-  ( colorize,-    logToStdout,-    runLoggerLoggingT,-  )+import Control.Concurrent.Async (race_)+import Control.Monad.Logger (LoggingT (runLoggingT), MonadLoggerIO (askLoggerIO), logInfoNS, logWarnNS)+import Control.Monad.Logger.Extras (runLoggerLoggingT) import Data.Dependent.Sum (DSum ((:=>)))-import Data.LVar (LVar) import Data.LVar qualified as LVar-import Data.Some-import Ema.Asset (Asset (AssetGenerated), Format (Html))-import Ema.CLI (Cli)+import Data.Some (Some (Some))+import Ema.CLI (getLogger) import Ema.CLI qualified as CLI-import Ema.Class (Ema)-import Ema.Generate qualified as Generate+import Ema.Dynamic (Dynamic (Dynamic))+import Ema.Generate (generateSiteFromModel)+import Ema.Route.Class (IsRoute (RouteModel)) import Ema.Server qualified as Server+import Ema.Site (EmaSite (SiteArg, siteInput), EmaStaticSite) import System.Directory (getCurrentDirectory)-import UnliftIO-  ( BufferMode (BlockBuffering, LineBuffering),-    MonadUnliftIO,-    hFlush,-    hSetBuffering,-  ) --- | Pure version of @runEmaWith@ (i.e with no model).------ Due to purity, there is no impure state, and thus no time-varying model.--- Neither is there a concept of route, as only a single route (index.html) is--- expected, whose HTML contents is specified as the only argument to this--- function.-runEmaPure ::-  -- | How to render a route-  (Some CLI.Action -> LByteString) ->-  IO ()-runEmaPure render = do-  void $-    runEma (\act () () -> AssetGenerated Html $ render act) $ \act model -> do-      LVar.set model ()-      when (CLI.isLiveServer act) $-        liftIO $ threadDelay maxBound+{- | Run the given Ema site, --- | Convenient version of @runEmaWith@ that takes initial model and an update--- function. You typically want to use this.------ It uses @race_@ to properly clean up the update action when the ema thread--- exits, and vice-versa.-runEma ::-  forall model route b.-  (Ema model route, Show route) =>-  -- | How to render a route, given the model-  (Some CLI.Action -> model -> route -> Asset LByteString) ->-  -- | A long-running IO action that will update the @model@ @LVar@ over time.-  -- This IO action must set the initial model value in the very beginning.-  (forall m. (MonadIO m, MonadUnliftIO m, MonadLoggerIO m) => Some CLI.Action -> LVar model -> m b) ->-  IO (Either b (DSum CLI.Action Identity))-runEma render runModel = do+  Takes as argument the associated `SiteArg`.++  In generate mode, return the generated files.  In live-server mode, this+  function will never return.+-}+runSite ::+  forall r.+  (Show r, Eq r, EmaStaticSite r) =>+  -- | The input required to create the `Dynamic` of the `RouteModel`+  SiteArg r ->+  IO [FilePath]+runSite input = do   cli <- CLI.cliAction-  runEmaWithCli cli render runModel+  result <- snd <$> runSiteWithCli @r cli input+  case result of+    CLI.Run _ :=> Identity () ->+      flip runLoggerLoggingT (getLogger cli) $+        CLI.crash "ema" "Live server unexpectedly stopped"+    CLI.Generate _ :=> Identity fs ->+      pure fs --- | Like @runEma@ but takes the CLI action------ Useful if you are handling CLI arguments yourself.-runEmaWithCli ::-  forall model route b.-  (Ema model route, Show route) =>-  Cli ->-  -- | How to render a route, given the model-  (Some CLI.Action -> model -> route -> Asset LByteString) ->-  -- | A long-running IO action that will update the @model@ @LVar@ over time.-  -- This IO action must set the initial model value in the very beginning.-  (forall m. (MonadIO m, MonadUnliftIO m, MonadLoggerIO m) => Some CLI.Action -> LVar model -> m b) ->-  IO (Either b (DSum CLI.Action Identity))-runEmaWithCli cli render runModel = do-  model <- LVar.empty-  -- TODO: Allow library users to control logging levels, or colors.-  let logger = colorize logToStdout-  flip runLoggerLoggingT logger $ do-    cwd <- liftIO getCurrentDirectory-    logInfoN $ "Launching Ema under: " <> toText cwd-    logInfoN "Waiting for initial model ..."-  race-    (flip runLoggerLoggingT logger $ runModel (CLI.action cli) model)-    (flip runLoggerLoggingT logger $ runEmaWithCliInCwd (CLI.action cli) model render)+-- | Like @runSite@ but discards the result+runSite_ :: forall r. (Show r, Eq r, EmaStaticSite r) => SiteArg r -> IO ()+runSite_ = void . runSite @r --- | Run Ema live dev server-runEmaWithCliInCwd ::-  forall model route m.-  (MonadIO m, MonadUnliftIO m, MonadLoggerIO m, Ema model route, Show route) =>-  -- | CLI arguments-  Some CLI.Action ->-  -- | Your site model type, as a @LVar@ in order to support modifications over-  -- time (for hot-reload).-  ---  -- Use @Data.LVar.new@ to create it, and then -- over time -- @Data.LVar.set@-  -- or @Data.LVar.modify@ to modify it. Ema will automatically hot-reload your-  -- site as this model data changes.-  LVar model ->-  -- | Your site render function. Takes the current @model@ value, and the page-  -- @route@ type as arguments. It must return the raw HTML to render to browser-  -- or generate on disk.-  (Some CLI.Action -> model -> route -> Asset LByteString) ->-  m (DSum CLI.Action Identity)-runEmaWithCliInCwd cliAction model render = do-  val <- LVar.get model-  logInfoN "... initial model is now available."-  case cliAction of-    Some (CLI.Generate dest) -> do-      fs <--        withBlockBuffering $-          Generate.generate dest val (render cliAction)-      pure $ CLI.Generate dest :=> Identity fs-    Some (CLI.Run (host, port)) -> do-      Server.runServerWithWebSocketHotReload host port model (render cliAction)-      pure $ CLI.Run (host, port) :=> Identity ()-  where-    -- Temporarily use block buffering before calling an IO action that is-    -- known ahead to log rapidly, so as to not hamper serial processing speed.-    withBlockBuffering f =-      hSetBuffering stdout (BlockBuffering Nothing)-        *> f-        <* (hSetBuffering stdout LineBuffering >> hFlush stdout)+{- | Like @runSite@ but takes the CLI action. Also returns more information.++ Useful if you are handling the CLI arguments yourself.++ Use "void $ Ema.runSiteWithCli def ..." if you are running live-server only.+-}+runSiteWithCli ::+  forall r.+  (Show r, Eq r, EmaStaticSite r) =>+  CLI.Cli ->+  SiteArg r ->+  IO+    ( -- The initial model value.+      RouteModel r+    , DSum CLI.Action Identity+    )+runSiteWithCli cli siteArg = do+  flip runLoggerLoggingT (getLogger cli) $ do+    cwd <- liftIO getCurrentDirectory+    logInfoNS "ema" $ "Launching Ema under: " <> toText cwd+    Dynamic (model0 :: RouteModel r, cont) <- siteInput @r (CLI.action cli) siteArg+    case CLI.action cli of+      Some act@(CLI.Generate dest) -> do+        fs <- generateSiteFromModel @r dest model0+        pure (model0, act :=> Identity fs)+      Some act@(CLI.Run (host, mport)) -> do+        model <- LVar.empty+        LVar.set model model0+        logger <- askLoggerIO+        liftIO $+          race_+            ( flip runLoggingT logger $ do+                cont $ LVar.set model+                logWarnNS "ema" "modelPatcher exited; no more model updates!"+                -- We want to keep this thread alive, so that the server thread+                -- doesn't exit.+                liftIO $ threadDelay maxBound+            )+            ( flip runLoggingT logger $ do+                Server.runServerWithWebSocketHotReload @r host mport model+            )+        pure (model0, act :=> Identity ())
src/Ema/Asset.hs view
@@ -1,5 +1,10 @@-module Ema.Asset where+{-# LANGUAGE UndecidableInstances #-} +module Ema.Asset (+  Asset (..),+  Format (..),+) where+ -- | The type of assets that can be bundled in a static site. data Asset a   = -- | A file that is copied as-is from the source directory.@@ -9,7 +14,12 @@     AssetStatic FilePath   | -- | A file whose contents are generated at runtime by user code.     AssetGenerated Format a-  deriving stock (Eq, Show, Ord, Generic)+  deriving stock (Eq, Show, Ord, Functor, Generic) -data Format = Html | Other+-- | The format of a generated asset.+data Format+  = -- | Html assets are served by the live server with hot-reload+    Html+  | -- | Other assets are served by the live server as static files.+    Other   deriving stock (Eq, Show, Ord, Generic)
src/Ema/CLI.hs view
@@ -4,21 +4,37 @@  module Ema.CLI where +import Control.Monad.Logger (LogLevel (LevelDebug, LevelInfo), LogSource, MonadLoggerIO, logErrorNS)+import Control.Monad.Logger.Extras (+  Logger (Logger),+  colorize,+  logToStdout,+ ) import Data.Constraint.Extras.TH (deriveArgDict) import Data.Default (Default (def))-import Data.GADT.Compare.TH-  ( DeriveGCompare (deriveGCompare),-    DeriveGEQ (deriveGEq),-  )+import Data.GADT.Compare.TH (+  DeriveGCompare (deriveGCompare),+  DeriveGEQ (deriveGEq),+ ) import Data.GADT.Show.TH (DeriveGShow (deriveGShow)) import Data.Some (Some (..))-import Ema.Server (Host, Port)+import Network.Wai.Handler.Warp (Port) import Options.Applicative hiding (action) +-- | Host string to start the server on.+newtype Host = Host {unHost :: Text}+  deriving newtype (Eq, Show, Ord, IsString)++instance Default Host where+  def = "127.0.0.1"+ -- | CLI subcommand-data Action res where+data Action result where+  -- | Generate static files at the given output directory, returning the list+  -- of generated files.   Generate :: FilePath -> Action [FilePath]-  Run :: (Host, Port) -> Action ()+  -- | Run the live server+  Run :: (Host, Maybe Port) -> Action ()  $(deriveGEq ''Action) $(deriveGShow ''Action)@@ -29,30 +45,45 @@ isLiveServer (Some (Run _)) = True isLiveServer _ = False +-- | Ema's command-line interface options data Cli = Cli-  { action :: (Some Action)+  { action :: Some Action+  -- ^ The Ema action to run+  , verbose :: Bool+  -- ^ Logging verbosity   }   deriving stock (Eq, Show) +instance Default Cli where+  -- By default, run the live server on random port.+  def = Cli (Some (Run def)) False+ cliParser :: Parser Cli cliParser = do   action <-     subparser-      (command "gen" (info generate (progDesc "Generate static HTML files")))+      (command "gen" (info generate (progDesc "Generate static site")))       <|> subparser (command "run" (info run (progDesc "Run the live server")))       <|> pure (Some $ Run def)+  verbose <- switch (long "verbose" <> short 'v' <> help "Enable verbose logging")   pure Cli {..}   where     run :: Parser (Some Action)     run =-      Some . Run-        <$> ( (,) <$> strOption (long "host" <> short 'h' <> metavar "HOST" <> help "Host to bind to" <> value def)-                <*> option auto (long "port" <> short 'p' <> metavar "PORT" <> help "Port to bind to" <> value def)-            )+      fmap (Some . Run) $ (,) <$> hostParser <*> optional portParser     generate :: Parser (Some Action)     generate =-      Some . Generate <$> argument str (metavar "DEST...")+      Some . Generate <$> argument str (metavar "DEST") +hostParser :: Parser Host+hostParser =+  strOption (long "host" <> short 'h' <> metavar "HOST" <> help "Host to bind to" <> value def)++portParser :: Parser Port+portParser =+  option auto (long "port" <> short 'p' <> metavar "PORT" <> help "Port to bind to")++-- | Parse Ema CLI arguments passed by the user. cliAction :: IO Cli cliAction = do   execParser opts@@ -64,3 +95,24 @@             <> progDesc "Ema - static site generator"             <> header "Ema"         )++getLogger :: Cli -> Logger+getLogger cli =+  logToStdout+    & colorize+    & allowLogLevelFrom (bool LevelInfo LevelDebug $ verbose cli)+  where+    allowLogLevelFrom :: LogLevel -> Logger -> Logger+    allowLogLevelFrom minLevel (Logger f) = Logger $ \loc src level msg ->+      if level >= minLevel+        then f loc src level msg+        else pass++{- | Crash the program with the given error message++ First log the message using Error level, and then exit using `fail`.+-}+crash :: (MonadLoggerIO m, MonadFail m) => LogSource -> Text -> m a+crash source msg = do+  logErrorNS source msg+  fail $ toString msg
− src/Ema/Class.hs
@@ -1,29 +0,0 @@-{-# LANGUAGE DefaultSignatures #-}-{-# LANGUAGE FunctionalDependencies #-}--module Ema.Class where---- | Enrich a model to work with Ema-class Ema model route | route -> model where-  -- | Get the filepath on disk corresponding to this route.-  encodeRoute :: model -> route -> FilePath--  -- | Decode a filepath on disk into a route.-  decodeRoute :: model -> FilePath -> Maybe route--  -- | All routes in the site-  ---  -- The `gen` command will generate only these routes. On live server, this-  -- function is never used.-  allRoutes :: model -> [route]-  default allRoutes :: (Bounded route, Enum route) => model -> [route]-  allRoutes _ = [minBound .. maxBound]---- | The unit model is useful when using Ema in pure fashion (see--- @Ema.runEmaPure@) with a single route (index.html) only.-instance Ema () () where-  encodeRoute () () = []-  decodeRoute () = \case-    [] -> Just ()-    _ -> Nothing-  allRoutes () = one ()
+ src/Ema/Dynamic.hs view
@@ -0,0 +1,62 @@+module Ema.Dynamic (+  Dynamic (Dynamic),+) where++import Control.Monad.Logger (MonadLogger, logDebugNS)+import UnliftIO (MonadUnliftIO, race_)+import UnliftIO.Concurrent (threadDelay)++{- | A time-varying value of type `a`, changing under monad `m`.++  To create a `Dynamic`, supply the initial value along with a function that+  forever updates it using the given monadic update function.++ `Dynamic`'s can be composed using `Applicative`.+-}+newtype Dynamic m a+  = Dynamic+      ( -- Initial value+        a+      , -- Set a new value+        (a -> m ()) -> m ()+      )++instance Functor (Dynamic m) where+  fmap f (Dynamic (x0, xf)) =+    Dynamic+      ( f x0+      , \send -> xf $ send . f+      )++instance (MonadUnliftIO m, MonadLogger m) => Applicative (Dynamic m) where+  pure x = Dynamic (x, const pass)+  liftA2 f (Dynamic (x0, xf)) (Dynamic (y0, yf)) =+    Dynamic+      ( f x0 y0+      , \send -> do+          var <- newTVarIO (x0, y0)+          sendLock :: TMVar () <- newEmptyTMVarIO+          race_+            ( do+                xf $ \x -> do+                  atomically $ putTMVar sendLock ()+                  logDebugNS "ema.dyn.app" "left update"+                  send <=< atomically $ do+                    modifyTVar' var $ first (const x)+                    f x . snd <$> readTVar var+                  atomically $ takeTMVar sendLock+                logDebugNS "ema.dyn.app" "updater exited; keeping thread alive"+                threadDelay maxBound+            )+            ( do+                yf $ \y -> do+                  atomically $ putTMVar sendLock ()+                  logDebugNS "ema.dyn.app" "right update"+                  send <=< atomically $ do+                    modifyTVar' var $ second (const y)+                    (`f` y) . fst <$> readTVar var+                  atomically $ takeTMVar sendLock+                logDebugNS "ema.dyn.app" "updater exited; keeping thread alive"+                threadDelay maxBound+            )+      )
src/Ema/Example/Common.hs view
@@ -1,8 +1,10 @@-module Ema.Example.Common-  ( tailwindLayout,-  )-where+module Ema.Example.Common (+  tailwindLayout,+  watchDirForked,+) where +import Control.Concurrent (Chan, forkIO, newChan, threadDelay)+import System.FSNotify qualified as FSNotify import Text.Blaze.Html.Renderer.Utf8 qualified as RU import Text.Blaze.Html5 ((!)) import Text.Blaze.Html5 qualified as H@@ -35,3 +37,14 @@         ! A.href "https://unpkg.com/tailwindcss@2/dist/tailwind.min.css"         ! A.rel "stylesheet"         ! A.type_ "text/css"++-- Observe changes to a directory path, and return the `Chan` of its events.+watchDirForked :: FilePath -> IO (Chan FSNotify.Event)+watchDirForked path = do+  ch <- newChan+  -- FIXME: We should be using race_, not forkIO.+  void . forkIO $+    FSNotify.withManager $ \mgr -> do+      _stopListening <- FSNotify.watchDirChan mgr path (const True) ch+      threadDelay maxBound+  pure ch
+ src/Ema/Example/Ex00_Hello.hs view
@@ -0,0 +1,21 @@+{-# LANGUAGE DeriveAnyClass #-}+{-# LANGUAGE UndecidableInstances #-}++-- | Most trivial Ema program+module Ema.Example.Ex00_Hello where++import Ema++-- Let's newtype the unit route, because we have only one page to generate.+newtype Route = Route ()+  deriving newtype+    (Show, Eq, Ord, Generic, IsRoute)++instance EmaSite Route where+  siteInput _ _ =+    pure $ pure ()+  siteOutput _ _ _ =+    pure $ Ema.AssetGenerated Ema.Html "<b>Hello</b>, Ema"++main :: IO ()+main = void $ Ema.runSite @Route ()
+ src/Ema/Example/Ex01_Basic.hs view
@@ -0,0 +1,44 @@+{-# LANGUAGE DeriveAnyClass #-}+{-# LANGUAGE TemplateHaskell #-}+{-# LANGUAGE UndecidableInstances #-}++-- | A very simple site in three parts: route types, `main` and rendering implementation.+module Ema.Example.Ex01_Basic where++import Ema+import Ema.Example.Common (tailwindLayout)+import Ema.Route.Generic.TH+import Text.Blaze.Html5 ((!))+import Text.Blaze.Html5 qualified as H+import Text.Blaze.Html5.Attributes qualified as A++data Route+  = Route_Index+  | Route_About+  deriving stock (Show, Eq, Ord, Generic)++deriveGeneric ''Route+deriveIsRoute ''Route [t|'[]|]++instance EmaSite Route where+  siteInput _ _ = pure $ pure ()+  siteOutput rp () r =+    pure . Ema.AssetGenerated Ema.Html $+      tailwindLayout (H.title "Basic site" >> H.base ! A.href "/") $+        H.div ! A.class_ "container mx-auto mt-8 p-2" $ do+          H.h1 ! A.class_ "text-3xl font-bold" $ "Basic site"+          case r of+            Route_Index -> do+              "You are on the index page. "+              routeElem Route_About "Go to About"+            Route_About -> do+              routeElem Route_Index "Go to Index"+              ". You are on the about page. "+    where+      routeElem r' w = do+        H.a ! A.class_ "text-red-500 hover:underline" ! routeHref r' $ w+      routeHref r' =+        A.href (fromString . toString $ Ema.routeUrl rp r')++main :: IO ()+main = void $ Ema.runSite @Route ()
− src/Ema/Example/Ex01_HelloWorld.hs
@@ -1,13 +0,0 @@--- | The simplest Ema site possible.------ A site with one route (index) that displays content generated from pure--- values.-module Ema.Example.Ex01_HelloWorld where--import Ema (runEmaPure)--main :: IO ()-main = do-  let speaker :: Text = "Ema"-  runEmaPure $ \_ ->-    encodeUtf8 $ "<b>Hello</b>, from " <> speaker
− src/Ema/Example/Ex02_Basic.hs
@@ -1,56 +0,0 @@--- | A very simple site with two routes, and HTML rendered using Blaze DSL-module Ema.Example.Ex02_Basic where--import Control.Concurrent (threadDelay)-import Data.LVar qualified as LVar-import Ema (Ema (..))-import Ema qualified-import Ema.CLI qualified as CLI-import Ema.Example.Common (tailwindLayout)-import Text.Blaze.Html5 ((!))-import Text.Blaze.Html5 qualified as H-import Text.Blaze.Html5.Attributes qualified as A--data Route-  = Index-  | About-  deriving stock (Show, Enum, Bounded)--newtype Model = Model {unModel :: Text}--instance Ema Model Route where-  encodeRoute _model =-    \case-      Index -> "index.html"-      About -> "about.html"-  decodeRoute _model = \case-    "index.html" -> Just Index-    "about.html" -> Just About-    _ -> Nothing--main :: IO ()-main = do-  void $-    Ema.runEma (\_act m -> Ema.AssetGenerated Ema.Html . render m) $ \act model -> do-      LVar.set model $ Model "Hello World. "-      when (CLI.isLiveServer act) $-        liftIO $ threadDelay maxBound--render :: Model -> Route -> LByteString-render model r =-  tailwindLayout (H.title "Basic site" >> H.base ! A.href "/") $-    H.div ! A.class_ "container mx-auto" $ do-      H.div ! A.class_ "mt-8 p-2 text-center" $ do-        case r of-          Index -> do-            H.toHtml (unModel model)-            "You are on the index page. "-            routeElem About "Go to About"-          About -> do-            "You are on the about page. "-            routeElem Index "Go to Index"-  where-    routeElem r' w =-      H.a ! A.class_ "text-red-500 hover:underline" ! routeHref r' $ w-    routeHref r' =-      A.href (fromString . toString $ Ema.routeUrl model r')
+ src/Ema/Example/Ex02_Clock.hs view
@@ -0,0 +1,89 @@+{-# LANGUAGE DeriveAnyClass #-}+{-# LANGUAGE TemplateHaskell #-}+{-# LANGUAGE UndecidableInstances #-}++{- | A very simple site with routes, but based on dynamically changing values++ The current time is computed in the server every second, and the resultant+ generated HTML is automatically updated on the browser. This is only a demo;+ usually we render HTML based on files on disk or something accessible outside+ of the browser. More advanced examples will demonstrate that.+-}+module Ema.Example.Ex02_Clock where++import Control.Concurrent (threadDelay)+import Control.Monad.Logger (logDebugNS, logInfoNS)+import Data.List ((!!))+import Data.Time (UTCTime, defaultTimeLocale, formatTime, getCurrentTime)+import Ema+import Ema.Example.Common (tailwindLayout)+import Ema.Route.Generic.TH+import Optics.Core (Prism')+import Text.Blaze.Html5 ((!))+import Text.Blaze.Html5 qualified as H+import Text.Blaze.Html5.Attributes qualified as A++type Model = UTCTime++data Route+  = Route_Index+  | Route_OnlyTime+  deriving stock (Show, Eq, Ord, Generic)++deriveGeneric ''Route+deriveIsRoute ''Route [t|'[WithModel Model]|]++instance EmaSite Route where+  type SiteArg Route = Int -- Delay between clock refresh+  siteInput _ timerDelay = do+    t0 <- liftIO getCurrentTime+    pure . Dynamic . (t0,) $ \setModel -> do+      logInfoNS "Ex02" "Starting clock..."+      forever $ do+        liftIO $ threadDelay timerDelay+        t <- liftIO getCurrentTime+        logDebugNS "Ex02" "Updating clock..."+        setModel t+  siteOutput rp m r =+    pure $ Ema.AssetGenerated Ema.Html $ render rp m r++main :: IO ()+main = do+  void $ Ema.runSite @Route delayNormal++delayNormal :: Int+delayNormal = 1000000 -- 1 second++delayFast :: Int+delayFast = 10000++render :: Prism' FilePath Route -> UTCTime -> Route -> LByteString+render rp now r =+  tailwindLayout (H.title "Clock" >> H.base ! A.href "/") $+    H.div ! A.class_ "container mx-auto" $ do+      H.div ! A.class_ "mt-8 p-2 text-center" $ do+        case r of+          Route_Index ->+            "The current date & time is: "+          Route_OnlyTime ->+            "The current time is: "+        H.pre ! A.class_ "text-6xl font-bold mt-2" $ do+          H.span ! A.class_ ("text-" <> randomColor now <> "-500") $ do+            let fmt = case r of+                  Route_Index -> "%Y/%m/%d %H:%M:%S%Q"+                  Route_OnlyTime -> "%H:%M:%S"+            H.toMarkup $ formatTime defaultTimeLocale fmt now+      H.div ! A.class_ "mt-4 text-center" $ do+        case r of+          Route_Index -> do+            routeElem Route_OnlyTime "Hide day?"+          Route_OnlyTime -> do+            routeElem Route_Index "Show day?"+  where+    routeElem r' = H.a ! A.class_ "text-xl text-purple-500 hover:underline" ! routeHref r'+    routeHref r' =+      A.href (fromString . toString $ Ema.routeUrl rp r')+    randomColor t =+      let epochSecs = fromMaybe 0 . readMaybe @Int $ formatTime defaultTimeLocale "%s" t+          colors = ["green", "gray", "purple", "red", "blue", "yellow", "black", "pink"]+       in colors !! mod epochSecs (length colors)
− src/Ema/Example/Ex03_Clock.hs
@@ -1,75 +0,0 @@-{-# OPTIONS_GHC -fno-warn-orphans #-}---- | A very simple site with routes, but based on dynamically changing values------ The current time is computed in the server every second, and the resultant--- generated HTML is automatically updated on the browser. This is only a demo;--- usually we render HTML based on files on disk or something accessible outside--- of the browser. More advanced examples will demonstrate that.-module Ema.Example.Ex03_Clock where--import Control.Concurrent (threadDelay)-import Data.LVar qualified as LVar-import Data.List ((!!))-import Data.Time (UTCTime, defaultTimeLocale, formatTime, getCurrentTime)-import Ema (Ema (..))-import Ema qualified-import Ema.Example.Common (tailwindLayout)-import Text.Blaze.Html5 ((!))-import Text.Blaze.Html5 qualified as H-import Text.Blaze.Html5.Attributes qualified as A--data Route-  = Index-  | OnlyTime-  deriving stock (Show, Enum, Bounded)--instance Ema UTCTime Route where-  encodeRoute _time = \case-    Index -> "index.html"-    OnlyTime -> "time.html"-  decodeRoute _time = \case-    "index.html" -> Just Index-    "time.html" -> Just OnlyTime-    _ -> Nothing--main :: IO ()-main = do-  void $-    Ema.runEma (\_act m -> Ema.AssetGenerated Ema.Html . render m) $ \_act model ->-      forever $ do-        -- logDebugNS "ex:clock" "Refreshing time"-        LVar.set model =<< liftIO getCurrentTime-        liftIO $ threadDelay 1000000--render :: UTCTime -> Route -> LByteString-render now r =-  tailwindLayout (H.title "Clock" >> H.base ! A.href "/") $-    H.div ! A.class_ "container mx-auto" $ do-      H.div ! A.class_ "mt-8 p-2 text-center" $ do-        case r of-          Index ->-            "The current date & time is: "-          OnlyTime ->-            "The current time is: "-        H.pre ! A.class_ "text-6xl font-bold mt-2" $ do-          H.span ! A.class_ ("text-" <> randomColor now <> "-500") $ do-            let fmt = case r of-                  Index -> "%Y/%m/%d %H:%M:%S"-                  OnlyTime -> "%H:%M:%S"-            H.toMarkup $ formatTime defaultTimeLocale fmt now-      H.div ! A.class_ "mt-4 text-center" $ do-        case r of-          Index -> do-            routeElem OnlyTime "Hide day?"-          OnlyTime -> do-            routeElem Index "Show day?"-  where-    routeElem r' w =-      H.a ! A.class_ "text-xl text-purple-500 hover:underline" ! routeHref r' $ w-    routeHref r' =-      A.href (fromString . toString $ Ema.routeUrl now r')-    randomColor t =-      let epochSecs = fromMaybe 0 . readMaybe @Int $ formatTime defaultTimeLocale "%s" t-          colors = ["green", "gray", "purple", "red", "blue", "yellow", "black", "pink"]-       in colors !! mod epochSecs (length colors)
+ src/Ema/Example/Ex03_Store.hs view
@@ -0,0 +1,184 @@+{-# LANGUAGE BlockArguments #-}+{-# LANGUAGE DeriveAnyClass #-}+{-# LANGUAGE TemplateHaskell #-}+{-# LANGUAGE UndecidableInstances #-}++{- | A simple web store for products++ Also demostrates both DerivingVia and TH based deriving of IsRoute.+-}+module Ema.Example.Ex03_Store where++import Control.Concurrent (readChan)+import Control.Exception (throwIO)+import Control.Monad.Logger (MonadLogger, logInfoNS)+import Data.Aeson (FromJSON, FromJSONKey, eitherDecodeFileStrict')+import Data.Map.Strict qualified as Map+import Ema+import Ema.Example.Common (tailwindLayout, watchDirForked)+import Ema.Route.Generic+import Ema.Route.Generic.TH (deriveGeneric, deriveIsRoute)+import Ema.Route.Prism+import Generics.SOP qualified as SOP+import Optics.Core (coercedTo, iso, prism', (%))+import System.FSNotify qualified as FSNotify+import System.FilePath (takeFileName, (</>))+import Text.Blaze.Html5 ((!))+import Text.Blaze.Html5 qualified as H+import Text.Blaze.Html5.Attributes qualified as A+import Prelude hiding (Product)++data Model = Model+  { modelStoreName :: Text+  , modelProducts :: Map Slug Product+  , modelCategories :: Map Slug Category+  }+  deriving stock (Generic)+  deriving anyclass (FromJSON)++newtype Slug = Slug Text+  deriving newtype (Show, Eq, Ord, IsString, ToString, FromJSON, FromJSONKey)+  deriving stock (Generic)++newtype Product = Product {unProduct :: Text}+  deriving newtype (Show, Eq, Ord, IsString, ToString, FromJSON)++newtype Category = Category {unCategory :: Text}+  deriving newtype (Show, Eq, Ord, IsString, ToString, FromJSON)++data Route+  = Route_Index+  | Route_About+  | Route_Products ProductRoute+  | Route_Category CategoryRoute+  deriving stock (Show, Eq, Ord, Generic)++data ProductRoute+  = ProductRoute_Index+  | ProductRoute_Product Slug+  deriving stock (Show, Eq, Ord, Generic)++data CategoryRoute+  = CategoryRoute_Index+  | CategoryRoute_Category Slug+  deriving stock (Show, Eq, Ord, Generic)+  -- We can also do this using TemplateHaskell (see ProductRoute deriving below)+  deriving anyclass (SOP.Generic, SOP.HasDatatypeInfo)+  deriving+    (HasSubRoutes, HasSubModels, IsRoute)+    via ( GenericRoute+            CategoryRoute+            '[ WithModel (Map Slug Category)+             , WithSubRoutes+                '[ FileRoute "index.html"+                 , StringRoute Category Slug+                 ]+             ]+        )++-- | A route represented by a stringy type; associated with a foldable of the same as its model.+newtype StringRoute (a :: Type) r = StringRoute {unStringRoute :: r}+  deriving stock (Show, Eq, Ord, Generic)+  deriving anyclass (SOP.Generic, SOP.HasDatatypeInfo)++instance (IsString r, ToString r, Eq r, Ord r) => IsRoute (StringRoute a r) where+  type RouteModel (StringRoute a r) = Map r a+  routePrism as =+    toPrism_ $+      htmlSuffixPrism+        % iso fromString toString+        % mapMemberPrism as+        % coercedTo+    where+      mapMemberPrism m =+        prism' id $ \r -> do pure r <* (guard $ r `Map.member` m)+  routeUniverse as = StringRoute <$> Map.keys as++deriveGeneric ''ProductRoute+deriveIsRoute+  ''ProductRoute+  [t|+    '[ WithModel (Map Slug Product)+     , WithSubRoutes+        '[ FileRoute "index.html"+         , StringRoute Product Slug+         ]+     ]+    |]++deriveGeneric ''Route+deriveIsRoute ''Route [t|'[WithModel Model]|]++main :: IO ()+main = void $ Ema.runSite @Route ()++instance EmaSite Route where+  siteInput _ () = do+    store0 <- readStoreFile+    pure . Dynamic . (store0,) $ \setModel -> do+      ch <- liftIO $ watchDirForked dataDir+      let loop = do+            log "Waiting for fs event ..."+            evt <- liftIO $ readChan ch+            log $ "Got fs event: " <> show evt+            when (takeFileName (FSNotify.eventPath evt) == "store.json") $ do+              setModel =<< readStoreFile+            loop+      loop+    where+      dataDir = "src/Ema/Example/Ex03_Store"+      readStoreFile :: (MonadIO m, MonadLogger m) => m Model+      readStoreFile = do+        log "Reading Store file"+        liftIO (eitherDecodeFileStrict' $ dataDir </> "store.json") >>= \case+          Left err -> liftIO $ throwIO $ StoreFileMalformed err+          Right store -> pure store+      log :: MonadLogger m => Text -> m ()+      log = logInfoNS "Ex03_Store"+  siteOutput rp (Model storeName ps cats) r =+    pure . Ema.AssetGenerated Ema.Html $+      tailwindLayout (H.title ("Store example: " <> H.toHtml storeName) >> H.base ! A.href "/") $+        H.div ! A.class_ "container mx-auto mt-8 p-2" $ do+          H.h1 ! A.class_ "text-3xl font-bold" $ H.toHtml storeName+          case r of+            Route_Index -> do+              "You are on the index page. "+              routeElem Route_About "Go to About"+              " or go to "+              routeElem (Route_Products ProductRoute_Index) "products"+              " or go to "+              routeElem (Route_Category CategoryRoute_Index) "categories"+            Route_About -> do+              routeElem Route_Index "Go to Index"+              ". You are on the about page. "+            Route_Products pr -> do+              H.h2 "Products"+              case pr of+                ProductRoute_Index -> do+                  H.p "List of products go here"+                  forM_ (Map.toList ps) $ \(k, Product p) -> do+                    H.li $ routeElem (Route_Products $ ProductRoute_Product k) $ H.toHtml p+                  routeElem Route_Index "Back to index"+                ProductRoute_Product name -> do+                  H.h3 ! A.class_ "p-2 border-2" $ show $ Map.lookup name ps+                  routeElem (Route_Products ProductRoute_Index) "Back to products"+            Route_Category cr -> do+              H.h2 "Categories"+              case cr of+                CategoryRoute_Index -> do+                  H.p "List of categories go here"+                  forM_ (Map.toList cats) $ \(k, Category c) -> do+                    H.li $ routeElem (Route_Category $ CategoryRoute_Category k) $ H.toHtml c+                  routeElem Route_Index "Back to index"+                CategoryRoute_Category name -> do+                  H.h3 ! A.class_ "p-2 border-2" $ show $ Map.lookup name cats+                  routeElem (Route_Category CategoryRoute_Index) "Back to categories"+    where+      routeElem r' w = do+        H.a ! A.class_ "text-red-500 hover:underline" ! routeHref r' $ w+      routeHref r' =+        A.href (fromString . toString $ Ema.routeUrl rp r')++newtype StoreFileError = StoreFileMalformed String+  deriving stock (Show, Eq)+  deriving anyclass (Exception)
+ src/Ema/Example/Ex04_Multi.hs view
@@ -0,0 +1,105 @@+{-# LANGUAGE DeriveAnyClass #-}+{-# LANGUAGE UndecidableInstances #-}++{- | Demonstration of merging multiple sites++  For an alternative (easier) approach, see `Ex05_MultiRoute.hs`.+-}+module Ema.Example.Ex04_Multi where++import Data.Generics.Sum.Any (AsAny (_As))+import Ema+import Ema.Example.Common (tailwindLayout)+import Ema.Example.Ex00_Hello qualified as Ex00+import Ema.Example.Ex01_Basic qualified as Ex01+import Ema.Example.Ex02_Clock qualified as Ex02+import Ema.Example.Ex03_Store qualified as Ex03+import Ema.Route.Generic+import GHC.Generics qualified as GHC+import Generics.SOP (Generic, HasDatatypeInfo, I (I), NP (Nil, (:*)))+import Optics.Core (Prism', (%))+import Text.Blaze.Html5 ((!))+import Text.Blaze.Html5 qualified as H+import Text.Blaze.Html5.Attributes qualified as A+import Prelude hiding (Generic)++data M = M+  { mClock :: Ex02.Model+  , mClockFast :: Ex02.Model+  , mStore :: Ex03.Model+  }+  deriving stock (GHC.Generic)++data R+  = R_Index+  | R_Hello Ex00.Route+  | R_Basic Ex01.Route+  | R_Clock Ex02.Route+  | R_ClockFast Ex02.Route+  | R_Store Ex03.Route+  deriving stock (Show, Ord, Eq, GHC.Generic)+  deriving anyclass (Generic, HasDatatypeInfo)+  deriving+    (HasSubRoutes, HasSubModels, IsRoute)+    via ( GenericRoute+            R+            '[ WithModel M+             , WithSubModels+                [ ()+                , ()+                , ()+                , -- You can refer to a record field by the field name+                  -- (We use `Proxy` only because heteregenous type+                  -- lists must be uni-kind).+                  Proxy "mClock"+                , Proxy "mClockFast"+                , -- Or by the field type.+                  -- Thanks to Data.Generics.Product.Any+                  Ex03.Model+                ]+             ]+        )++main :: IO ()+main = do+  void $ Ema.runSite @R ()++instance EmaSite R where+  siteInput cliAct () = do+    x1 :: Dynamic m Ex02.Model <- siteInput @Ex02.Route cliAct Ex02.delayNormal+    x2 :: Dynamic m Ex02.Model <- siteInput @Ex02.Route cliAct Ex02.delayFast+    x3 :: Dynamic m Ex03.Model <- siteInput @Ex03.Route cliAct ()+    pure $ liftA3 M x1 x2 x3+  siteOutput rp m = \case+    R_Index ->+      pure $ Ema.AssetGenerated Ema.Html $ renderIndex rp m+    R_Hello r ->+      siteOutput (rp % (_As @"R_Hello")) m2 r+    R_Basic r ->+      siteOutput (rp % (_As @"R_Basic")) m3 r+    R_Clock r ->+      siteOutput (rp % (_As @"R_Clock")) m4 r+    R_ClockFast r ->+      siteOutput (rp % (_As @"R_Clock")) m5 r+    R_Store r ->+      siteOutput (rp % (_As @"R_Store")) m6 r+    where+      I () :* I m2 :* I m3 :* I m4 :* I m5 :* I m6 :* Nil = subModels @R m++renderIndex :: Prism' FilePath R -> M -> LByteString+renderIndex rp m =+  tailwindLayout (H.title "Ex04_Multi" >> H.base ! A.href "/") $+    H.div ! A.class_ "container mx-auto text-center mt-8 p-2" $ do+      H.p "You can compose Ema sites. Here are three sites composed to produce one:"+      H.ul ! A.class_ "flex flex-col justify-center .items-center mt-4 space-y-4" $ do+        H.li $ routeElem (R_Hello $ Ex00.Route ()) "Ex00_Hello"+        H.li $ routeElem (R_Basic Ex01.Route_Index) "Ex01_Basic"+        H.li $ routeElem (R_Clock Ex02.Route_Index) "Ex02_Clock"+        H.li $ routeElem (R_ClockFast Ex02.Route_Index) "Ex02_ClockFast"+        H.li $ routeElem (R_Store Ex03.Route_Index) "Ex03_Store"+      H.p $ do+        "The current time is: "+        H.small $ show $ mClock m+  where+    routeElem r w = do+      H.a ! A.class_ "text-xl text-purple-500 hover:underline" ! A.href (H.toValue $ routeUrl rp r) $ w
+ src/Ema/Example/Ex05_MultiRoute.hs view
@@ -0,0 +1,57 @@+{-# LANGUAGE DeriveAnyClass #-}+{-# LANGUAGE UndecidableInstances #-}++-- | Demonstration of `MultiRoute`.+module Ema.Example.Ex05_MultiRoute where++import Ema+import Ema.Example.Common (tailwindLayout)+import Ema.Example.Ex00_Hello qualified as Ex00+import Ema.Example.Ex01_Basic qualified as Ex01+import Ema.Example.Ex02_Clock qualified as Ex02+import Ema.Example.Ex03_Store qualified as Ex03+import Ema.Route.Generic+import Ema.Route.Lib.Multi (MultiRoute)+import Generics.SOP (I (I), NP (Nil, (:*)))+import Text.Blaze.Html5 ((!))+import Text.Blaze.Html5 qualified as H+import Text.Blaze.Html5.Attributes qualified as A++type R =+  MultiRoute+    '[ TopRoute+     , FolderRoute "hello" Ex00.Route+     , FolderRoute "basic" Ex01.Route+     , FolderRoute "clock" Ex02.Route+     , FolderRoute "clockfast" Ex02.Route+     , FolderRoute "store" Ex03.Route+     ]++newtype TopRoute = TopRoute ()+  deriving newtype+    (Show, Eq, Ord, Generic, IsRoute)++instance EmaSite TopRoute where+  siteInput _ _ = pure $ pure ()+  siteOutput _enc _ _ =+    pure $ Ema.AssetGenerated Ema.Html renderIndex++main :: IO ()+main = do+  void $ Ema.runSite @R $ I () :* I () :* I () :* I Ex02.delayNormal :* I Ex02.delayFast :* I () :* Nil++renderIndex :: LByteString+renderIndex =+  tailwindLayout (H.title "Ex05_MultiRoute" >> H.base ! A.href "/") $+    H.div ! A.class_ "container mx-auto text-center mt-8 p-2" $ do+      H.p "You can compose Ema sites. Here are three sites composed to produce one, using Ema.Route.Lib.Multi:"+      H.ul ! A.class_ "flex flex-col justify-center .items-center mt-4 space-y-4" $ do+        -- NOTE: Since we don't have the MultiModel, we must hardcode the routes here.+        H.li $ routeElem "hello" "Ex00_Hello"+        H.li $ routeElem "basic" "Ex01_Basic"+        H.li $ routeElem "clock" "Ex02_Clock"+        H.li $ routeElem "clockfast" "Ex02_Clock (Fast)"+        H.li $ routeElem "store" "Ex03_Store"+  where+    routeElem r w = do+      H.a ! A.class_ "text-xl text-purple-500 hover:underline" ! A.href r $ w
src/Ema/Generate.hs view
@@ -1,69 +1,111 @@+{-# LANGUAGE AllowAmbiguousTypes #-} {-# LANGUAGE BangPatterns #-} {-# LANGUAGE DeriveAnyClass #-} -module Ema.Generate where+module Ema.Generate (+  generateSiteFromModel,+  generateSiteFromModel',+) where -import Control.Exception (throw)-import Control.Monad.Logger+import Control.Exception (throwIO)+import Control.Monad.Except (MonadError (throwError))+import Control.Monad.Logger (+  LogLevel (LevelError, LevelInfo),+  MonadLogger,+  MonadLoggerIO,+  logWithoutLoc,+ ) import Ema.Asset (Asset (..))-import Ema.Class (Ema (allRoutes, encodeRoute))+import Ema.CLI (crash)+import Ema.Route.Class (IsRoute (RouteModel, routePrism, routeUniverse))+import Ema.Route.Prism (+  checkRoutePrismGivenRoute,+  fromPrism_,+ )+import Ema.Site (EmaSite (siteOutput), EmaStaticSite)+import Optics.Core (review) import System.Directory (copyFile, createDirectoryIfMissing, doesDirectoryExist, doesFileExist, doesPathExist) import System.FilePath (takeDirectory, (</>)) import System.FilePattern.Directory (getDirectoryFiles)-import UnliftIO (MonadUnliftIO)  log :: MonadLogger m => LogLevel -> Text -> m ()-log = logWithoutLoc "Generate"+log = logWithoutLoc "ema.generate" -generate ::-  forall model route m.-  ( MonadIO m,-    MonadUnliftIO m,-    MonadLoggerIO m,-    Ema model route,-    HasCallStack-  ) =>+{- | Generate the static site at `dest`++  The *only* data we need is the `RouteModel`.+-}+generateSiteFromModel ::+  forall r m.+  (MonadIO m, MonadLoggerIO m, MonadFail m, Eq r, Show r, IsRoute r, EmaStaticSite r) =>+  -- | Target directory to write files to. Must exist.   FilePath ->-  model ->-  (model -> route -> Asset LByteString) ->+  -- | The model data used to generate assets.+  RouteModel r ->+  m [FilePath]+generateSiteFromModel dest model =+  withBlockBuffering $ do+    runExceptT (generateSiteFromModel' @r dest model) >>= \case+      Left err -> do+        crash "ema" err+      Right fs ->+        pure fs+  where+    -- Temporarily use block buffering before calling an IO action that is+    -- known ahead to log rapidly, so as to not hamper serial processing speed.+    withBlockBuffering f =+      hSetBuffering stdout (BlockBuffering Nothing)+        *> f+        <* (hSetBuffering stdout LineBuffering >> hFlush stdout)++-- | Like `generateSiteFromModel` but without buffering or error handling.+generateSiteFromModel' ::+  forall r m.+  (MonadIO m, MonadLoggerIO m, MonadError Text m, Eq r, Show r, EmaStaticSite r) =>+  FilePath ->+  RouteModel r ->   -- | List of generated files.   m [FilePath]-generate dest model render = do+generateSiteFromModel' dest model = do+  let enc = routePrism @r+      rp = fromPrism_ $ enc model+  -- Sanity checks   unlessM (liftIO $ doesDirectoryExist dest) $ do-    error $ "Destination does not exist: " <> toText dest-  let routes = allRoutes model-  log LevelInfo $ "Writing " <> show (length routes) <> " routes"-  let (staticPaths, generatedPaths) =-        lefts &&& rights $-          routes <&> \r ->-            case render model r of-              AssetStatic fp -> Left (r, fp)-              AssetGenerated _fmt s -> Right (encodeRoute model r, s)-  paths <- forM generatedPaths $ \(relPath, !s) -> do-    let fp = dest </> relPath-    log LevelInfo $ toText $ "W " <> fp-    liftIO $ do-      createDirectoryIfMissing True (takeDirectory fp)-      writeFileLBS fp s-      pure fp-  forM_ staticPaths $ \(r, staticPath) -> do-    liftIO (doesPathExist staticPath) >>= \case-      True ->-        -- TODO: In current branch, we don't expect this to be a directory.-        -- Although the user may pass it, but review before merge.-        copyDirRecursively (encodeRoute model r) staticPath dest-      False ->-        log LevelWarn $ toText $ "? " <> staticPath <> " (missing)"+    throwError $ "Destination directory does not exist: " <> toText dest+  let routes = routeUniverse @r model+  when (null routes) $+    throwError "Your app's `routeUniverse` is empty; nothing to generate!"+  forM_ routes $ \route ->+    checkRoutePrismGivenRoute enc model route+      `whenLeft_` throwError+  -- For Github Pages   noBirdbrainedJekyll dest-  pure paths+  -- Enumerate and write all routes.+  log LevelInfo $ "Writing " <> show (length routes) <> " routes"+  fmap concat . forM routes $ \r -> do+    let fp = dest </> review rp r+    siteOutput rp model r >>= \case+      AssetStatic staticPath -> do+        liftIO (doesPathExist staticPath) >>= \case+          True ->+            -- NOTE: A static path can indeed be a directory. The user is not+            -- obliged to recursively list the files.+            copyRecursively staticPath fp+          False ->+            log LevelError $ toText $ "? " <> staticPath <> " (missing)"+        pure []+      AssetGenerated _fmt !s -> do+        writeFileGenerated fp s+        pure [fp] --- | Disable birdbrained hacks from GitHub to disable surprises like,--- https://github.com/jekyll/jekyll/issues/55+{- | Disable birdbrained hacks from GitHub to disable surprises like,+ https://github.com/jekyll/jekyll/issues/55+-} noBirdbrainedJekyll :: (MonadIO m, MonadLoggerIO m) => FilePath -> m () noBirdbrainedJekyll dest = do   let nojekyll = dest </> ".nojekyll"   liftIO (doesFileExist nojekyll) >>= \case-    True -> pure ()+    True -> pass     False -> do       log LevelInfo $ "Disabling Jekyll by writing " <> toText nojekyll       writeFileLBS nojekyll ""@@ -72,37 +114,46 @@   deriving stock (Show)   deriving anyclass (Exception) -copyDirRecursively ::-  ( MonadIO m,-    MonadUnliftIO m,-    MonadLoggerIO m,-    HasCallStack+writeFileGenerated :: (MonadLogger m, MonadIO m) => FilePath -> LByteString -> m ()+writeFileGenerated fp s = do+  log LevelInfo $ toText $ "W " <> fp+  liftIO $ do+    createDirectoryIfMissing True (takeDirectory fp)+    writeFileLBS fp s++{- | Copy a file or directory recursively to the target directory++  Like `cp -R src dest`.+-}+copyRecursively ::+  forall m.+  ( MonadIO m+  , MonadLoggerIO m   ) =>-  -- | Source file path relative to CWD-  FilePath ->-  -- | Absolute path to source file to copy.+  -- | Absolute path to source file or directory to copy.   FilePath ->-  -- | Directory *under* which the source file/dir will be copied+  -- | Target file or directory path.   FilePath ->   m ()-copyDirRecursively srcRel srcAbs destParent = do-  liftIO (doesFileExist srcAbs) >>= \case-    True -> do-      let b = destParent </> srcRel-      log LevelInfo $ toText $ "C " <> b-      copyFileCreatingParents srcAbs b-    False ->-      liftIO (doesDirectoryExist srcAbs) >>= \case-        False ->-          throw $ StaticAssetMissing srcAbs-        True -> do-          fs <- liftIO $ getDirectoryFiles srcAbs ["**"]-          forM_ fs $ \fp -> do-            let a = srcAbs </> fp-                b = destParent </> srcRel </> fp-            log LevelInfo $ toText $ "C " <> b-            copyFileCreatingParents a b+copyRecursively src dest = do+  fs <- enumerateFilesToCopy src dest+  forM_ fs $ \(a, b) -> do+    log LevelInfo $ toText $ "C " <> b+    copyFileCreatingParents a b   where+    enumerateFilesToCopy :: FilePath -> FilePath -> m [(FilePath, FilePath)]+    enumerateFilesToCopy a b = do+      liftIO (doesFileExist a) >>= \case+        True ->+          pure [(a, b)]+        False -> do+          liftIO (doesDirectoryExist a) >>= \case+            False ->+              liftIO $ throwIO $ StaticAssetMissing a+            True -> do+              fs <- liftIO $ getDirectoryFiles src ["**"]+              pure $ fs <&> \fp -> (a </> fp, b </> fp)+     copyFileCreatingParents a b =       liftIO $ do         createDirectoryIfMissing True (takeDirectory b)
− src/Ema/Route.hs
@@ -1,62 +0,0 @@-module Ema.Route-  ( routeUrl,-    routeUrlWith,-    UrlStrategy (..),-  )-where--import Data.Aeson (FromJSON (parseJSON), Value)-import Data.Aeson.Types (Parser)-import Data.List.NonEmpty qualified as NE-import Data.Text qualified as T-import Ema.Class (Ema (encodeRoute))-import Network.URI.Slug qualified as Slug--data UrlStrategy-  = UrlPretty-  | UrlDirect-  deriving stock (Eq, Show, Ord)--instance FromJSON UrlStrategy where-  parseJSON val =-    f UrlPretty "pretty" val <|> f UrlDirect "direct" val-    where-      f :: UrlStrategy -> Text -> Value -> Parser UrlStrategy-      f c s v = do-        x <- parseJSON v-        guard $ x == s-        pure c---- | Return the relative URL of the given route------ As the returned URL is relative, you will have to either make it absolute (by--- prepending with `/`) or set the `<base>` URL in your HTML head element.-routeUrlWith :: forall r model. Ema model r => UrlStrategy -> model -> r -> Text-routeUrlWith urlStrategy model =-  relUrlFromPath . encodeRoute model-  where-    relUrlFromPath :: FilePath -> Text-    relUrlFromPath fp =-      case T.stripSuffix (urlStrategySuffix urlStrategy) (toText fp) of-        Just htmlFp ->-          case nonEmpty (urlSlugFromText <$> T.splitOn "/" htmlFp) of-            Nothing ->-              ""-            Just (removeLastIf "index" -> partsSansIndex) ->-              T.intercalate "/" partsSansIndex-        Nothing ->-          T.intercalate "/" $ urlSlugFromText <$> T.splitOn "/" (toText fp)-      where-        urlSlugFromText = Slug.encodeSlug . fromString @Slug.Slug . toString-        removeLastIf :: Eq a => a -> NonEmpty a -> [a]-        removeLastIf x xs =-          if NE.last xs == x-            then NE.init xs-            else toList xs-        urlStrategySuffix = \case-          UrlPretty -> ".html"-          UrlDirect -> ""--routeUrl :: forall r model. Ema model r => model -> r -> Text-routeUrl =-  routeUrlWith UrlPretty
+ src/Ema/Route/Class.hs view
@@ -0,0 +1,32 @@+module Ema.Route.Class (+  IsRoute (RouteModel, routePrism, routeUniverse),+) where++import Ema.Route.Prism (Prism_, toPrism_)+import Optics.Core (only)+import Prelude hiding (All, Generic)++{- | Class of Ema routes++  An Ema route has a @Prism'@ `routePrism`, that knows how to convert it to/from+  filepaths.  As well as an universe function, `routeUniverse`, that gives all+  possible route values in a static site.++  Both functions take the associated model, `RouteModel r`, as an argument.+-}+class IsRoute r where+  type RouteModel r :: Type++  -- | An optics `Prism'` that denotes how to encode and decode a route.+  routePrism :: RouteModel r -> Prism_ FilePath r++  -- | All possible route values for the given `RouteModel`.+  --+  -- This is used in determining the pages to statically generate.+  routeUniverse :: RouteModel r -> [r]++-- Single element routes are represented by `()`+instance IsRoute () where+  type RouteModel () = ()+  routePrism () = toPrism_ $ only "index.html"+  routeUniverse () = one ()
+ src/Ema/Route/Generic.hs view
@@ -0,0 +1,164 @@+{-# LANGUAGE DeriveAnyClass #-}+{-# LANGUAGE UndecidableInstances #-}+{-# LANGUAGE UndecidableSuperClasses #-}++module Ema.Route.Generic (+  -- * Generic deriving types+  GenericRoute (GenericRoute),+  HasSubRoutes,+  HasSubModels,+  WithModel,+  WithSubRoutes,+  WithSubModels,++  -- * Customizing generic deriving+  GenericRouteOpt (..),++  -- * Handy functions+  subModels,++  -- * Export these for DerivingVia coercion representations+  FileRoute (FileRoute),+  FolderRoute (FolderRoute),+) where++import Ema.Route.Class (IsRoute (..))+import Ema.Route.Generic.RGeneric+import Ema.Route.Generic.SubModel as X+import Ema.Route.Generic.SubRoute as X+import Ema.Route.Generic.Verification+import Ema.Route.Lib.File (FileRoute (FileRoute))+import Ema.Route.Lib.Folder (FolderRoute (FolderRoute))+import Ema.Route.Lib.Multi (MultiModel, MultiRoute)+import Ema.Route.Prism.Type (mapRoutePrism)+import GHC.Generics qualified as GHC+import Generics.SOP (All, I (..), NP)+import Optics.Core (ReversibleOptic (re), coercedTo, equality, review, (%))+import Prelude hiding (All, Generic)++-- | DerivingVia type to generically derive `IsRoute`+newtype GenericRoute r (opts :: [Type]) = GenericRoute r+  deriving stock (GHC.Generic)++{- | Associate the route with the given model type.++ Default: `()`+-}+data WithModel (r :: Type)++{- | Specify isomorphic types to delegate sub-route behaviour. Usually this is identical to the route product type.++    The isomorphism is specified by @Coercible@.++    The default implementation uses @FileRoute@ for terminal routes, and+    @FolderRoute@ (with constructor prefix stripped) for wrapping sub-routes types.+-}+data WithSubRoutes (subRoutes :: [Type])++{- | Specify the @Data.Generics.Product.Any.HasAny@ selector type for sub models++  Note: if the selector is a @Symbol@ you must wrap it in a @Proxy@.+-}+data WithSubModels (subModels :: [Type])++{- | Typeclass to control `GenericRoute` behaviour.++  The `FooM` type enables users to define their type optionally, whose default+  is specified in the `Foo` type family (further below).++  You can define your own options, for example:++  @+    data MySubRoutes+    instance GenericRouteOpt r MySubRoutes where+      type+        OptSubRoutesM r MySubRoutes =+          'Just (GSubRoutes (RDatatypeName r) (RConstructorNames r) (RCode r))+  @++  And use it as:++  > deriving via (GenericRoute MyRoute '[MySubRoutes])+-}+class GenericRouteOpt (r :: Type) (opt :: Type) where+  type OptModelM r opt :: Maybe Type+  type OptModelM r opt = 'Nothing+  type OptSubRoutesM r opt :: Maybe [Type]+  type OptSubRoutesM r opt = 'Nothing+  type OptSubModelsM r opt :: Maybe [Type]+  type OptSubModelsM r opt = 'Nothing++instance GenericRouteOpt r (WithModel t) where+  type OptModelM r (WithModel t) = 'Just t+instance GenericRouteOpt r (WithSubRoutes t) where+  type OptSubRoutesM r (WithSubRoutes t) = 'Just t+instance GenericRouteOpt r (WithSubModels t) where+  type OptSubModelsM r (WithSubModels t) = 'Just t++type family OptModel r (opts :: [Type]) :: Type where+  OptModel r '[] = ()+  OptModel r (opt ': opts) = FromMaybe (OptModel r opts) (OptModelM r opt)++type family OptSubRoutes r (opts :: [Type]) :: [Type] where+  OptSubRoutes r '[] = GSubRoutes (RDatatypeName r) (RConstructorNames r) (RCode r)+  OptSubRoutes r (opt ': opts) = FromMaybe (OptSubRoutes r opts) (OptSubRoutesM r opt)++type family OptSubModels r (opts :: [Type]) :: [Type] where+  OptSubModels r '[] = MultiModel (SubRoutes r)+  OptSubModels r (opt ': opts) = FromMaybe (OptSubModels r opts) (OptSubModelsM r opt)++type family FromMaybe (def :: a) (maybe :: Maybe a) :: a where+  FromMaybe def 'Nothing = def+  FromMaybe def ( 'Just a) = a++type GenericRouteOpts r opts = All (GenericRouteOpt r) opts++instance+  ( GenericRouteOpts r opts+  , RGeneric r+  , ValidSubRoutes r (OptSubRoutes r opts)+  ) =>+  HasSubRoutes (GenericRoute r opts)+  where+  type SubRoutes (GenericRoute r opts) = OptSubRoutes r opts++instance+  ( VerifyModels+      (RouteModel (GenericRoute r opts))+      (MultiModel (SubRoutes (GenericRoute r opts)))+      (OptSubModels r opts)+  , VerifyRoutes (RCode r) (SubRoutes (GenericRoute r opts))+  , GSubModels (RouteModel (GenericRoute r opts)) (MultiModel (OptSubRoutes r opts)) (OptSubModels r opts)+  , HasSubRoutes (GenericRoute r opts)+  , GenericRouteOpts r opts+  ) =>+  HasSubModels (GenericRoute r opts)+  where+  subModels m =+    gsubModels @_ @(RouteModel (GenericRoute r opts))+      @(MultiModel (SubRoutes (GenericRoute r opts)))+      @(OptSubModels r opts)+      m++instance+  ( VerifyRoutes (RCode r) (SubRoutes (GenericRoute r opts))+  , HasSubRoutes r+  , HasSubModels r+  , ValidSubRoutes r (SubRoutes r)+  , RGeneric r+  , mr ~ MultiRoute (SubRoutes r)+  , mm ~ MultiModel (SubRoutes r)+  , RouteModel r ~ OptModel r opts+  , RouteModel mr ~ NP I mm+  , IsRoute mr+  , GenericRouteOpts r opts+  ) =>+  IsRoute (GenericRoute r opts)+  where+  type RouteModel (GenericRoute r opts) = OptModel r opts+  routePrism =+    routePrism @mr+      & mapRoutePrism equality (re (subRoutesIso @r) % coercedTo) (subModels @r)+  routeUniverse m =+    GenericRoute . review subRoutesIso+      <$> routeUniverse (subModels @r m)
+ src/Ema/Route/Generic/Example.hs view
@@ -0,0 +1,159 @@+{-# LANGUAGE AllowAmbiguousTypes #-}+{-# LANGUAGE DeriveAnyClass #-}+{-# LANGUAGE TemplateHaskell #-}+{-# LANGUAGE UndecidableInstances #-}++module Ema.Route.Generic.Example where++import Data.Generics.Sum.Any (AsAny (_As))+import Data.SOP (I (..), NP (..))+import Ema+import Ema.Asset qualified as Asset+import Ema.Route.Generic+import Ema.Route.Generic.TH+import Generics.SOP qualified as SOP+import Generics.SOP.TH qualified as SOP+import Optics.Core ((%))+import Optics.Prism (prism')++-- ----------+-- Examples+-- ----------++type M = (Int, Int, String)++data R = R_Index | R_Foo | R_Bar NumRoute | R_Bar2 NumRoute+  deriving stock (Show, Eq, Generic)+  deriving anyclass (SOP.Generic, SOP.HasDatatypeInfo)+  deriving (HasSubRoutes, IsRoute) via (GenericRoute R '[WithModel M])++-- We must derive this manually due to ambiguity in field types (Int)+instance HasSubModels R where+  subModels (a, b, _) =+    I () :* I () :* I a :* I b :* Nil++data NumRoute = NumRoute+  deriving stock (Show, Eq, Generic)++instance IsRoute NumRoute where+  type RouteModel NumRoute = Int+  routePrism n =+    let fp = show n <> ".html"+     in toPrism_ $+          prism' (const fp) $ \s -> do+            guard $ s == fp+            pure NumRoute+  routeUniverse _ = [NumRoute]++instance EmaSite R where+  siteInput _ () = pure $ pure (42, 21, "inner")+  siteOutput _ m r = pure $ Asset.AssetGenerated Asset.Html $ show r <> show m++-- --warnings -c "cabal repl ema -f with-examples" -T Ema.Route.Generic.main  --setup ":set args gen /tmp"+main :: IO ()+main = Ema.runSite_ @R ()++-- ---+-- Let's try defining a top-level route using `R` to see how EmaSite instances compose.+-- --++type TM = (M, String)++data TR = TR_Index | TR_Inner R+  deriving stock (Show, Eq, Generic)+  deriving anyclass (SOP.Generic, SOP.HasDatatypeInfo)+  deriving (HasSubRoutes, HasSubModels, IsRoute) via (GenericRoute TR '[WithModel TM])++instance EmaSite TR where+  siteInput x () = do+    m1 <- siteInput @R x ()+    pure $ fmap (,"TOP") m1+  siteOutput rp m = \case+    r@TR_Index ->+      pure $ Asset.AssetGenerated Asset.Html $ show r <> show m+    TR_Inner r ->+      -- Might as well provide a `innerSiteOutput (_As @TR_Inner)`?+      siteOutput @R (rp % _As @"TR_Inner") (trInnerModel m) r+    where+      trInnerModel model =+        let I () :* I m' :* Nil = subModels @TR model+         in m'++mainTop :: IO ()+mainTop = Ema.runSite_ @TR ()++-- ---+-- Ensure that the constant model case (simple one) still works+-- --++type M2 = (Int, String)++data R2 = R2_Index | R2_Foo | R2_Bar BarRoute | R2_Bar2 BarRoute+  deriving stock (Show, Eq, Generic)+  deriving anyclass (SOP.Generic, SOP.HasDatatypeInfo)+  deriving (HasSubRoutes, HasSubModels, IsRoute) via (GenericRoute R2 '[WithModel M2])++data BarRoute = BarRoute+  deriving stock (Show, Eq, Generic)+  deriving anyclass (SOP.Generic, SOP.HasDatatypeInfo)+  deriving (HasSubRoutes, HasSubModels, IsRoute) via (GenericRoute BarRoute '[WithModel M2])++instance EmaSite R2 where+  siteInput _ () = pure $ pure (21, "inner")+  siteOutput _ m r = pure $ Asset.AssetGenerated Asset.Html $ show r <> show m++mainConst :: IO ()+mainConst = Ema.runSite_ @R2 ()++-- ---+-- Ensure deriveIsRoute works with (partial) subroutes+-- --++data M3+  = M3+  deriving stock (Eq, Show, Generic)++data R3+  = R3_Sub R3_SubR+  | R3_Index++data R3_SubR+  = R3_SubR+  deriving stock (Show, Eq, Ord, Generic)+  deriving anyclass (SOP.Generic, SOP.HasDatatypeInfo)+  deriving+    (HasSubRoutes, HasSubModels, IsRoute)+    via ( GenericRoute+            R3_SubR+            '[ WithSubRoutes+                '[ FileRoute "example.html"+                 ]+             ]+        )++SOP.deriveGeneric ''R3+deriveIsRoute+  ''R3+  [t|+    [ WithModel M3+    , WithSubRoutes+        [ R3_SubR+        , ()+        ]+    ]+    |]++-- ---+-- Ensure deriveIsRoute works in a no-subroute case+-- --++data M4+  = M4+  deriving stock (Eq, Show, Generic)++data R4+  = R4_Index+  | R3_About++SOP.deriveGeneric ''R4+deriveIsRoute ''R4 [t|'[WithModel M4]|]
+ src/Ema/Route/Generic/RGeneric.hs view
@@ -0,0 +1,99 @@+{-# LANGUAGE UndecidableInstances #-}+{-# LANGUAGE UndecidableSuperClasses #-}++module Ema.Route.Generic.RGeneric (+  RGeneric (RCode, rfrom, rto),+  RHasDatatypeInfo (RDatatypeName, RConstructorNames),+) where++import GHC.TypeLits (ErrorMessage (Text), TypeError)+import GHC.TypeLits.Extra (Impossible (impossible), TypeErr)+import Generics.SOP+import Generics.SOP.Type.Metadata qualified as SOPM+import Prelude hiding (All, Generic)++-- | Like `Generic` but for Route types only.+class (Generic r, HasDatatypeInfo r) => RGeneric r where+  type RCode r :: [Type]+  rfrom :: r -> NS I (RCode r)+  rto :: NS I (RCode r) -> r++class (RGeneric r, HasDatatypeInfo r) => RHasDatatypeInfo r where+  type RDatatypeName r :: SOPM.DatatypeName+  type RConstructorNames r :: [SOPM.ConstructorName]++instance (Generic r, HasDatatypeInfo r, RGeneric' (Code r), All RouteNP (Code r)) => RGeneric r where+  type RCode r = RCode' (Code r)+  rfrom = rfrom' @(Code r) . unSOP . from+  rto = to . SOP . rto' @(Code r)++instance (RGeneric r, HasDatatypeInfo r) => RHasDatatypeInfo r where+  type RDatatypeName r = RDatatypeName' (DatatypeInfoOf r)+  type RConstructorNames r = RConstructorNames' (DatatypeInfoOf r)++class RGeneric' (xss :: [[Type]]) where+  type RCode' xss :: [Type]+  rfrom' :: NS (NP I) xss -> NS I (RCode' xss)+  rto' :: NS I (RCode' xss) -> NS (NP I) xss++instance RGeneric' '[] where+  type RCode' '[] = '[]+  rfrom' = \case {}+  rto' = \case {}++instance (RGeneric' xss, RouteNP xs) => RGeneric' (xs ': xss) where+  type RCode' (xs ': xss) = RouteNPType xs ': RCode' xss+  rfrom' = \case+    Z routeNP -> Z $ I $ fromRouteNP routeNP+    S rest -> S $ rfrom' @xss rest+  rto' = \case+    Z (I t) -> Z $ toRouteNP t+    S rest -> S $ rto' @xss rest++type family RDatatypeName' (info :: SOPM.DatatypeInfo) :: SOPM.DatatypeName where+  RDatatypeName' ( 'SOPM.ADT _ name _ _) =+    name+  RDatatypeName' ( 'SOPM.Newtype _ name _) =+    name++type family RConstructorNames' (info :: SOPM.DatatypeInfo) :: [SOPM.ConstructorName] where+  RConstructorNames' ( 'SOPM.ADT _ _ constrs _) =+    GetConstructorNames constrs+  RConstructorNames' ( 'SOPM.Newtype _ _ constr) =+    '[GetConstructorName constr]++type family GetConstructorName (info :: SOPM.ConstructorInfo) :: SOPM.ConstructorName where+  GetConstructorName ( 'SOPM.Constructor name) =+    name+  GetConstructorName ( 'SOPM.Infix name _ _) =+    name+  GetConstructorName ( 'SOPM.Record name _) =+    name++type family GetConstructorNames (infos :: [SOPM.ConstructorInfo]) :: [SOPM.ConstructorName] where+  GetConstructorNames '[] = '[]+  GetConstructorNames (x ': xs) = GetConstructorName x ': GetConstructorNames xs++{- | Class of `NP` that can be used in a route tyupe.++    Ensures that the constructor can have 0 or 1 products only.+-}+class RouteNP (xs :: [Type]) where+  type RouteNPType xs :: Type+  fromRouteNP :: NP I xs -> RouteNPType xs+  toRouteNP :: RouteNPType xs -> NP I xs++instance RouteNP '[] where+  type RouteNPType '[] = ()+  fromRouteNP Nil = ()+  toRouteNP () = Nil++instance RouteNP (x ': '[]) where+  type RouteNPType (x ': '[]) = x+  fromRouteNP (I x :* Nil) = x+  toRouteNP x = I x :* Nil++instance (TypeErr ( 'Text "MultiRoute: too many arguments")) => RouteNP (x ': x' ': xs) where+  type RouteNPType (x ': x' ': xs) = TypeError ( 'Text "MultiRoute: too many arguments")+  fromRouteNP _ = impossible+  toRouteNP _ = impossible
+ src/Ema/Route/Generic/SubModel.hs view
@@ -0,0 +1,42 @@+{-# LANGUAGE AllowAmbiguousTypes #-}+{-# LANGUAGE UndecidableInstances #-}+{-# OPTIONS_GHC -Wno-orphans #-}++module Ema.Route.Generic.SubModel (+  HasSubModels (subModels),+  -- DerivingVia types+  GSubModels (..),+) where++import Data.Generics.Product (HasAny (the))+import Ema.Route.Class (IsRoute (RouteModel))+import Ema.Route.Generic.SubRoute (HasSubRoutes (SubRoutes))+import Ema.Route.Lib.Multi (MultiModel)+import Generics.SOP (I (..), NP (Nil, (:*)))+import Optics.Core (united, view)+import Prelude hiding (All)++class HasSubRoutes r => HasSubModels r where+  -- | Break the model into a list of sub-models used correspondingly by the sub-routes.+  subModels :: RouteModel r -> NP I (MultiModel (SubRoutes r))++class GSubModels m (ms :: [Type]) (lookups :: [k]) where+  gsubModels :: m -> NP I ms++instance GSubModels m '[] '[] where+  gsubModels _ = Nil++instance+  {-# OVERLAPPING #-}+  (HasAny s m m t t, GSubModels m ms ss) =>+  GSubModels m (t ': ms) (s ': ss)+  where+  gsubModels m = I (view (the @s @m @_ @t @_) m) :* gsubModels @_ @m @ms @ss m++-- Useful instances to support varied types in `WithSubModels` list.++instance {-# OVERLAPPING #-} HasAny () s s () () where+  the = united++instance HasAny sel s t a b => HasAny (Proxy sel) s t a b where+  the = the @sel
+ src/Ema/Route/Generic/SubRoute.hs view
@@ -0,0 +1,113 @@+{-# LANGUAGE AllowAmbiguousTypes #-}+{-# LANGUAGE CPP #-}+{-# LANGUAGE DefaultSignatures #-}+{-# LANGUAGE UndecidableInstances #-}++module Ema.Route.Generic.SubRoute (+  HasSubRoutes (SubRoutes),+  subRoutesIso,+  -- DerivingVia types+  GSubRoutes,+  gtoSubRoutes,+  gfromSubRoutes,+  ValidSubRoutes,+) where++import Data.SOP.Constraint (AllZipF)+import Data.SOP.NS (trans_NS)+import Ema.Route.Generic.RGeneric (RGeneric (..))+import Ema.Route.Lib.Multi (MultiRoute)+import GHC.TypeLits (AppendSymbol, Symbol)+#if MIN_VERSION_GLASGOW_HASKELL(9,2,0,0)+import GHC.TypeLits.Extra.Symbol (StripPrefix, ToLower)+import Ema.Route.Lib.File (FileRoute)+import Ema.Route.Lib.Folder (FolderRoute)+#else +import GHC.TypeLits+#endif+import Generics.SOP (All, I (..), NS, SameShapeAs, Top, unI)+import Generics.SOP.Type.Metadata qualified as SOPM+import Optics.Core (Iso', iso)+import Prelude hiding (All)++{- | HasSubRoutes is a class of routes with an underlying MultiRoute (and MultiModel) representation.++ The idea is that by deriving HasSubRoutes (and HasSubModels), we get IsRoute for free (based on MultiRoute).++ TODO: Rename this class, or change the API.+-}+class HasSubRoutes r where+  -- | The sub-routes in the `r` (for each constructor).+  type SubRoutes r :: [Type]++subRoutesIso ::+  forall r.+  ( RGeneric r+  , HasSubRoutes r+  , ValidSubRoutes r (SubRoutes r)+  ) =>+  Iso' r (MultiRoute (SubRoutes r))+subRoutesIso =+  iso (gtoSubRoutes @r . rfrom) (rto . gfromSubRoutes @r)++gtoSubRoutes ::+  forall r subRoutes.+  ( RGeneric r+  , ValidSubRoutes r subRoutes+  ) =>+  NS I (RCode r) ->+  MultiRoute subRoutes+gtoSubRoutes = trans_NS (Proxy @Coercible) (I . coerce . unI)++gfromSubRoutes ::+  forall r subRoutes.+  ( RGeneric r+  , ValidSubRoutes r subRoutes+  ) =>+  MultiRoute subRoutes ->+  NS I (RCode r)+gfromSubRoutes = trans_NS (Proxy @Coercible) (I . coerce . unI)++-- | @subRoutes@ are valid sub-routes of @r@+type ValidSubRoutes r subRoutes =+  ( SameShapeAs (RCode r) subRoutes+  , SameShapeAs subRoutes (RCode r)+  , All Top (RCode r)+  , All Top subRoutes+  , AllZipF Coercible (RCode r) subRoutes+  , AllZipF Coercible subRoutes (RCode r)+  )++#if MIN_VERSION_GLASGOW_HASKELL(9,2,0,0)+type family GSubRoutes (name :: SOPM.DatatypeName) (constrs :: [SOPM.ConstructorName]) (xs :: [Type]) :: [Type] where+  GSubRoutes _ _ '[] = '[]+  GSubRoutes name (c ': cs) (() ': xs) =+    -- TODO: The .html suffix part should be overridable.+    FileRoute (Constructor2RoutePath name c ".html")+      ': GSubRoutes name cs xs+  GSubRoutes name (c ': cs) (x ': xs) =+    FolderRoute (Constructor2RoutePath name c "") x+      ': GSubRoutes name cs xs++type family+  Constructor2RoutePath+    (name :: SOPM.DatatypeName)+    (constr :: SOPM.ConstructorName)+    (suffix :: Symbol) ::+    Symbol+  where+  Constructor2RoutePath name constr suffix =+    AppendSymbol+      ( -- Instead of ToLower we want Camel2Kebab here, ideally.+        -- So that `Foo_BarQux` encodes to bar-qux instead of barqux.+        ToLower+          ( StripPrefix+              (AppendSymbol name "_")+              constr+          )+      )+      suffix+#else +type family GSubRoutes (name :: SOPM.DatatypeName) (constrs :: [SOPM.ConstructorName]) (xs :: [Type]) :: [Type] where+  GSubRoutes _ _ _ = TypeError ('Text "GHC 9.2 is required for anyclass deriving of HasSubRoutes")+#endif
+ src/Ema/Route/Generic/TH.hs view
@@ -0,0 +1,44 @@+{-# LANGUAGE TemplateHaskell #-}++module Ema.Route.Generic.TH (+  -- * Main TH+  deriveIsRoute,++  -- * Convenient re-exports+  deriveGeneric,+  module X,+) where++import Ema.Route.Class (IsRoute)+import Ema.Route.Generic as X+import Generics.SOP.TH (deriveGeneric)+import Language.Haskell.TH++{- | @deriveIsRoute route model subroutes@ derives 'HasSubRoutes', 'HasSubModels', and 'IsRoute' for the given @route@.++Subroutes are optionally supplied, but if they are then the length of the list must be the same as the number of+constructors in @route@.++TODO: Add TypeErrors to catch mismatched 'WithSubRoutes' list shapes at the generic deriving level?+-}+deriveIsRoute :: Name -> TypeQ -> Q [Dec]+deriveIsRoute route opts = do+  opts' <- opts+  let instances =+        [ ''HasSubRoutes+        , ''HasSubModels+        , ''IsRoute+        ]+  pure $+    flip fmap instances $ \i ->+      StandaloneDerivD+        ( Just+            ( ViaStrategy+                ( ConT ''GenericRoute+                    `AppT` (ConT route)+                    `AppT` opts'+                )+            )+        )+        []+        (ConT i `AppT` ConT route)
+ src/Ema/Route/Generic/Verification.hs view
@@ -0,0 +1,57 @@+{-# LANGUAGE UndecidableInstances #-}++module Ema.Route.Generic.Verification (+  type VerifyModels,+  type VerifyRoutes,+) where++import Data.Generics.Product.Any (HasAny)+import GHC.TypeLits++{- | @VerifyModels model routeModels lookups@ verifies the given @model@ to ensure that there+exists a valid @HasSubModels@ instance for the given combination of (model, routeModels, lookups).+-}+type family VerifyModels model (subModels :: [Type]) (lookups :: [Type]) :: Constraint where+  VerifyModels m '[] '[] = ()+  VerifyModels m '[] t =+    TypeError+      ( 'Text "'WithSubModels' has extra unnecessary types: " ':$$: 'Text "" ':$$: 'Text "\t" ':<>: 'ShowType t)+  VerifyModels m f '[] =+    TypeError+      ( 'Text "'WithSubModels' is missing submodel types: " ':$$: 'Text "" ':$$: 'Text "\t" ':<>: 'ShowType f)+  VerifyModels model (model ': fs) (model ': ss) =+    -- This checks the simple case that (the model ~ the submodel),+    -- because it doesn't necessarily have to have a generic instance in this case.+    -- After that, we can /assume/ the model has a generic instance to allow us to inspect its+    -- structure statically to verify that the correct submodel exists.+    VerifyModels model fs ss+  VerifyModels model (subModel ': subModels) (sel ': sels) =+    ( HasAny sel model model subModel subModel+    , VerifyModels model subModels sels+    )++{- | @VerifyRoutes route rep subroutes@ verifies the given @route@ to ensure that there+exists a valid @HasSubRoutes@ instance for @route@ given its @rep@ and the @subroutes@ it is generic-deriving from.++Invariant: code ~ Code route+-}+type family VerifyRoutes (rcode :: [Type]) (subRoutes :: [Type]) :: Constraint where+  VerifyRoutes '[] '[] = ()+-- Inconsistent lengths+  VerifyRoutes '[] t =+    TypeError+      ( 'Text "'WithSubRoutes' has extra unnecessary types: " ':$$: 'Text "" ':$$: 'Text "\t" ':<>: 'ShowType t)+  VerifyRoutes t '[] =+    TypeError+      ( 'Text "'WithSubRoutes' is missing subroutes for:"+          ':$$: 'Text ""+          ':$$: ( 'Text "\t" ':<>: 'ShowType t)+      )+-- Subroute rep is unit (REVIEW: this case not strictly necessary anymore; should it be removed?)+  VerifyRoutes (() ': rs) (() : rs') = VerifyRoutes rs rs'+  VerifyRoutes (r' ': rs) (() : rs') =+    TypeError+      ( 'Text "A 'WithSubRoutes' entry is '()' instead of the expected: "+          ':$$: 'ShowType r'+      )+  VerifyRoutes (r1 ': rs) (r2 ': rs') = (Coercible r1 r2, VerifyRoutes rs rs')
+ src/Ema/Route/Lib/Extra/PandocRoute.hs view
@@ -0,0 +1,197 @@+{-# LANGUAGE DeriveAnyClass #-}+{-# LANGUAGE UndecidableInstances #-}++module Ema.Route.Lib.Extra.PandocRoute (+  -- * Route+  PandocRoute (..),+  mkPandocRoute,++  -- * Model and Arg+  Model (..),+  Arg (..),++  -- * Looking up Pandoc values in model+  lookupPandocRoute,++  -- * Rendering+  PandocHtml (..),+  PandocError (..),+) where++import Control.Exception (throw, throwIO)+import Control.Monad.Logger (+  MonadLogger,+  MonadLoggerIO,+  logInfoNS,+ )+import Data.Default (Default (..))+import Data.List qualified as List+import Data.Map.Strict qualified as Map+import Data.SOP (I (I), NP (..))+import Data.Set qualified as Set+import Ema+import Ema.CLI qualified+import Ema.Route.Generic.TH+import Ema.Route.Lib.Extra.SlugRoute+import Generics.SOP qualified as SOP+import System.FilePath ((</>))+import System.UnionMount qualified as UnionMount+import Text.Pandoc (Pandoc, ReaderOptions, runIO)+import Text.Pandoc qualified as Pandoc+import Text.Pandoc.Sources (ToSources)+import UnliftIO (MonadUnliftIO)++-- | Represents the relative path to a file that pandoc can parse.+newtype PandocRoute = PandocRoute {unPandocRoute :: SlugRoute Pandoc}+  deriving stock (Eq, Ord, Show, Generic)+  deriving anyclass (SOP.Generic, SOP.HasDatatypeInfo)+  deriving+    (HasSubRoutes, IsRoute)+    via ( GenericRoute+            PandocRoute+            '[ WithModel Model+             , WithSubRoutes+                '[ SlugRoute Pandoc+                 ]+             ]+        )++instance HasSubModels PandocRoute where+  subModels m = I (Map.mapKeys unPandocRoute $ modelPandocs m) :* Nil++instance IsString PandocRoute where+  fromString fp = maybe (error $ "Bad path: " <> toText fp) snd $ mkPandocRoute fp++mkPandocRoute :: FilePath -> Maybe (String, PandocRoute)+mkPandocRoute fp = do+  (ext, r) <- mkSlugRoute fp+  pure (ext, PandocRoute r)++data Model = Model+  { modelArg :: Arg+  , modelPandocs :: Map PandocRoute Pandoc+  }+  deriving stock (Generic)++lookupPandocRoute :: Model -> PandocRoute -> Maybe (Pandoc, Pandoc -> PandocHtml)+lookupPandocRoute model r = do+  pandoc <- Map.lookup r $ modelPandocs model+  let render = PandocHtml . renderHtml (argWriterOpts $ modelArg model)+  pure (pandoc, render)+  where+    renderHtml :: HasCallStack => Pandoc.WriterOptions -> Pandoc -> Text+    renderHtml writerSettings pandoc =+      either (throw . PandocError_RenderError . show) id $+        Pandoc.runPure $ Pandoc.writeHtml5String writerSettings pandoc++data Arg = Arg+  { -- Base directory+    argBaseDir :: FilePath+  , -- Pandoc reader formats supported, as file extension; eg: '.md'+    argFormats :: Set String+  , argReaderOpts :: Pandoc.ReaderOptions+  , argWriterOpts :: Pandoc.WriterOptions+  }+  deriving stock (Generic)++instance Default Arg where+  def = Arg "." formats defaultReaderOpts defaultWriterOpts+    where+      formats = Set.fromList knownPandocFormats+      defaultReaderOpts = def {Pandoc.readerExtensions = exts}+      defaultWriterOpts = def {Pandoc.writerExtensions = exts}+      exts :: Pandoc.Extensions+      exts =+        -- Sensible defaults for Markdown and others+        Pandoc.pandocExtensions <> Pandoc.extensionsFromList [Pandoc.Ext_attributes]++instance EmaSite PandocRoute where+  type SiteArg PandocRoute = Arg++  -- Returns the `Pandoc` AST along with the function that renders it to HTML.+  type SiteOutput PandocRoute = (Pandoc, Pandoc -> PandocHtml)++  siteInput _ arg = do+    fmap (Model arg) <$> pandocFilesDyn (argBaseDir arg) (argFormats arg) (argReaderOpts arg)+  siteOutput _rp model r = do+    maybe (liftIO $ throwIO $ PandocError_Missing $ show r) pure $ lookupPandocRoute model r++pandocFilesDyn ::+  forall m.+  (MonadIO m, MonadUnliftIO m, MonadLogger m, MonadLoggerIO m) =>+  FilePath ->+  Set String ->+  ReaderOptions ->+  m (Dynamic m (Map PandocRoute Pandoc))+pandocFilesDyn baseDir formats readerOpts = do+  let pats =+        toList formats <&> \ext ->+          ((), "**/*" <> ext)+      ignorePats = [".*"]+      model0 = mempty+  Dynamic <$> UnionMount.mount baseDir pats ignorePats model0 (const handleUpdate)+  where+    -- Take the file that got changed and update our in-memory `Model` accordingly.+    handleUpdate ::+      (MonadIO m, MonadLogger m, MonadLoggerIO m) =>+      FilePath ->+      UnionMount.FileAction () ->+      m (Map PandocRoute Pandoc -> Map PandocRoute Pandoc)+    handleUpdate fp = \case+      UnionMount.Refresh _ _ -> do+        mData <- readSource fp+        pure $ maybe id (uncurry Map.insert) mData+      UnionMount.Delete ->+        pure $ maybe id (Map.delete . snd) $ mkPandocRoute fp+    readSource :: (MonadIO m, MonadLogger m, MonadLoggerIO m) => FilePath -> m (Maybe (PandocRoute, Pandoc))+    readSource fp = runMaybeT $ do+      (ext, r :: PandocRoute) <- hoistMaybe (mkPandocRoute fp)+      guard $ ext `Set.member` formats+      log $ "Reading " <> toText fp+      s :: Text <- fmap decodeUtf8 $ readFileBS $ baseDir </> fp+      liftIO (runIO $ readPandocSource readerOpts ext s) >>= \case+        Left err -> Ema.CLI.crash "PandocRoute" $ show err+        Right doc -> do+          pure (r, doc)++log :: MonadLogger m => Text -> m ()+log = logInfoNS "PandocRoute"++data PandocError+  = PandocError_Missing Text+  | PandocError_RenderError Text+  deriving stock (Eq, Show)+  deriving anyclass (Exception)++newtype PandocHtml = PandocHtml {unPandocHtml :: Text}+  deriving stock (Eq, Generic)++-- Pandoc reader abstraction+--+-- TODO: Can we refactor this using,+-- https://github.com/jgm/pandoc/blob/16f0316fbaa4d667ba40772969ab8e28fea6a493/src/Text/Pandoc/App/FormatHeuristics.hs#L36++knownPandocFormats :: [String]+knownPandocFormats = [".md", ".org"]++formatFromExt :: String -> Text+formatFromExt = \case+  ".md" -> "markdown"+  ".org" -> "org"+  ext -> throw $ UnsupportedPandocFormat ext++readPandocSource ::+  forall m a.+  (Pandoc.PandocMonad m, ToSources a) =>+  ReaderOptions ->+  [Char] ->+  a ->+  m Pandoc+readPandocSource readerOpts ext s =+  case List.lookup (formatFromExt ext) Pandoc.readers of+    Just (Pandoc.TextReader f) -> f readerOpts s+    _ -> throw $ UnsupportedPandocFormat ext++data UnsupportedPandocFormat = UnsupportedPandocFormat String+  deriving stock (Eq, Show)+  deriving anyclass (Exception)
+ src/Ema/Route/Lib/Extra/SlugRoute.hs view
@@ -0,0 +1,40 @@+module Ema.Route.Lib.Extra.SlugRoute (+  SlugRoute,+  mkSlugRoute,+) where++import Data.Map.Strict qualified as Map+import Data.Text qualified as T+import Ema.Route.Class (IsRoute (..))+import Ema.Route.Prism (htmlSuffixPrism, toPrism_)+import Network.URI.Slug (Slug)+import Network.URI.Slug qualified as Slug+import Optics.Core (prism', (%))+import System.FilePath (splitExtension, splitPath)++{- | Route to a file that is associated with a value of type `a`.++ A route to foo/bar/qux.md, for instance, is encoded as /foo/bar/qux. ie., the+ extension is dropped.+-}+newtype SlugRoute (a :: Type) = SlugRoute {unSlugRoute :: NonEmpty Slug}+  deriving stock (Eq, Ord, Show, Generic)++instance IsRoute (SlugRoute a) where+  type RouteModel (SlugRoute a) = Map (SlugRoute a) a+  routePrism m =+    let encode (SlugRoute slugs) =+          toString $ T.intercalate "/" $ Slug.unSlug <$> toList slugs+        decode fp = do+          guard $ not $ null fp+          slugs <- nonEmpty $ fromString . toString <$> T.splitOn "/" (toText fp)+          let r = SlugRoute slugs+          guard $ Map.member r m+          pure r+     in toPrism_ $ htmlSuffixPrism % prism' encode decode+  routeUniverse = Map.keys++mkSlugRoute :: forall a. FilePath -> Maybe (String, SlugRoute a)+mkSlugRoute (splitExtension -> (relFp, ext')) = do+  let slugs = fromString . toString . T.dropWhileEnd (== '/') . toText <$> splitPath relFp+  (ext',) <$> viaNonEmpty SlugRoute slugs
+ src/Ema/Route/Lib/Extra/StaticRoute.hs view
@@ -0,0 +1,111 @@+{-# LANGUAGE DeriveAnyClass #-}+{-# OPTIONS_GHC -Wno-unrecognised-pragmas #-}++{-# HLINT ignore "Use impureThrow" #-}++module Ema.Route.Lib.Extra.StaticRoute (+  StaticRoute,+  Model (..),++  -- * Helpers+  staticRouteUrl,+) where++import Control.Exception (throw)+import Control.Monad.Logger (MonadLogger, MonadLoggerIO)+import Data.List qualified as List+import Data.Map.Strict qualified as Map+import Data.Some (Some)+import Data.Time (UTCTime, defaultTimeLocale, formatTime, getCurrentTime)+import Ema+import Ema.CLI qualified+import GHC.TypeLits (KnownSymbol, Symbol, symbolVal)+import Optics.Core (Prism', prism')+import System.FilePath (takeExtension, (</>))+import System.UnionMount qualified as UnionMount+import UnliftIO (MonadUnliftIO)++-- | Route to a static file under @baseDir@.+newtype StaticRoute (baseDir :: Symbol) = StaticRoute {unStaticRoute :: FilePath}+  deriving newtype (Eq, Ord, Show)+  deriving stock (Generic)++data Model = Model+  { modelCliAction :: Some Ema.CLI.Action+  , modelFiles :: Map FilePath UTCTime+  }+  deriving stock (Eq, Show, Generic)++instance IsRoute (StaticRoute baseDir) where+  type RouteModel (StaticRoute baseDir) = Model+  routePrism (modelFiles -> files) =+    let enc =+          unStaticRoute+        dec fp =+          StaticRoute fp <$ guard (Map.member fp files)+     in toPrism_ $ prism' enc dec+  routeUniverse (modelFiles -> files) =+    StaticRoute <$> Map.keys files++instance KnownSymbol baseDir => EmaSite (StaticRoute baseDir) where+  siteInput cliAct _ = do+    files <- staticFilesDynamic $ symbolVal (Proxy @baseDir)+    pure $ Model cliAct <$> files+  siteOutput _ _ (StaticRoute path) =+    pure $ Ema.AssetStatic $ symbolVal (Proxy @baseDir) </> path++staticFilesDynamic ::+  forall m.+  (MonadIO m, MonadUnliftIO m, MonadLogger m, MonadLoggerIO m) =>+  FilePath ->+  m (Dynamic m (Map FilePath UTCTime))+staticFilesDynamic baseDir = do+  let pats = [((), "**")]+      ignorePats = [".*"]+      model0 = mempty+  Dynamic <$> UnionMount.mount baseDir pats ignorePats model0 (const handleUpdate)+  where+    handleUpdate ::+      FilePath ->+      UnionMount.FileAction () ->+      m (Map FilePath UTCTime -> Map FilePath UTCTime)+    handleUpdate fp = \case+      UnionMount.Refresh _ _ -> do+        lastAccessed <- liftIO getCurrentTime+        pure $ Map.insert fp lastAccessed+      UnionMount.Delete -> do+        pure $ Map.delete fp++-- | Like `Ema.routeUrl`, but looks up the value and appends it to URL in live-server (for force-reload in browser)+staticRouteUrl ::+  forall s baseDir.+  (IsString s, HasCallStack) =>+  Prism' FilePath (StaticRoute baseDir) ->+  RouteModel (StaticRoute baseDir) ->+  FilePath ->+  s+staticRouteUrl rp model fp =+  let lastAccessed = lookupMust fp model+      tag = toText $ formatTime defaultTimeLocale "%s" lastAccessed+      url = Ema.routeUrl rp $ StaticRoute fp+   in fromString . toString $ url <> refreshAddendum tag+  where+    -- Force the browser to reload the static file referenced+    refreshAddendum tag =+      fromMaybe "" $ do+        -- In live server, force reload all (re-added/modified) static files.+        -- In statically generated site, do it only for CSS and JS files.+        guard $+          Ema.CLI.isLiveServer (modelCliAction model)+            || takeExtension fp `List.elem` [".css", ".js"]+        pure $ "?" <> tag++lookupMust :: FilePath -> Model -> UTCTime+lookupMust fp model =+  case Map.lookup fp (modelFiles model) of+    Just lastAccessed -> lastAccessed+    Nothing -> throw $ MissingStaticFile fp++newtype MissingStaticFile = MissingStaticFile FilePath+  deriving stock (Eq, Show)+  deriving anyclass (Exception)
+ src/Ema/Route/Lib/File.hs view
@@ -0,0 +1,28 @@+module Ema.Route.Lib.File (+  FileRoute (..),+) where++import Ema.Route.Class (IsRoute (..))+import Ema.Route.Prism (+  toPrism_,+ )+import GHC.TypeLits (KnownSymbol, Symbol, symbolVal)+import Optics.Core (coercedTo, only, (%))++{- | A type-level singleton route, whose encoding is given by the symbol parameter.++ FileRoute "foo.html" encodes to "foo.html".++ TODO: Can this type be simplified? See https://stackoverflow.com/q/72755053/55246+-}+newtype FileRoute (filename :: Symbol) = FileRoute ()+  deriving stock (Eq, Ord, Show, Generic)++instance KnownSymbol fn => IsRoute (FileRoute fn) where+  type RouteModel (FileRoute fn) = ()+  routePrism () =+    toPrism_ $+      only (symbolVal (Proxy @fn))+        % coercedTo+  routeUniverse () =+    [FileRoute ()]
+ src/Ema/Route/Lib/Folder.hs view
@@ -0,0 +1,48 @@+module Ema.Route.Lib.Folder (+  FolderRoute (FolderRoute, unFolderRoute),+  prefixRoutePrism,+) where++import Data.Text qualified as T+import Ema.Route.Class (IsRoute (..))+import Ema.Route.Prism (Prism_, mapRoutePrism)+import Ema.Site (EmaSite (..), EmaStaticSite)+import GHC.TypeLits (KnownSymbol, Symbol, symbolVal)+import Optics.Core (coercedTo, prism', (%))+import System.FilePath ((</>))+import Text.Show (Show (show))++-- | A route that is prefixed at some URL prefix+newtype FolderRoute (prefix :: Symbol) r = FolderRoute {unFolderRoute :: r}+  deriving newtype (Eq, Ord, Generic)++instance (Show r, KnownSymbol prefix) => Show (FolderRoute prefix r) where+  show (FolderRoute r) = symbolVal (Proxy @prefix) <> "/:" <> Text.Show.show r++instance (IsRoute r, KnownSymbol prefix) => IsRoute (FolderRoute prefix r) where+  type RouteModel (FolderRoute prefix r) = RouteModel r+  routePrism = prefixRoutePrism @prefix @r $ routePrism @r+  routeUniverse m = FolderRoute <$> routeUniverse @r m++instance (EmaStaticSite r, KnownSymbol prefix) => EmaSite (FolderRoute prefix r) where+  type SiteArg (FolderRoute prefix r) = SiteArg r+  siteInput cliAct =+    siteInput @r cliAct+  siteOutput rp m r =+    siteOutput @r (rp % coercedTo) m (unFolderRoute r)++-- | Prefix the encoding of the given route prism.+prefixRoutePrism ::+  forall prefix r.+  KnownSymbol prefix =>+  (RouteModel r -> Prism_ FilePath r) ->+  (RouteModel r -> Prism_ FilePath (FolderRoute prefix r))+prefixRoutePrism =+  mapRoutePrism+    (prism' (prefix </>) stripPrefix)+    coercedTo+    id+  where+    prefix = symbolVal (Proxy @prefix)+    stripPrefix =+      fmap toString . T.stripPrefix (toText $ prefix <> "/") . toText
+ src/Ema/Route/Lib/Multi.hs view
@@ -0,0 +1,106 @@+{-# OPTIONS_GHC -Wno-orphans #-}++{- | Merging multiple Ema sites into one.++    This is implemented in using `sop-core`'s NS and NP types. Use as+    `MultiRoute '[MySite1, MySite2, ...]`.+-}+module Ema.Route.Lib.Multi (+  MultiRoute,+  MultiModel,+) where++import Data.SOP (I (..), NP (..), NS (..))+import Ema.Route.Class (IsRoute (..))+import Ema.Route.Prism+import Ema.Site (EmaSite (..), EmaStaticSite)+import Optics.Core (equality, iso, prism', (%))++{- | The merged site's route is represented as a n-ary sum (`NS`) of the+ sub-routes.+-}+type MultiRoute (rs :: [Type]) = NS I rs++type family MultiModel (rs :: [Type]) :: [Type] where+  MultiModel '[] = '[]+  MultiModel (r ': rs) = RouteModel r : MultiModel rs++type family MultiSiteArg (rs :: [Type]) :: [Type] where+  MultiSiteArg '[] = '[]+  MultiSiteArg (r ': rs) = SiteArg r : MultiSiteArg rs++instance IsRoute (MultiRoute '[]) where+  type RouteModel (MultiRoute '[]) = NP I '[]+  routePrism = impossiblePrism+    where+      impossiblePrism :: (NP I '[] -> Prism_ FilePath (MultiRoute '[]))+      impossiblePrism Nil =+        toPrism_ $ prism' (\case {}) (const Nothing)+  routeUniverse Nil = mempty++instance+  ( IsRoute r+  , IsRoute (MultiRoute rs)+  , RouteModel (MultiRoute rs) ~ NP I (MultiModel rs)+  ) =>+  IsRoute (MultiRoute (r ': rs))+  where+  type RouteModel (MultiRoute (r ': rs)) = NP I (RouteModel r ': MultiModel rs)+  routePrism =+    routePrism @r+      `nsRoutePrism` routePrism @(MultiRoute rs)+  routeUniverse (I m :* ms) =+    fmap (toNS . Left) (routeUniverse @r m)+      <> fmap (toNS . Right) (routeUniverse @(MultiRoute rs) ms)++instance EmaSite (MultiRoute '[]) where+  type SiteArg (MultiRoute '[]) = NP I '[]+  siteInput _ Nil = pure $ pure Nil+  siteOutput _ Nil = \case {}++instance+  ( EmaStaticSite r+  , EmaStaticSite (MultiRoute rs)+  , SiteArg (MultiRoute rs) ~ NP I (MultiSiteArg rs)+  , RouteModel (MultiRoute rs) ~ NP I (MultiModel rs)+  ) =>+  EmaSite (MultiRoute (r ': rs))+  where+  type SiteArg (MultiRoute (r ': rs)) = NP I (MultiSiteArg (r ': rs))+  siteInput cliAct (I i :* is) = do+    m <- siteInput @r cliAct i+    ms <- siteInput @(MultiRoute rs) cliAct is+    pure $ curry toNP <$> m <*> ms+  siteOutput rp (I m :* ms) =+    fromNS+      >>> either+        (siteOutput @r (rp % headRoute) m)+        (siteOutput @(MultiRoute rs) (rp % tailRoute) ms)+    where+      tailRoute =+        (prism' (toNS . Right) (fromNS >>> rightToMaybe))+      headRoute =+        (prism' (toNS . Left) (fromNS >>> leftToMaybe))++-- | Like `eitherRoutePrism` but uses sop-core types instead of Either/Product.+nsRoutePrism ::+  (a -> Prism_ FilePath r) ->+  (NP I as -> Prism_ FilePath (NS I rs)) ->+  (NP I (a ': as) -> Prism_ FilePath (NS I (r ': rs)))+nsRoutePrism a b =+  eitherRoutePrism a b+    & mapRoutePrism equality (iso toNS fromNS) fromNP++fromNP :: NP I (a ': as) -> (a, NP I as)+fromNP (I x :* y) = (x, y)++toNP :: (a, NP I as) -> NP I (a ': as)+toNP (x, y) = I x :* y++fromNS :: NS I (a ': as) -> Either a (NS I as)+fromNS = \case+  Z (I x) -> Left x+  S xs -> Right xs++toNS :: Either a (NS I as) -> NS I (a ': as)+toNS = either (Z . I) S
+ src/Ema/Route/Prism.hs view
@@ -0,0 +1,56 @@+module Ema.Route.Prism (+  module X,++  -- * Handy encoders+  eitherRoutePrism,++  -- * Handy lenses+  htmlSuffixPrism,+  stringIso,+  showReadPrism,+) where++import Data.Text qualified as T+import Ema.Route.Prism.Check as X+import Ema.Route.Prism.Type as X+import Optics.Core (Iso', Prism', iso, preview, prism', review)++stringIso :: (ToString a, IsString a) => Iso' String a+stringIso = iso fromString toString++showReadPrism :: (Show a, Read a) => Prism' String a+showReadPrism = prism' show readMaybe++htmlSuffixPrism :: Prism' FilePath FilePath+htmlSuffixPrism = prism' (<> ".html") (fmap toString . T.stripSuffix ".html" . toText)++{- | Returns a new route `Prism_` that supports *either* of the input routes.++  The resulting route `Prism_`'s model type becomes the *product* of the input models.+-}+eitherRoutePrism ::+  (a -> Prism_ FilePath r1) ->+  (b -> Prism_ FilePath r2) ->+  ((a, b) -> Prism_ FilePath (Either r1 r2))+eitherRoutePrism enc1 enc2 (m1, m2) =+  toPrism_ $ eitherPrism (fromPrism_ $ enc1 m1) (fromPrism_ $ enc2 m2)++{- | Given two @Prism'@'s whose filepaths are distinct (ie., both @a@ and @b@+ encode to distinct filepaths), return a new @Prism'@ that combines both.++ If this distinctness property does not hold between the input @Prism'@'s, then+ the resulting @Prism'@ will not be lawful.+-}+eitherPrism :: Prism' FilePath a -> Prism' FilePath b -> Prism' FilePath (Either a b)+eitherPrism p1 p2 =+  prism'+    ( either+        (review p1)+        (review p2)+    )+    ( \fp ->+        asum+          [ Left <$> preview p1 fp+          , Right <$> preview p2 fp+          ]+    )
+ src/Ema/Route/Prism/Check.hs view
@@ -0,0 +1,93 @@+module Ema.Route.Prism.Check (+  checkRoutePrismGivenFilePath,+  checkRoutePrismGivenRoute,+) where++import Control.Monad.Writer (Writer, runWriter, tell)+import Data.Text qualified as T+import Ema.Route.Prism.Type (Prism_, fromPrism_)+import Optics.Core (Prism', preview, review)+import System.FilePath ((</>))++checkRoutePrismGivenRoute ::+  (HasCallStack, Eq r, Show r) =>+  (a -> Prism_ FilePath r) ->+  a ->+  r ->+  Either Text ()+checkRoutePrismGivenRoute enc a r =+  let s = review (fromPrism_ $ enc a) r+   in checkRoutePrism enc a r s++checkRoutePrismGivenFilePath ::+  (HasCallStack, Eq r, Show r) =>+  (a -> Prism_ FilePath r) ->+  a ->+  FilePath ->+  Either (r, [(FilePath, Text)]) (Maybe r)+checkRoutePrismGivenFilePath enc a s = do+  -- We should treat /foo, /foo.html and /foo/index.html as equivalent.+  let candidates = [s, s <> ".html", s </> "index.html"]+      rp = fromPrism_ $ enc a+  case asum (preview rp <$> candidates) of+    Nothing -> pure Nothing+    Just r -> do+      -- All candidates must be checked, and if even one passes - we let this+      -- route go through.+      let (failed, passed) =+            partitionEithers $+              candidates <&> \candidate ->+                case checkRoutePrism enc a r candidate of+                  Left err -> Left (candidate, err)+                  Right () -> Right ()+      if null passed+        then Left (r, failed)+        else Right (Just r)++checkRoutePrism :: (Eq r, Show r) => (a -> Prism_ FilePath r) -> a -> r -> FilePath -> Either Text ()+checkRoutePrism p a r s =+  let (valid, checkLog) =+        runWriter $ routePrismIsLawfulFor p a r s+   in if valid+        then Right ()+        else Left $ "Unlawful route prism for route value '" <> show r <> "'\n- " <> T.intercalate "\n- " checkLog++{- | Check if the route @Prism_@ is lawful.++  A route @Prism_@ is lawful if its conversions both the ways form an+  isomorphism for a given value.++  Returns a Writer reporting logs.+-}+routePrismIsLawfulFor ::+  forall ctx a.+  (Eq a, Show a) =>+  (ctx -> Prism_ FilePath a) ->+  ctx ->+  a ->+  FilePath ->+  Writer [Text] Bool+routePrismIsLawfulFor enc =+  prismIsLawfulFor . fromPrism_ . enc++prismIsLawfulFor ::+  forall s a.+  (Eq a, Eq s, Show a, ToText s) =>+  Prism' s a ->+  a ->+  s ->+  Writer [Text] Bool+prismIsLawfulFor p a s = do+  -- TODO: The logging here could be improved.+  -- log $ "Testing Partial ISO law for " <> show a <> " and " <> toText s+  let s' :: s = review p a+  -- log $ "Prism actual encoding: " <> toText s'+  let ma' :: Maybe a = preview p s'+  -- log $ "Decoding of that encoding: " <> show ma'+  unless (s == s') $+    log $ toText s <> " /= " <> toText s' <> " (encoding of '" <> show a <> "')"+  unless (Just a == ma') $+    log $ show (Just a) <> " /= " <> show ma' <> " (decoding of " <> toText s <> ")"+  pure $ (s == s') && (Just a == ma')+  where+    log = tell . one
+ src/Ema/Route/Prism/Type.hs view
@@ -0,0 +1,46 @@+module Ema.Route.Prism.Type where++import Optics.Core (A_Prism, Is, NoIx, Optic', Prism', castOptic, preview, prism', review, (%))++--  DerivingVia prevents us from directly using Prism' here+--  https://stackoverflow.com/q/71489589/55246++{- | Isomorphic to @Prism' s a@, but coercion-friendly.++ Use `fromPrism_` and `toPrism_` to convert between the optics @Prism'@ and this+ @Prism_@.+-}+type Prism_ s a = (a -> s, s -> Maybe a)++-- | Convert a `Prism_` to a @Prism'@.+fromPrism_ :: Prism_ s a -> Prism' s a+fromPrism_ = uncurry prism'++-- | Convert a @Prism'@ to a `Prism_`.+toPrism_ :: Prism' s a -> Prism_ s a+toPrism_ = review &&& preview++-- | map over the filepath, route and model of the given route prism.+mapRoutePrism ::+  (pr `Is` A_Prism, pf `Is` A_Prism) =>+  -- | How to transform the encoded `FilePath`+  Optic' pf NoIx FilePath FilePath ->+  -- | How to transform the decode route+  Optic' pr NoIx r1 r2 ->+  -- | How to transform (contramap) the resultant model+  (b -> a) ->+  -- | The route prism to fmap.+  (a -> Prism_ FilePath r1) ->+  (b -> Prism_ FilePath r2)+mapRoutePrism (castOptic -> fp) (castOptic -> rp) m enc =+  toPrism_ . cpmap fp rp m (fromPrism_ . enc)+  where+    cpmap ::+      forall a b c d x y.+      Prism' b a ->+      Prism' c d ->+      (y -> x) ->+      (x -> Prism' a c) ->+      (y -> Prism' b d)+    cpmap p q f r x =+      p % r (f x) % q
+ src/Ema/Route/Url.hs view
@@ -0,0 +1,77 @@+{-# LANGUAGE InstanceSigs #-}++module Ema.Route.Url (+  -- * Create URL from route+  routeUrl,+  routeUrlWith,+  UrlStrategy (..),+  urlToFilePath,+) where++import Data.Aeson (FromJSON (parseJSON), Value)+import Data.Aeson.Types (Parser)+import Data.Text qualified as T+import Network.URI.Slug qualified as Slug+import Optics.Core (Prism', review)++{- | Return the relative URL of the given route++ Note: when using relative URLs it is imperative to set the `<base>` URL to your+ site's base URL or path (typically just `/`). Otherwise you must accordingly+ make these URLs absolute yourself.+-}+routeUrlWith :: HasCallStack => UrlStrategy -> Prism' FilePath r -> r -> Text+routeUrlWith urlStrategy rp =+  relUrlFromPath . review rp+  where+    relUrlFromPath :: FilePath -> Text+    relUrlFromPath fp =+      case toString <$> T.stripSuffix (urlStrategySuffix urlStrategy) (toText fp) of+        Just htmlFp ->+          case nonEmpty (filepathToUrl htmlFp) of+            Nothing ->+              ""+            Just (removeLastIfOneOf ["index", "index.html"] -> partsSansIndex) ->+              T.intercalate "/" partsSansIndex+        Nothing ->+          T.intercalate "/" $ filepathToUrl fp+      where+        removeLastIfOneOf :: Eq a => [a] -> NonEmpty a -> [a]+        removeLastIfOneOf x xs =+          if last xs `elem` x+            then init xs+            else toList xs+        urlStrategySuffix = \case+          UrlPretty -> ".html"+          UrlDirect -> ""++filepathToUrl :: FilePath -> [Text]+filepathToUrl =+  fmap (Slug.encodeSlug . fromString @Slug.Slug . toString) . T.splitOn "/" . toText++urlToFilePath :: Text -> FilePath+urlToFilePath =+  toString . T.intercalate "/" . fmap (Slug.unSlug . Slug.decodeSlug) . T.splitOn "/"++-- | Like `routeUrlWith` but uses @UrlDirect@ strategy+routeUrl :: HasCallStack => Prism' FilePath r -> r -> Text+routeUrl =+  routeUrlWith UrlDirect++-- | How to produce URL paths from routes+data UrlStrategy+  = -- | Use pretty URLs. The route encoding "foo/bar.html" produces "foo/bar" as URL.+    UrlPretty+  | -- | Use filepaths as URLs. The route encoding "foo/bar.html" produces "foo/bar.html" as URL.+    UrlDirect+  deriving stock (Eq, Show, Ord, Generic)++instance FromJSON UrlStrategy where+  parseJSON val =+    f UrlPretty "pretty" val <|> f UrlDirect "direct" val+    where+      f :: UrlStrategy -> Text -> Value -> Parser UrlStrategy+      f c s v = do+        x <- parseJSON v+        guard $ x == s+        pure c
src/Ema/Server.hs view
@@ -1,83 +1,97 @@+{-# LANGUAGE AllowAmbiguousTypes #-} {-# LANGUAGE QuasiQuotes #-}+{-# LANGUAGE RecordWildCards #-}+{-# LANGUAGE TemplateHaskell #-}  module Ema.Server where  import Control.Concurrent.Async (race)-import Control.Exception (catch, try)+import Control.Exception (try) import Control.Monad.Logger-import Data.Default+import Data.FileEmbed import Data.LVar (LVar) import Data.LVar qualified as LVar import Data.Text qualified as T-import Ema.Asset-import Ema.Class (Ema (..))-import GHC.IO.Unsafe (unsafePerformIO)+import Ema.Asset (+  Asset (AssetGenerated, AssetStatic),+  Format (Html, Other),+ )+import Ema.CLI (Host (unHost))+import Ema.Route.Class (IsRoute (RouteModel, routePrism))+import Ema.Route.Prism (+  checkRoutePrismGivenFilePath,+  fromPrism_,+ )+import Ema.Route.Url (urlToFilePath)+import Ema.Site (EmaSite (siteOutput), EmaStaticSite) import NeatInterpolation (text) import Network.HTTP.Types qualified as H import Network.Wai qualified as Wai+import Network.Wai.Handler.Warp (Port) import Network.Wai.Handler.Warp qualified as Warp import Network.Wai.Handler.WebSockets qualified as WaiWs import Network.Wai.Middleware.Static qualified as Static import Network.WebSockets (ConnectionException) import Network.WebSockets qualified as WS-import System.FilePath ((</>))+import Optics.Core (review) import Text.Printf (printf) import UnliftIO (MonadUnliftIO)---- | Host string to start the server on.-newtype Host = Host {unHost :: Text}-  deriving newtype (Eq, Show, Ord, IsString)---- | Port number to bind the server on.-newtype Port = Port {unPort :: Int}-  deriving newtype (Eq, Show, Ord, Num, Read)--instance Default Host where-  def = "127.0.0.1"--instance Default Port where-  def = 8000+import UnliftIO.Concurrent (threadDelay)+import UnliftIO.Exception (catch)  runServerWithWebSocketHotReload ::-  forall model route m.-  ( Ema model route,-    Show route,-    MonadIO m,-    MonadUnliftIO m,-    MonadLoggerIO m+  forall r m.+  ( Show r+  , MonadIO m+  , MonadUnliftIO m+  , MonadLoggerIO m+  , Eq r+  , IsRoute r+  , EmaStaticSite r   ) =>   Host ->-  Port ->-  LVar model ->-  (model -> route -> Asset LByteString) ->+  Maybe Port ->+  LVar (RouteModel r) ->   m ()-runServerWithWebSocketHotReload host port model render = do-  let settings =+runServerWithWebSocketHotReload host mport model = do+  logger <- askLoggerIO+  let runM = flip runLoggingT logger+      settings =         Warp.defaultSettings-          & Warp.setPort (unPort port)           & Warp.setHost (fromString . toString . unHost $ host)-  logger <- askLoggerIO--  logInfoN "=============================================="-  logInfoN $ "Ema live server running: http://" <> unHost host <> ":" <> show port-  logInfoN "=============================================="-  liftIO $-    Warp.runSettings settings $-      assetsMiddleware $+      app =         WaiWs.websocketsOr           WS.defaultConnectionOptions-          (flip runLoggingT logger . wsApp)+          (runM . wsApp)           (httpApp logger)+      banner port = do+        logInfoNS "ema" "==============================================="+        logInfoNS "ema" $ "Ema live server RUNNING: http://" <> unHost host <> ":" <> show port+        logInfoNS "ema" "==============================================="+  liftIO $ warpRunSettings settings mport (runM . banner) app   where+    enc = routePrism @r+    -- Like Warp.runSettings but takes *optional* port. When no port is set, a+    -- free (random) port is used.+    warpRunSettings :: Warp.Settings -> Maybe Port -> (Port -> IO a) -> Wai.Application -> IO ()+    warpRunSettings settings mPort banner app = do+      case mPort of+        Nothing ->+          Warp.withApplicationSettings settings (pure app) $ \port -> do+            void $ banner port+            threadDelay maxBound+        Just port -> do+          void $ banner port+          Warp.runSettings (settings & Warp.setPort port) app     wsApp pendingConn = do       conn :: WS.Connection <- lift $ WS.acceptRequest pendingConn       logger <- askLoggerIO       lift $-        WS.withPingThread conn 30 (pure ()) $+        WS.withPingThread conn 30 pass $           flip runLoggingT logger $ do             subId <- LVar.addListener model             let log lvl (s :: Text) =-                  logWithoutLoc (toText @String $ printf "WS.Client.%.2d" subId) lvl s+                  logWithoutLoc (toText @String $ printf "ema.ws.%.2d" subId) lvl s             log LevelInfo "Connected"             let askClientForRoute = do                   msg :: Text <- liftIO $ WS.receiveData conn@@ -90,20 +104,23 @@                   pure $ routeFromPathInfo val pathInfo                 sendRouteHtmlToClient pathInfo s = do                   decodeRouteWithCurrentModel pathInfo >>= \case-                    Nothing ->+                    Left err -> do+                      log LevelError $ badRouteEncodingMsg err+                      liftIO $ WS.sendTextData conn $ emaErrorHtmlResponse $ badRouteEncodingMsg err+                    Right Nothing ->                       liftIO $ WS.sendTextData conn $ emaErrorHtmlResponse decodeRouteNothingMsg-                    Just r -> do-                      case renderCatchingErrors logger s r of+                    Right (Just r) -> do+                      renderCatchingErrors s r >>= \case                         AssetStatic staticPath ->                           -- HACK: Websocket client should check for REDIRECT prefix.                           -- Not bothering with JSON to avoid having to JSON parse every HTML dump.                           liftIO $ WS.sendTextData conn $ "REDIRECT " <> toText staticPath                         AssetGenerated Html html ->-                          liftIO $ WS.sendTextData conn $ html <> emaStatusHtml+                          liftIO $ WS.sendTextData conn $ html <> toLazy wsClientHtml                         AssetGenerated Other _s ->                           -- HACK: Websocket client should check for REDIRECT prefix.                           -- Not bothering with JSON to avoid having to JSON parse every HTML dump.-                          liftIO $ WS.sendTextData conn $ "REDIRECT " <> toText (encodeRoute s r)+                          liftIO $ WS.sendTextData conn $ "REDIRECT " <> toText (review (fromPrism_ $ enc s) r)                       log LevelDebug $ " ~~> " <> show r                 loop = flip runLoggingT logger $ do                   -- Notice that we @askClientForRoute@ in succession twice here.@@ -130,7 +147,7 @@                         sendRouteHtmlToClient mNextRoute =<< LVar.get model                         lift loop             liftIO (try loop) >>= \case-              Right () -> pure ()+              Right () -> pass               Left (connExc :: ConnectionException) -> do                 case connExc of                   WS.CloseRequest _ (decodeUtf8 -> reason) ->@@ -138,287 +155,106 @@                   _ ->                     log LevelError $ "Websocket error: " <> show connExc                 LVar.removeListener model subId-    assetsMiddleware =-      Static.static     httpApp logger req f = do       flip runLoggingT logger $ do         val <- LVar.get model         let path = Wai.pathInfo req             mr = routeFromPathInfo val path-        logInfoNS "HTTP" $ show path <> " as " <> show mr+        logInfoNS "ema.http" $ "GET " <> ("/" <> T.intercalate "/" path) <> " as " <> show mr         case mr of-          Nothing ->-            liftIO $ f $ Wai.responseLBS H.status404 [(H.hContentType, "text/plain")] $ encodeUtf8 decodeRouteNothingMsg-          Just r -> do-            case renderCatchingErrors logger val r of+          Left err -> do+            logErrorNS "App" $ badRouteEncodingMsg err+            let s = emaErrorHtmlResponse (badRouteEncodingMsg err) <> wsClientJS+            liftIO $ f $ Wai.responseLBS H.status500 [(H.hContentType, "text/html")] s+          Right Nothing -> do+            let s = emaErrorHtmlResponse decodeRouteNothingMsg <> wsClientJS+            liftIO $ f $ Wai.responseLBS H.status404 [(H.hContentType, "text/html")] s+          Right (Just r) -> do+            renderCatchingErrors val r >>= \case               AssetStatic staticPath -> do                 let mimeType = Static.getMimeType staticPath                 liftIO $ f $ Wai.responseFile H.status200 [(H.hContentType, mimeType)] staticPath Nothing               AssetGenerated Html html -> do-                let s = html <> emaStatusHtml <> wsClientShim+                let s = html <> toLazy wsClientHtml <> wsClientJS                 liftIO $ f $ Wai.responseLBS H.status200 [(H.hContentType, "text/html")] s               AssetGenerated Other s -> do-                let mimeType = Static.getMimeType $ encodeRoute val r+                let mimeType = Static.getMimeType $ review (fromPrism_ $ enc val) r                 liftIO $ f $ Wai.responseLBS H.status200 [(H.hContentType, mimeType)] s-    renderCatchingErrors logger m r =-      unsafeCatch (render m r) $ \(err :: SomeException) ->-        unsafePerformIO $ do-          -- Log the error first.-          flip runLoggingT logger $ logErrorNS "App" $ show @Text err-          pure $-            AssetGenerated Html . emaErrorHtml $-              show @Text err+    renderCatchingErrors m r =+      catch (siteOutput (fromPrism_ $ enc m) m r) $ \(err :: SomeException) -> do+        -- Log the error first.+        logErrorNS "App" $ show @Text err+        pure $+          AssetGenerated Html . mkHtmlErrorMsg $+            show @Text err     routeFromPathInfo m =       decodeUrlRoute m . T.intercalate "/"-    -- TODO: It would be good have this also get us the stack trace.-    unsafeCatch :: Exception e => a -> (e -> a) -> a-    unsafeCatch x f = unsafePerformIO $ catch (seq x $ pure x) (pure . f)+    -- Decode an URL path into a route+    --+    -- This function is used only in live server. If the route is not+    -- isomoprhic, this returns a Left, with the mismatched encoding.+    decodeUrlRoute :: RouteModel r -> Text -> Either (BadRouteEncoding r) (Maybe r)+    decodeUrlRoute m (urlToFilePath -> s) = do+      case checkRoutePrismGivenFilePath enc m s of+        Left (r, log) -> Left $ BadRouteEncoding s r log+        Right mr -> Right mr  -- | A basic error response for displaying in the browser emaErrorHtmlResponse :: Text -> LByteString emaErrorHtmlResponse err =-  emaErrorHtml err <> emaStatusHtml+  mkHtmlErrorMsg err <> toLazy wsClientHtml -emaErrorHtml :: Text -> LByteString-emaErrorHtml s =-  encodeUtf8 $-    "<html><head><meta charset=\"UTF-8\"></head><body><h1>Ema App threw an exception</h1><pre style=\"border: 1px solid; padding: 1em 1em 1em 1em;\">"-      <> s-      <> "</pre><p>Once you fix the source of the error, this page will automatically refresh.</body>"+mkHtmlErrorMsg :: Text -> LByteString+mkHtmlErrorMsg s =+  encodeUtf8 . T.replace "MESSAGE" s . decodeUtf8 $ $(embedFile "www/ema-error.html") --- | Return the equivalent of WAI's @pathInfo@, from the raw path string--- (`document.location.pathname`) the browser sends us.+{- | Return the equivalent of WAI's @pathInfo@, from the raw path string+ (`document.location.pathname`) the browser sends us.+-} pathInfoFromWsMsg :: Text -> [Text] pathInfoFromWsMsg =   filter (/= "") . T.splitOn "/" . T.drop 1 --- | Decode a URL path into a route------ This function is used only in live server.-decodeUrlRoute :: forall model route. Ema model route => model -> Text -> Maybe route-decodeUrlRoute model (toString -> s) = do-  decodeRoute @model @route model s-    <|> decodeRoute @model @route model (s <> ".html")-    <|> decodeRoute @model @route model (s </> "index.html")- decodeRouteNothingMsg :: Text-decodeRouteNothingMsg = "Ema: 404 (decodeRoute returned Nothing)"+decodeRouteNothingMsg = "Ema: 404 (route decoding returned Nothing)" +data BadRouteEncoding r = BadRouteEncoding+  { _bre_urlFilePath :: FilePath+  , _bre_decodedRoute :: r+  , _bre_checkLog :: [(FilePath, Text)]+  }+  deriving stock (Show)++badRouteEncodingMsg :: Show r => BadRouteEncoding r -> Text+badRouteEncodingMsg BadRouteEncoding {..} =+  toText $+    "A route Prism' is unlawful.\n\nThe URL '" <> toText _bre_urlFilePath+      <> "' decodes to route '"+      <> show _bre_decodedRoute+      <> "', but it is not isomporphic on any of the allowed candidates: \n\n"+      <> T.intercalate+        "\n\n"+        ( _bre_checkLog <&> \(candidate, log) ->+            "## Candidate '" <> toText candidate <> "':\n" <> log+        )+      <> " \n\nYou should make the relevant routePrism lawful to fix this issue."++wsClientHtml :: ByteString+wsClientHtml = $(embedFile "www/ema-indicator.html")++wsClientJSShim :: Text+wsClientJSShim = decodeUtf8 $(embedFile "www/ema-shim.js")+ -- Browser-side JavaScript code for interacting with the Haskell server-wsClientShim :: LByteString-wsClientShim =+wsClientJS :: LByteString+wsClientJS =   encodeUtf8     [text|         <script type="module" src="https://cdn.jsdelivr.net/npm/morphdom@2.6.1/dist/morphdom-umd.min.js"></script>          <script type="module">--        function htmlToElem(html) {-          let temp = document.createElement('template');-          html = html.trim(); // Never return a space text node as a result-          temp.innerHTML = html;-          return temp.content.firstChild;-        };--        // Unlike setInnerHtml, this patches the Dom in place-        function setHtml(elm, html) {-          var htmlElem = htmlToElem(html);-          morphdom(elm, html);-          // Re-add <script> tags, because just DOM diff applying is not enough.-          reloadScripts(elm);-        };--        // FIXME: This doesn't reliably work across all JS.-        // See also the HACK below in one of the invocations.-        function reloadScripts(elm) {-          Array.from(elm.querySelectorAll("script")).forEach(oldScript => {-            const newScript = document.createElement("script");-            Array.from(oldScript.attributes)-              .forEach(attr => newScript.setAttribute(attr.name, attr.value));-            newScript.appendChild(document.createTextNode(oldScript.innerHTML));-            oldScript.parentNode.replaceChild(newScript, oldScript);-          });-        };--        // Ema Status indicator-        const messages = {-          connected: "Connected",-          reloading: "Reloading",-          connecting: "Connecting to the server",-          disconnected: "Disconnected - try reloading the window"-        };-        function setIndicators(connected, reloading, connecting, disconnected) {-          const is = { connected, reloading, connecting, disconnected }--          for (const i in is) {-            document.getElementById(`ema-$${i}`).style.display =-              is[i] ? "block" : "none"-            if(is[i])-              document.getElementById('ema-message').innerText = messages[i]-          };-          document.getElementById("ema-indicator").style.display = "block";-        };-        window.connected    = () => setIndicators(true,  false, false, false)-        window.reloading    = () => setIndicators(false, true,  false, false)-        window.connecting   = () => setIndicators(false, false, true,  false)-        window.disconnected = () => setIndicators(false, false, false, true)-        window.hideIndicator = () => {-          document.getElementById("ema-indicator").style.display = "none";-        };--        // Base URL path - for when the ema site isn't served at "/"-        const baseHref = document.getElementsByTagName("base")[0]?.href;-        const basePath = baseHref ? new URL(baseHref).pathname : "/";--        // Use TLS for websocket iff the current page is also served with TLS-        const wsProto = window.location.protocol === "https:" ? "wss://" : "ws://";-        const wsUrl = wsProto + window.location.host + basePath;--        // WebSocket logic: watching for server changes & route switching-        function init(reconnecting) {-          // The route current DOM is displaying-          let routeVisible = document.location.pathname;--          const verb = reconnecting ? "Reopening" : "Opening";-          console.log(`ema: $${verb} conn $${wsUrl} ...`);-          window.connecting();-          let ws = new WebSocket(wsUrl);--          function sendObservePath(path) {-            const relPath = path.startsWith(basePath) ? path.slice(basePath.length - 1) : path;-            console.debug(`ema: requesting $${relPath}`);-            ws.send(relPath);-          }--          // Call this, then the server will send update *once*. Call again for-          // continous monitoring.-          function watchCurrentRoute() {-            console.log(`ema: ⏿ Observing changes to $${document.location.pathname}`);-            sendObservePath(document.location.pathname);-          };--          function switchRoute(path) {-             console.log(`ema: → Switching to $${path}`);-             window.history.pushState({}, "", path);-             sendObservePath(path);-          }--          function handleRouteClicks(e) {-              const origin = e.target.closest("a");-              if (origin) {-                if (window.location.host === origin.host && origin.getAttribute("target") != "_blank") {-                  switchRoute(origin.pathname);-                  e.preventDefault();-                };-              }-            };-          // Intercept route click events, and ask server for its HTML whilst-          // managing history state.-          window.addEventListener(`click`, handleRouteClicks);--          ws.onopen = () => {-            console.log(`ema: ... connected!`);-            // window.connected();-            window.hideIndicator();-            if (!reconnecting) {-              // HACK: We have to reload <script>'s here on initial page load-              // here, so as to make Twind continue to function on the *next*-              // route change. This is not a problem with *subsequent* (ie. 2nd-              // or latter) route clicks, because those have already called-              // reloadScripts at least once.-              reloadScripts(document.documentElement);-            };-            watchCurrentRoute();-          };--          ws.onclose = () => {-            console.log("ema: reconnecting ..");-            window.removeEventListener(`click`, handleRouteClicks);-            window.reloading();-            // Reconnect after as small a time is possible, then retry again. -            // ghcid can take 1s or more to reboot. So ideally we need an-            // exponential retry logic.-            // -            // Note that a slow delay (200ms) may often cause websocket-            // connection error (ghcid hasn't rebooted yet), which cannot be-            // avoided as it is impossible to trap this error and handle it.-            // You'll see a big ugly error in the console.-            setTimeout(function () {init(true);}, 400);-          };--          ws.onmessage = evt => {-            if (evt.data.startsWith("REDIRECT ")) {-              console.log("ema: redirect");-              document.location.href = evt.data.slice("REDIRECT ".length);-            } else {-              console.log("ema: ✍ Patching DOM");-              setHtml(document.documentElement, evt.data);-              if (routeVisible != document.location.pathname) {-                // This is a new route switch; scroll up.-                window.scrollTo({ top: 0});-                routeVisible = document.location.pathname;-              } -              watchCurrentRoute();-            };-          };-          window.onbeforeunload = evt => { ws.close(); };-          window.onpagehide = evt => { ws.close(); };--          // When the user clicks the back button, resume watching the URL in-          // the addressback, which has the effect of loading it immediately.-          window.onpopstate = function(e) {-            watchCurrentRoute();-          };--          // API for user invocations -          window.ema = {-            switchRoute: switchRoute-          };-        };+        ${wsClientJSShim}                  window.onpageshow = function () { init(false) };         </script>     |]--emaStatusHtml :: LByteString-emaStatusHtml =-  encodeUtf8-    [text|-      <div class="absolute top-0 left-0 p-2" style="display: none;" id="ema-indicator">-        <div-          class="-            flex overflow-hidden items-center p-2 text-xs gap-2-            h-8 border-2 border-gray-200 bg-white rounded-full shadow-lg-            transition-[width,height] duration-500 ease-in-out w-8 hover:w-full-          "-          id="ema-status"-          title="Ema Status"-        >-          <div-            hidden-            class="flex-none w-3 h-3 bg-green-600 rounded-full"-            id="ema-connected"-          ></div>-          <div-            hidden-            class="flex-none w-3 h-3 rounded-full animate-spin bg-gradient-to-r from-blue-300 to-blue-600"-            id="ema-reloading"-          ></div>-          <div-            hidden-            class="flex-none w-3 h-3 bg-yellow-500 rounded-full"-            id="ema-connecting"-          >-            <div-              class="flex-none w-3 h-3 bg-yellow-500 rounded-full animate-ping"-            ></div>-          </div>-          <div-            hidden-            class="flex-none w-3 h-3 bg-red-500 rounded-full"-            id="ema-disconnected"-          ></div>-          <p class="whitespace-nowrap" id="ema-message"></p>-        </div>-      </div>-  |]
+ src/Ema/Site.hs view
@@ -0,0 +1,74 @@+{-# LANGUAGE AllowAmbiguousTypes #-}++module Ema.Site (+  EmaSite (..),+  EmaStaticSite,+) where++import Control.Monad.Logger (MonadLoggerIO)+import Data.Some (Some)+import Ema.Asset (Asset)+import Ema.CLI qualified as CLI+import Ema.Dynamic (Dynamic)+import Ema.Route.Class (IsRoute (RouteModel))+import Optics.Core (Prism')+import UnliftIO (MonadUnliftIO)++{- | Typeclass to orchestrate an Ema site++  Given a route `r` from the class of `IsRoute` types, instantiating EmaSite+  on it enables defining the site build pipeline as follows:++  @+  SiteArg -> siteInput -> Dynamic model --[r, model]--> siteOutput+  @++  - `SiteArg` is typically not used, but it can be used to pass command line+  arguments and other such settings.+  - `siteInput` returns a time-varying value (Dynamic) representing the data for+  your static site.+  - `siteOutput` takes this data model (oneshot value) and returns the generated+  content (usually HTML asset, per `SiteOutput`) for the given route.++  Finally, `Ema.App.runSite @r arg` (where `arg` is of type `SiteArg`) is run+  from the `main` entry point to run your Ema site.+-}+class IsRoute r => EmaSite r where+  -- | `SiteArg` is typically settings from the environment (config file, or+  --    command-line arguments) that your Dynamic-producing `siteInput` function+  --    consumes as argument.+  type SiteArg r :: Type++  -- By default Ema sites have no site arguments.+  type SiteArg r = ()++  -- | Type of the value returned by `siteOutput`. Usually `Asset LByteString`+  -- but it can be anything.+  type SiteOutput r :: Type++  type SiteOutput r = Asset LByteString++  -- | Get the model's time-varying value as a `Dynamic`.+  --+  --    If your model is not time-varying, use `pure` to produce a constant value.+  siteInput ::+    forall m.+    (MonadIO m, MonadUnliftIO m, MonadLoggerIO m) =>+    Some CLI.Action ->+    -- | The value passed by the programmer to `Ema.App.runSite`+    SiteArg r ->+    -- | Time-varying value of the model. If your model is not time-varying, use+    -- `pure` to produce a constant value.+    m (Dynamic m (RouteModel r))++  -- | Return the output (typically an `Asset`) for the given route and model.+  siteOutput ::+    forall m.+    (MonadIO m, MonadLoggerIO m) =>+    Prism' FilePath r ->+    RouteModel r ->+    r ->+    m (SiteOutput r)++-- | Like `EmaSite` but `SiteOutput` is a bytestring `Asset`.+type EmaStaticSite r = (EmaSite r, SiteOutput r ~ Asset LByteString)
+ src/GHC/TypeLits/Extra.hs view
@@ -0,0 +1,15 @@+{-# LANGUAGE UndecidableInstances #-}++module GHC.TypeLits.Extra where++import GHC.TypeLits (TypeError)++class Impossible where+  impossible :: a++{- | Like TypeError but suitable for use in type-classes to avoid `undefined`++ cf. https://stackoverflow.com/a/72783230/55246+-}+type family TypeErr t where+  TypeErr t = (TypeError t, Impossible)
+ src/GHC/TypeLits/Extra/Symbol.hs view
@@ -0,0 +1,63 @@+{-# LANGUAGE UndecidableInstances #-}++module GHC.TypeLits.Extra.Symbol (+  StripPrefix,+  ToLower,+) where++import GHC.TypeLits (ConsSymbol, Symbol, UnconsSymbol)++-- | Strip `prefix` from `symbol`. Return `symbol` as-is if the prefix doesn't match.+type family StripPrefix (prefix :: Symbol) (symbol :: Symbol) :: Symbol where+  StripPrefix prefix symbol =+    FromMaybe+      symbol+      (StripPrefix' (UnconsSymbol prefix) (UnconsSymbol symbol))++-- | Strip `prefix` from `symbol`. Return Nothing if the prefix doesn't match.+type family StripPrefix' (prefix :: Maybe (Char, Symbol)) (symbol :: Maybe (Char, Symbol)) :: Maybe Symbol where+  StripPrefix' 'Nothing 'Nothing = 'Just ""+  StripPrefix' 'Nothing ( 'Just '(x, xs)) = 'Just (ConsSymbol x xs)+  StripPrefix' _p 'Nothing = 'Nothing+  StripPrefix' ( 'Just '(p, ps)) ( 'Just '(p, ss)) = StripPrefix' (UnconsSymbol ps) (UnconsSymbol ss)+  StripPrefix' ( 'Just '(p, ps)) ( 'Just '(_, ss)) = 'Nothing++type family ToLower (sym :: Symbol) :: Symbol where+  ToLower sym = ToLower' (UnconsSymbol sym)++type family ToLower' (pair :: Maybe (Char, Symbol)) :: Symbol where+  ToLower' 'Nothing = ""+  ToLower' ( 'Just '(c, cs)) = ConsSymbol (ToLowerC c) (ToLower' (UnconsSymbol cs))++type family ToLowerC (c :: Char) :: Char where+  ToLowerC 'A' = 'a'+  ToLowerC 'B' = 'b'+  ToLowerC 'C' = 'c'+  ToLowerC 'D' = 'd'+  ToLowerC 'E' = 'e'+  ToLowerC 'F' = 'f'+  ToLowerC 'G' = 'g'+  ToLowerC 'H' = 'h'+  ToLowerC 'I' = 'i'+  ToLowerC 'J' = 'j'+  ToLowerC 'K' = 'k'+  ToLowerC 'L' = 'l'+  ToLowerC 'M' = 'm'+  ToLowerC 'N' = 'n'+  ToLowerC 'O' = 'o'+  ToLowerC 'P' = 'p'+  ToLowerC 'Q' = 'q'+  ToLowerC 'R' = 'r'+  ToLowerC 'S' = 's'+  ToLowerC 'T' = 't'+  ToLowerC 'U' = 'u'+  ToLowerC 'V' = 'v'+  ToLowerC 'W' = 'w'+  ToLowerC 'X' = 'x'+  ToLowerC 'Y' = 'y'+  ToLowerC 'Z' = 'z'+  ToLowerC a = a++type family FromMaybe (def :: a) (maybe :: Maybe a) :: a where+  FromMaybe def 'Nothing = def+  FromMaybe def ( 'Just a) = a
+ test/type-errors/Deriving.hs view
@@ -0,0 +1,243 @@+{-# LANGUAGE CPP #-}+{-# LANGUAGE DeriveAnyClass #-}+{-# LANGUAGE QuasiQuotes #-}+{-# LANGUAGE TemplateHaskell #-}+{-# LANGUAGE UndecidableInstances #-}++module Deriving where++import Data.Proxy (Proxy)+import Data.Text (Text)+import Ema.Route.Class+import Ema.Route.Generic+import Ema.Route.Generic.TH+import GHC.Generics qualified as GHC+import Generics.SOP qualified as SOP+import Text.RawString.QQ (r)++#define ENABLE_SPEC++----------------------------------------+-- Subroute verification+----------------------------------------++#undef ENABLE_SPEC+#ifdef ENABLE_SPEC+data BadRoute = BR_1 Int String | BR_2 String+data M = M { m1 :: (), m2 :: () } deriving stock GHC.Generic++deriveGeneric ''BadRoute+-- Subroutes should not have constructors with multiple fields+-- Expect: MultiRoute: too many arguments+deriveIsRoute ''BadRoute [t|+    '[ WithModel M+     ]+  |]+#endif+#define ENABLE_SPEC++-----------------------------------------++#undef ENABLE_SPEC+#ifdef ENABLE_SPEC+data R = R_1 () | R_2 ()+deriveGeneric ''R+-- WithSubRoutes' list should not be shorter than number of route constructors+-- Expect:+{-+'WithSubRoutes' is missing subroutes for:++  '[()]+-}+deriveIsRoute ''R [t|+  '[ WithSubRoutes '[ () ] ]+  |]+#endif+#define ENABLE_SPEC++-----------------------------------------++#undef ENABLE_SPEC+#ifdef ENABLE_SPEC+data R = R_1 () | R_2 ()+deriveGeneric ''R+-- WithSubRoutes' list should not be longer than number of route constructors+-- Expect:+{-+'WithSubRoutes' has extra unnecessary types:++  '[Int]+-}+deriveIsRoute ''R [t|+  '[ WithSubRoutes '[ (), (), Int ] ]+  |]+#endif+#define ENABLE_SPEC++------------------------------------------++#undef ENABLE_SPEC+#ifdef ENABLE_SPEC+data R = R_1 Int | R_2+deriveGeneric ''R+-- constructors should either be empty or contain () when 'WithSubRoutes' specifies ()+-- Expect:+{-+A 'WithSubRoutes' entry is '()' instead of the expected:+Int+-}+deriveIsRoute ''R [t|+  '[ WithSubRoutes '[ (), () ] ]+  |]+#endif+#define ENABLE_SPEC++-------------------------------------------++#undef ENABLE_SPEC+#ifdef ENABLE_SPEC+data R = R_1 | R_2 +deriveGeneric ''R+-- subroute types that are nonisomorphic to what is specified in 'WithSubRoutes' should be illegal+-- Expect:+{-+Couldn't match representation of type ‘()’ with that of ‘Bool’+  arising from a use of ‘routePrism’+-}+deriveIsRoute ''R [t|+  ' [ WithSubRoutes '[ (), Bool] ]+  |]+#endif+#define ENABLE_SPEC++-------------------------------------------++-- subroute types that are the same as what is specified in 'WithSubRoutes' should typecheck"+data RSubIso = RSubIso_1 | RSubIso_2+deriveGeneric ''RSubIso+deriveIsRoute+  ''RSubIso+  [t|+    '[WithSubRoutes '[(), ()]]+    |]++-------------------------------------------++-- FIXME: This test is broken.+#undef ENABLE_SPEC+#ifdef ENABLE_SPEC+routeSpec "subroute types that are an unwrapped representation of what is specified in 'WithSubRoutes' should typecheck | ( empty constructor <-> () ) special case"+  (niceRoute ''() ''())+  [t|+    '[ WithModel (NiceNamedM () ())+     , WithSubRoutes '[ (), PlainR_NiceNamedM ]+     ]+  |]+  [r|+  |]+#endif+#define ENABLE_SPEC++-------------------------------------------++-- Subroute types that are an unwrapped representation of what is specified in+-- 'WithSubRoutes' should typecheck | (wrapper deriving /newtype/ GHC.Generic)+-- case"+data RSubNewtype = RSubNewtype_1 | RSubNewtype_2+deriveGeneric ''RSubNewtype+newtype WrapNewtype a = WrapNewtype a+  deriving newtype (GHC.Generic, IsRoute)+deriveIsRoute+  ''RSubNewtype+  [t|+    '[ WithSubRoutes '[(), WrapNewtype ()]+     ]+    |]++-------------------------------------------+-- Submodel verification+-------------------------------------------++#undef ENABLE_SPEC+#ifdef ENABLE_SPEC+data R = R1 | R2 +deriveGeneric ''R +data M = M { m1 :: (), m2 :: () } deriving stock GHC.Generic+-- Submodel selectors should not be less than number of subroutes+-- Expect:+{-+'WithSubModels' is missing submodel types:+  '[()]+-}+deriveIsRoute ''R +  [t|+    '[ WithModel M+     , WithSubModels '[ Proxy "m1" ]+     ]+  |]+#endif+#define ENABLE_SPEC++-------------------------------------------++#undef ENABLE_SPEC+#ifdef ENABLE_SPEC+data R = R1 | R2 +deriveGeneric ''R +data M = M { m1 :: (), m2 :: () }+  deriving stock GHC.Generic+-- submodel selectors should not outnumber number of subroutes+-- Expect:+{-+'WithSubModels' has extra unnecessary types:++  '[Proxy "m2"]+-}+deriveIsRoute ''R+  [t|+    '[ WithModel M+     , WithSubModels '[ Proxy "m1", Proxy "m2", Proxy "m2" ]+     ]+  |]+#endif+#define ENABLE_SPEC++-------------------------------------------+data RSubMSelf = RSubMSelf1 | RSubMSelf2 RSubMSelf+  deriving stock (GHC.Generic)+deriveGeneric ''RSubMSelf+data MSelf = MSelf {mself1 :: (), mself2 :: ()}+  deriving stock (GHC.Generic)++-- submodel type selectors be able to reference the model itself if they are of the same type+deriveIsRoute+  ''RSubMSelf+  [t|+    '[ WithModel MSelf+     , WithSubModels '[Proxy "mself1", MSelf]+     ]+    |]++-------------------------------------------++#undef ENABLE_SPEC+#ifdef ENABLE_SPEC+data R = R1 | R2+deriveGeneric ''R+data M = M { m1 :: (), m2 :: () }+  deriving stock GHC.Generic+-- submodel type selectors must, at a minimum, refer to a model field of a matching type+-- Expect:+{-+Couldn't match type ‘Bool’ with ‘()’+-}+deriveIsRoute ''R+  [t|+    '[ WithModel M+     , WithSubModels '[ Proxy "m1", Bool ]+     ]+  |]+#endif+#define ENABLE_SPEC++-----------------------------------------
+ test/type-errors/Spec.hs view
@@ -0,0 +1,5 @@+module Main where++-- | Filler module so cabal will be happy that there's a main-is+main :: IO ()+main = pure ()
+ www/ema-error.html view
@@ -0,0 +1,16 @@+<html lang="en">++<head>+    <meta charset="UTF-8">+    <meta name="viewport" content="width=device-width, initial-scale=1" />+    <title>Ema App exception</title>+</head>++<body class="overflow-y: scroll;">+    <h1>Ema App threw an exception</h1>+    <pre+        style="font-family: monospace; border: 1px solid; padding: 1em 1em 1em 1em; overflow-wrap: anywhere;">MESSAGE</pre>+    <p>Once you fix the source of the error, this page will automatically refresh.+</body>++</html>
+ www/ema-indicator.html view
@@ -0,0 +1,103 @@+<!-- +The inline CSS here is roughly analogous to the ones generated by Tailwind.+See the original version based on Tailwind`: https://gist.github.com/srid/2471813953a6df9b24909b9bb1d3cd2b+-->++<div style="+  display: none;+  position: absolute;+  top: 0px;+  left: 0px;+  padding: 0.5rem;+  font-size: 12px;+  line-height: 18px;+  tab-size: 4;+  text-size-adjust: 100%;+" id="ema-indicator">+  <div style="+    display: flex;+    overflow: hidden;+    font-size: 0.75rem;+    align-items: center;+    gap: 0.5rem;+    padding: 0.5rem;+    height: 2rem;+    width: 2rem;+    box-sizing: border-box;+    border-style: solid;+    border-width: 2px;+    border-color: rgb(229 231 235);+    background-color: rgb(255 255 255);+    border-radius: 9999px;+    box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);+    transition-property: width, height;+    transition-duration: 500ms;+      transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);+  " onMouseOver="this.style.width='100%'" onMouseOut="this.style.width='2rem'" id="ema-status" title="Ema Status">+    <div hidden style="+      flex: none;+      width: 0.75rem;+      height: 0.75rem;+      background-color: rgb(22 163 74);+      border-radius: 9999px;+    " id="ema-connected"></div>+    <div hidden style="+      flex: none;+      width: 0.75rem;+      height: 0.75rem;+      border-radius: 9999px;+        animation: spin 1s linear infinite;+      background-image: linear-gradient(to right, var(--tw-gradient-stops));+      --tw-gradient-from: #93c5fd;+      --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to);+      --tw-gradient-to: #2563eb;+    " id="ema-reloading">+      <style>+        @keyframes spin {+          from {+            transform: rotate(0deg);+          }++          to {+            transform: rotate(360deg);+          }+        }+      </style>+    </div>+    <div hidden style="+      flex: none;+      width: 0.75rem;+      height: 0.75rem;+      border-radius: 9999px;+        background-color: rgb(234 179 8);+    " id="ema-connecting">+      <div style="+        flex: none;+        width: 0.75rem;+        height: 0.75rem;+        border-radius: 9999px;+        background-color: rgb(234 179 8);+        animation: ping 1s cubic-bezier(0, 0, 0.2, 1) infinite;+      ">+        <style>+          @keyframes ping {++            75%,+            100% {+              transform: scale(2);+              opacity: 0;+            }+          }+        </style>+      </div>+    </div>+    <div hidden style="+      flex: none;+      width: 0.75rem;+      height: 0.75rem;+      border-radius: 9999px;+      background-color: rgb(239 68 68);+    " id="ema-disconnected"></div>+    <p style="white-space: nowrap;" id="ema-message"></p>+  </div>+</div>
+ www/ema-shim.js view
@@ -0,0 +1,186 @@+function htmlToElem(html) {+    let temp = document.createElement('template');+    html = html.trim(); // Never return a space text node as a result+    temp.innerHTML = html;+    return temp.content.firstChild;+};++// Unlike setInnerHtml, this patches the Dom in place+function setHtml(elm, html) {+    var htmlElem = htmlToElem(html);+    window.dispatchEvent(new Event('EMABeforeMorphDOM'));+    morphdom(elm, html);+    window.dispatchEvent(new Event('EMABeforeScriptReload'));+    // Re-add <script> tags, because just DOM diff applying is not enough.+    reloadScripts(elm);+    window.dispatchEvent(new Event('EMAHotReload'));+};++// FIXME: This doesn't reliably work across all JS.+// See also the HACK below in one of the invocations.+function reloadScripts(elm) {+    Array.from(elm.querySelectorAll("script")).forEach(oldScript => {+        const newScript = document.createElement("script");+        Array.from(oldScript.attributes)+            .forEach(attr => newScript.setAttribute(attr.name, attr.value));+        newScript.appendChild(document.createTextNode(oldScript.innerHTML));+        oldScript.parentNode.replaceChild(newScript, oldScript);+    });+};++// Ema Status indicator+const messages = {+    connected: "Connected",+    reloading: "Reloading",+    connecting: "Connecting to the server",+    disconnected: "Disconnected - try reloading the window"+};+function setIndicators(connected, reloading, connecting, disconnected) {+    const is = { connected, reloading, connecting, disconnected }++    for (const i in is) {+        document.getElementById(`ema-${i}`).style.display =+            is[i] ? "block" : "none"+        if (is[i])+            document.getElementById('ema-message').innerText = messages[i]+    };+    document.getElementById("ema-indicator").style.display = "block";+};+window.connected = () => setIndicators(true, false, false, false)+window.reloading = () => setIndicators(false, true, false, false)+window.connecting = () => setIndicators(false, false, true, false)+window.disconnected = () => setIndicators(false, false, false, true)+window.hideIndicator = () => {+    document.getElementById("ema-indicator").style.display = "none";+};++// Base URL path - for when the ema site isn't served at "/"+const baseHref = document.getElementsByTagName("base")[0]?.href;+const basePath = baseHref ? new URL(baseHref).pathname : "/";++// Use TLS for websocket iff the current page is also served with TLS+const wsProto = window.location.protocol === "https:" ? "wss://" : "ws://";+const wsUrl = wsProto + window.location.host + basePath;++// WebSocket logic: watching for server changes & route switching+function init(reconnecting) {+    // The route current DOM is displaying+    let routeVisible = document.location.pathname;++    const verb = reconnecting ? "Reopening" : "Opening";+    console.log(`ema: ${verb} conn ${wsUrl} ...`);+    window.connecting();+    let ws = new WebSocket(wsUrl);++    function sendObservePath(path) {+        const relPath = path.startsWith(basePath) ? path.slice(basePath.length - 1) : path;+        console.debug(`ema: requesting ${relPath}`);+        ws.send(relPath);+    }++    // Call this, then the server will send update *once*. Call again for+    // continous monitoring.+    function watchCurrentRoute() {+        console.log(`ema: ⏿ Observing changes to ${document.location.pathname}`);+        sendObservePath(document.location.pathname);+    };++    function switchRoute(path, hash = "") {+        console.log(`ema: → Switching to ${path + hash}`);+        window.history.pushState({}, "", path + hash);+        sendObservePath(path);+    }++    function scrollToAnchor(hash) {+        console.log(`ema: Scroll to ${hash}`)+        var el = document.querySelector(hash);+        if (el !== null) {+            el.scrollIntoView({ behavior: 'smooth' });+        }+    };++    function handleRouteClicks(e) {+        const origin = e.target.closest("a");+        if (origin) {+            if (window.location.host === origin.host && origin.getAttribute("target") != "_blank") {+                if (origin.getAttribute("href").startsWith("#")) {+                    // Switching to local anchor+                    window.history.pushState({}, "", window.location.pathname + origin.hash);+                    scrollToAnchor(window.location.hash);+                    e.preventDefault();+                } else {+                    // Switching to another route+                    switchRoute(origin.pathname, origin.hash);+                    e.preventDefault();+                }+            };+        }+    };+    // Intercept route click events, and ask server for its HTML whilst+    // managing history state.+    window.addEventListener(`click`, handleRouteClicks);++    ws.onopen = () => {+        console.log(`ema: ... connected!`);+        // window.connected();+        window.hideIndicator();+        if (!reconnecting) {+            // HACK: We have to reload <script>'s here on initial page load+            // here, so as to make Twind continue to function on the *next*+            // route change. This is not a problem with *subsequent* (ie. 2nd+            // or latter) route clicks, because those have already called+            // reloadScripts at least once.+            reloadScripts(document.documentElement);+        };+        watchCurrentRoute();+    };++    ws.onclose = () => {+        console.log("ema: reconnecting ..");+        window.removeEventListener(`click`, handleRouteClicks);+        window.reloading();+        // Reconnect after as small a time is possible, then retry again. +        // ghcid can take 1s or more to reboot. So ideally we need an+        // exponential retry logic.+        // +        // Note that a slow delay (200ms) may often cause websocket+        // connection error (ghcid hasn't rebooted yet), which cannot be+        // avoided as it is impossible to trap this error and handle it.+        // You'll see a big ugly error in the console.+        setTimeout(function () { init(true); }, 400);+    };++++    ws.onmessage = evt => {+        if (evt.data.startsWith("REDIRECT ")) {+            console.log("ema: redirect");+            document.location.href = evt.data.slice("REDIRECT ".length);+        } else {+            console.log("ema: ✍ Patching DOM");+            setHtml(document.documentElement, evt.data);+            if (routeVisible != document.location.pathname) {+                // This is a new route switch; scroll up.+                window.scrollTo({ top: 0 });+                routeVisible = document.location.pathname;+            }+            if (window.location.hash) {+                scrollToAnchor(window.location.hash);+            }+            watchCurrentRoute();+        };+    };+    window.onbeforeunload = evt => { ws.close(); };+    window.onpagehide = evt => { ws.close(); };++    // When the user clicks the back button, resume watching the URL in+    // the addressback, which has the effect of loading it immediately.+    window.onpopstate = function (e) {+        watchCurrentRoute();+    };++    // API for user invocations +    window.ema = {+        switchRoute: switchRoute+    };+};