The page $path could not be found.
$message
+ +""" +``` + +**ThemeRoutes.scala:** +```scala +package theme + +import theme.asset.AssetService +import theme.static.StaticPageCache +import component.ComponentManager +import zio.* +import zio.http.* +import java.nio.file.{Files, Path} + +/** Routes for theme asset serving and page rendering */ +object ThemeRoutes: + + /** Routes for serving theme static assets */ + def assetRoutes: Routes[ThemeService, Response] = + Routes( + // Serve theme assets: /themes/{code}/assets/{path} + Method.GET / "themes" / string("code") / "assets" / trailing -> + handler { (code: String, path: Path, req: Request) => + serveThemeAsset(code, path) + } + ) + + /** Catch-all route for page rendering */ + def pageRoutes: Routes[PageEnv, Response] = + Routes( + // Catch-all for CMS pages + Method.GET / trailing -> + handler { (path: Path, req: Request) => + PagePipeline.handleRequest(req) + } + ) + + /** Serve a static asset from theme directory */ + private def serveThemeAsset(themeCode: String, assetPath: Path): ZIO[ThemeService, Nothing, Response] = + (for + theme <- ThemeService.getActive.flatMap { + case Some(t) if t.code == themeCode => ZIO.succeed(t) + case Some(_) => ZIO.fail("Theme mismatch") + case None => ZIO.fail("No active theme") + } + + // Resolve asset path (prevent directory traversal) + relativePath = assetPath.toString.stripPrefix("/") + fullPath = theme.path.resolve("assets").resolve(relativePath).normalize() + + // Security: ensure path is within theme assets directory + assetsDir = theme.path.resolve("assets").normalize() + _ <- ZIO.fail("Invalid path").when(!fullPath.startsWith(assetsDir)) + + // Check if file exists + exists <- ZIO.attemptBlocking(Files.exists(fullPath) && Files.isRegularFile(fullPath)).orDie + _ <- ZIO.fail("Not found").when(!exists) + + // Determine content type + contentType = guessContentType(fullPath.getFileName.toString) + + // Read and return file + content <- ZIO.attemptBlocking(Files.readAllBytes(fullPath)).orDie + yield Response( + status = Status.Ok, + headers = Headers(Header.ContentType(contentType)), + body = Body.fromArray(content) + )).catchAll { err => + ZIO.succeed(Response.status(Status.NotFound)) + } + + private def guessContentType(filename: String): MediaType = + val ext = filename.lastIndexOf('.') match + case -1 => "" + case i => filename.substring(i + 1).toLowerCase + ext match + case "css" => MediaType.text.css + case "js" => MediaType.application.javascript + case "json" => MediaType.application.json + case "png" => MediaType.image.png + case "jpg" | "jpeg" => MediaType.image.jpeg + case "gif" => MediaType.image.gif + case "svg" => MediaType.image.`svg+xml` + case "woff" => MediaType.application.`font-woff` + case "woff2" => MediaType.application.`font-woff` + case "ttf" => MediaType.application.`font-sfnt` + case "eot" => MediaType.application.`vnd.ms-fontobject` + case "ico" => MediaType.image.`x-icon` + case "html" | "htm" => MediaType.text.html + case "txt" => MediaType.text.plain + case _ => MediaType.application.`octet-stream` +``` + +**package.scala:** +```scala +package object theme: + import zio.* + import theme.asset.{AssetService, AssetRegistry} + import theme.static.StaticPageCache + + /** Combined layer for all theme services */ + type ThemeServices = ThemeService & ThemeLoader & AssetService & AssetRegistry & StaticPageCache + + /** + * Create theme services layer. + * @param devMode Whether in development mode (use Vite dev server) + */ + def themeLayer(devMode: Boolean): ZLayer[Any, ThemeError, ThemeServices] = + val loaderLayer = ThemeLoader.live + val themeServiceLayer = loaderLayer >>> ThemeService.default + + // Asset layer needs active theme - we'll create it dynamically per request + // For now, provide a basic layer + val assetLayer = loaderLayer >>> ZLayer.fromZIO { + for + loader <- ZIO.service[ThemeLoader] + themes <- loader.discover(ThemeLoader.defaultThemesDir) + theme = themes.headOption + service <- theme match + case Some(t) => AssetService.make(t, devMode) + case None => ZIO.succeed(new theme.asset.AssetServiceLive( + ThemeConfig("default", ""), + theme.asset.ViteManifest.empty, + devMode, + "http://localhost:5173" + ).asInstanceOf[AssetService]) + yield service + }.mapError(e => ThemeError.ThemeNotFound(e.message)) + + val registryLayer = AssetRegistry.live + val cacheLayer = StaticPageCache.live() + + loaderLayer ++ themeServiceLayer ++ assetLayer ++ registryLayer ++ cacheLayer + +// Note: AssetServiceLive needs to be accessible +package theme.asset { + // Make AssetServiceLive package-private accessible + private[theme] class AssetServiceLive( + theme: ThemeConfig, + manifest: ViteManifest, + devMode: Boolean, + viteDevUrl: String + ) extends AssetService: + private val publicPath = theme.assets.publicPath.getOrElse(s"/themes/${theme.code}/assets/") + + def isDevelopment: UIO[Boolean] = ZIO.succeed(devMode) + + def scriptTag(entry: String): IO[AssetError, AssetHtml] = + if devMode then + ZIO.succeed(AssetHtml( + s""" +""")) + else + manifest.get(entry) match + case Some(e) => + val scripts = manifest.getJsForEntry(entry).map { file => + s"""""" + } + ZIO.succeed(AssetHtml(scripts.mkString("\n"))) + case None => + ZIO.fail(AssetError.AssetNotFound(entry)) + + def linkTag(entry: String): IO[AssetError, AssetHtml] = + if devMode then + ZIO.succeed(AssetHtml(s"""""")) + else + val cssFiles = manifest.getCssForEntry(entry) + if cssFiles.isEmpty then + manifest.get(entry).map(_.file) match + case Some(file) => + ZIO.succeed(AssetHtml(s"""""")) + case None => + ZIO.fail(AssetError.AssetNotFound(entry)) + else + ZIO.succeed(AssetHtml(cssFiles.map(f => s"""""").mkString("\n"))) + + def resolveAsset(path: String): IO[AssetError, String] = + if devMode then ZIO.succeed(s"$viteDevUrl/$path") + else ZIO.succeed(manifest.get(path).map(_.file).getOrElse(path)).map(f => s"$publicPath$f") + + def vueHydrationScript(vueEntry: Option[String]): UIO[AssetHtml] = + val entry = vueEntry.orElse(theme.vue.entry).getOrElse("js/app.js") + if devMode then + ZIO.succeed(AssetHtml(s"""""")) + else + ZIO.succeed(AssetHtml(s"""""")) +} +``` + +**Update api/Routes.scala:** + +Read existing file and add theme routes. The key is to: +1. Import theme routes +2. Combine with existing routes +3. Theme page routes should be LAST (catch-all) + +```scala +package api + +import zio.http.* +import component.{ComponentRoutes, componentLayer} +import theme.{ThemeRoutes, themeLayer, PageEnv} +import zio.* + +object Routes: + /** + * Combined routes for the application. + * Order matters: specific routes first, catch-all page routes last. + */ + def routes(devMode: Boolean): Routes[PageEnv, Response] = + // API and health routes (highest priority) + HealthRoutes.routes ++ + LandingRoutes.routes ++ + // Component HTMX routes + ComponentRoutes.routes ++ + // Theme asset serving + ThemeRoutes.assetRoutes ++ + // CMS page routes (catch-all - lowest priority) + ThemeRoutes.pageRoutes +``` + +**Update Main.scala:** + +Read existing file and update to include theme layer: + +```scala +package summercms + +import zio.* +import zio.http.* +import zio.config.typesafe.TypesafeConfigProvider + +import api.Routes +import _root_.config.{AppConfig as SummerConfig} +import db.QuillContext +import plugin.{PluginManager, PluginDiscovery, pluginLayer} +import component.{ComponentManager, componentLayer} +import theme.{ThemeService, ThemeLoader, themeLayer} + +object Main extends ZIOAppDefault { + + private val banner: String = + """ + | + | | . + | `. * | .' + | `. ._|_* .' . + | . * .' `. * + | -------| |------- + | . *`.___.' * . + | .' |* `. * + | .' * | . `. + | . | + | + | S U M M E R C M S + |""".stripMargin + + override val bootstrap: ZLayer[ZIOAppArgs, Any, Any] = + Runtime.setConfigProvider(TypesafeConfigProvider.fromResourcePath()) + + private val startupLogic: ZIO[Any, Any, Nothing] = + for { + cfg <- ZIO.config[SummerConfig](SummerConfig.config) + _ <- Console.printLine(banner) + _ <- Console.printLine(s"Starting on port ${cfg.server.port}...") + _ <- Console.printLine(s"Development mode: ${cfg.server.isDevelopment}") + _ <- Console.printLine("") + + // Determine if in dev mode + devMode = cfg.server.isDevelopment + + // Create combined layer for all services + combinedLayer = pluginLayer ++ componentLayer ++ themeLayer(devMode).orDie + + res <- (for { + // Initialize plugin system + _ <- Console.printLine("Loading plugins...") + _ <- PluginManager.loadPlugins(PluginDiscovery.defaultPluginsDir) + .catchAll(e => Console.printLine(s"Plugin loading warning: ${e.message}").as(())) + _ <- PluginManager.bootAll + .catchAll(e => Console.printLine(s"Plugin boot warning: ${e.message}").as(())) + plugins <- PluginManager.listPlugins + _ <- Console.printLine(s"Loaded ${plugins.count(_._2.isActive)} plugin(s)") + + // Initialize component system + components <- ComponentManager.listComponents + _ <- Console.printLine(s"Registered ${components.size} component(s)") + + // Initialize theme system + theme <- ThemeService.getActive + _ <- theme match + case Some(t) => Console.printLine(s"Active theme: ${t.name} (${t.rendering})") + case None => Console.printLine("Warning: No theme found in themes/ directory") + + _ <- Console.printLine("") + _ <- Console.printLine(s"Server ready at http://localhost:${cfg.server.port}") + + // Start server with all routes + res <- Server.serve(Routes.routes(devMode)).provide( + Server.defaultWithPort(cfg.server.port), + QuillContext.dataSourceLayer + ) + } yield res).provide(combinedLayer) + } yield res + + override def run: ZIO[Any, Any, Any] = startupLogic +} +``` + +Note: The exact integration depends on existing file contents. Key changes: +1. Import theme package +2. Add `isDevelopment` config check (may need to add to AppConfig) +3. Create themeLayer with devMode +4. Combine layers: pluginLayer ++ componentLayer ++ themeLayer +5. Initialize theme system and report active theme +6. Pass devMode to Routes.routes() + +If AppConfig doesn't have isDevelopment, add it: +```scala +// In config/AppConfig.scala +case class ServerConfig( + port: Int, + isDevelopment: Boolean = true // Add this +) +``` + +And in application.conf: +```hocon +server { + port = 8080 + is-development = true // Add this for dev mode +} +``` +