Files
summercms-initial-research/.planning/phases/09-content-management/09-06-PLAN.md
Jakub Zych dca89e10cd docs(09): create phase plan
Phase 09: Content Management
- 6 plan(s) in 3 wave(s)
- Wave 1: 09-01 (CMS pages/layouts), 09-03 (media library) - parallel
- Wave 2: 09-02 (component embedding), 09-04 (revisions), 09-05 (menus)
- Wave 3: 09-06 (hot reload)
- Ready for execution
2026-02-05 15:33:51 +01:00

17 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, must_haves
phase plan type wave depends_on files_modified autonomous must_haves
09-content-management 06 execute 3
09-01
09-02
summercms/src/hot/FileWatcher.scala
summercms/src/hot/HotReloadService.scala
summercms/src/hot/TemplateInvalidator.scala
summercms/src/config/AppConfig.scala
summercms/src/Main.scala
true
truths artifacts key_links
Template file changes trigger Pebble cache invalidation
Page/layout/partial modifications reflect immediately in browser
Hot reload only active in development mode
File watcher uses OS-native WatchService (not polling)
Cache invalidation is targeted (not full cache clear)
path provides contains
summercms/src/hot/FileWatcher.scala File change detection using WatchService class FileWatcher
path provides contains
summercms/src/hot/HotReloadService.scala Reload orchestration service trait HotReloadService
path provides contains
summercms/src/hot/TemplateInvalidator.scala Pebble cache invalidation class TemplateInvalidator
from to via pattern
FileWatcher HotReloadService file change events trigger reload HotReloadService.*onFileChanged
from to via pattern
HotReloadService PebbleEngine.cache invalidates template cache pebbleEngine.*invalidate
Implement hot reload for development mode

Purpose: Enable fast iteration during development by automatically reloading templates when files change. Per DIFF-02 requirement and CONTEXT.md decision for Claude's discretion on implementation details.

Output: FileWatcher using JDK WatchService, HotReloadService orchestrating reloads, TemplateInvalidator for Pebble cache, configuration for dev mode detection.

<execution_context> @/home/jin/.claude/get-shit-done/workflows/execute-plan.md @/home/jin/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/phases/09-content-management/09-RESEARCH.md @.planning/phases/09-content-management/09-01-SUMMARY.md @.planning/phases/09-content-management/09-02-SUMMARY.md @summercms/src/config/AppConfig.scala @summercms/src/Main.scala Task 1: Create FileWatcher using JDK WatchService summercms/src/hot/FileWatcher.scala summercms/src/config/AppConfig.scala **Update AppConfig.scala:** Add development mode configuration: ```scala case class DevConfig( enabled: Boolean = false, // Enable dev features hotReload: Boolean = false, // Enable hot reload watchPaths: List[String] = List(), // Additional paths to watch watchDebounceMs: Int = 100 // Debounce rapid changes )

case class AppConfig( server: ServerConfig, database: DatabaseConfig, dev: DevConfig = DevConfig() )


Update application.conf:
```hocon
dev {
  enabled = false
  enabled = ${?DEV_MODE}
  hot-reload = false
  hot-reload = ${?HOT_RELOAD}
  watch-paths = []
  watch-debounce-ms = 100
}

FileWatcher.scala: File change detection using java.nio.file.WatchService:

import java.nio.file.*
import java.nio.file.attribute.BasicFileAttributes
import scala.jdk.CollectionConverters.*

case class FileChangeEvent(
  path: Path,
  kind: FileChangeKind,
  timestamp: Instant
)

enum FileChangeKind:
  case Created, Modified, Deleted

trait FileWatcher:
  def start: IO[FileWatchError, Unit]
  def stop: IO[FileWatchError, Unit]
  def events: ZStream[Any, FileWatchError, FileChangeEvent]

object FileWatcher:
  def live(
    watchPaths: List[Path],
    debounceMs: Int = 100
  ): ZLayer[Any, Nothing, FileWatcher] =
    ZLayer.fromZIO {
      for
        watchService <- ZIO.attemptBlocking(FileSystems.getDefault.newWatchService())
        eventQueue   <- Queue.unbounded[FileChangeEvent]
        running      <- Ref.make(false)
      yield new FileWatcher:
        def start: IO[FileWatchError, Unit] =
          for
            _ <- running.set(true)
            // Register all directories recursively
            _ <- ZIO.foreach(watchPaths)(registerRecursive(_, watchService))
            // Start watch loop in background
            _ <- watchLoop(watchService, eventQueue)
              .ensuring(ZIO.attemptBlocking(watchService.close()).ignore)
              .fork
          yield ()

        def stop: IO[FileWatchError, Unit] =
          running.set(false)

        def events: ZStream[Any, FileWatchError, FileChangeEvent] =
          ZStream.fromQueue(eventQueue)
            .groupedWithin(100, Duration.fromMillis(debounceMs))
            .map(_.distinctBy(_.path))  // Dedupe rapid changes
            .flatMap(ZStream.fromIterable)

        private def watchLoop(
          watcher: WatchService,
          queue: Queue[FileChangeEvent]
        ): IO[FileWatchError, Unit] =
          running.get.flatMap { isRunning =>
            if !isRunning then ZIO.unit
            else
              for
                key <- ZIO.attemptBlocking(watcher.poll(500, java.util.concurrent.TimeUnit.MILLISECONDS))
                  .mapError(FileWatchError.WatchFailed(_))
                _ <- ZIO.whenCase(key) {
                  case k if k != null =>
                    val events = k.pollEvents().asScala.toList
                    val dir = k.watchable().asInstanceOf[Path]
                    ZIO.foreach(events) { event =>
                      val kind = event.kind()
                      if kind != StandardWatchEventKinds.OVERFLOW then
                        val relativePath = event.context().asInstanceOf[Path]
                        val fullPath = dir.resolve(relativePath)
                        val changeKind = kind match
                          case StandardWatchEventKinds.ENTRY_CREATE => FileChangeKind.Created
                          case StandardWatchEventKinds.ENTRY_MODIFY => FileChangeKind.Modified
                          case StandardWatchEventKinds.ENTRY_DELETE => FileChangeKind.Deleted
                          case _ => FileChangeKind.Modified
                        queue.offer(FileChangeEvent(fullPath, changeKind, Instant.now))
                      else ZIO.unit
                    } *> ZIO.attemptBlocking(k.reset()).ignore
                }
                _ <- watchLoop(watcher, queue)
              yield ()
          }

        private def registerRecursive(path: Path, watcher: WatchService): IO[FileWatchError, Unit] =
          ZIO.attemptBlocking {
            if Files.isDirectory(path) then
              Files.walkFileTree(path, new SimpleFileVisitor[Path] {
                override def preVisitDirectory(dir: Path, attrs: BasicFileAttributes): FileVisitResult = {
                  dir.register(
                    watcher,
                    StandardWatchEventKinds.ENTRY_CREATE,
                    StandardWatchEventKinds.ENTRY_MODIFY,
                    StandardWatchEventKinds.ENTRY_DELETE
                  )
                  FileVisitResult.CONTINUE
                }
              })
          }.mapError(FileWatchError.RegistrationFailed(_))
    }

enum FileWatchError:
  case WatchFailed(cause: Throwable)
  case RegistrationFailed(cause: Throwable)

Key features:

  • Recursive directory registration
  • Event debouncing to handle rapid saves
  • Deduplication of same-path events
  • Non-blocking poll with timeout
  • Graceful shutdown via running flag ./mill summercms.compile succeeds FileWatcher can be instantiated (manual test) FileWatcher uses JDK WatchService for OS-native file change detection. Supports recursive watching, debouncing, and deduplication.
Task 2: Implement TemplateInvalidator and HotReloadService summercms/src/hot/TemplateInvalidator.scala summercms/src/hot/HotReloadService.scala **TemplateInvalidator.scala:** Targeted Pebble cache invalidation: ```scala import io.pebbletemplates.pebble.PebbleEngine import io.pebbletemplates.pebble.cache.PebbleCache

trait TemplateInvalidator: def invalidate(path: Path): UIO[Unit] def invalidateAll: UIO[Unit]

object TemplateInvalidator: def live(pebbleEngine: PebbleEngine, themePath: Path): ULayer[TemplateInvalidator] = ZLayer.succeed { new TemplateInvalidator: def invalidate(path: Path): UIO[Unit] = ZIO.attemptBlocking { // Convert path to template name val templateName = themePath.relativize(path).toString // Pebble's template cache is keyed by template name val cache = pebbleEngine.getTemplateCache cache.invalidate(templateName)

        // Also invalidate tag cache (for custom tags)
        pebbleEngine.getTagCache.invalidateAll()
      }.ignore

    def invalidateAll: UIO[Unit] =
      ZIO.attemptBlocking {
        pebbleEngine.getTemplateCache.invalidateAll()
        pebbleEngine.getTagCache.invalidateAll()
      }.ignore
}

**HotReloadService.scala:**
Orchestrates hot reload on file changes:
```scala
trait HotReloadService:
  def start: IO[HotReloadError, Unit]
  def stop: IO[HotReloadError, Unit]
  def onFileChanged(event: FileChangeEvent): UIO[Unit]

case class HotReloadConfig(
  enabled: Boolean,
  watchPaths: List[Path],
  debounceMs: Int
)

object HotReloadService:
  def live(config: HotReloadConfig): ZLayer[
    FileWatcher & TemplateInvalidator & CmsRouter,
    Nothing,
    HotReloadService
  ] =
    ZLayer.fromZIO {
      for
        watcher     <- ZIO.service[FileWatcher]
        invalidator <- ZIO.service[TemplateInvalidator]
        cmsRouter   <- ZIO.service[CmsRouter]
        running     <- Ref.make(false)
      yield new HotReloadService:
        def start: IO[HotReloadError, Unit] =
          if !config.enabled then
            ZIO.logInfo("Hot reload disabled") *> ZIO.unit
          else
            for
              _ <- ZIO.logInfo(s"Starting hot reload for paths: ${config.watchPaths}")
              _ <- running.set(true)
              _ <- watcher.start.mapError(HotReloadError.WatcherFailed(_))
              // Process events in background
              _ <- watcher.events
                .tap(event => onFileChanged(event))
                .runDrain
                .fork
            yield ()

        def stop: IO[HotReloadError, Unit] =
          for
            _ <- running.set(false)
            _ <- watcher.stop.mapError(HotReloadError.WatcherFailed(_))
            _ <- ZIO.logInfo("Hot reload stopped")
          yield ()

        def onFileChanged(event: FileChangeEvent): UIO[Unit] =
          val path = event.path
          val extension = path.getFileName.toString.split('.').lastOption.getOrElse("")

          extension match
            case "htm" | "html" =>
              // Template file changed
              for
                _ <- ZIO.logDebug(s"Template changed: $path")
                _ <- invalidator.invalidate(path)
                // Rebuild router if page URL might have changed
                _ <- ZIO.when(path.toString.contains("/pages/")) {
                  cmsRouter.rebuild.ignore
                }
              yield ()

            case "css" | "js" =>
              // Asset changed - just log, browser handles via Vite
              ZIO.logDebug(s"Asset changed: $path")

            case "yaml" | "yml" =>
              // Config changed - may need full invalidation
              for
                _ <- ZIO.logDebug(s"Config changed: $path")
                _ <- invalidator.invalidateAll
              yield ()

            case _ =>
              ZIO.logDebug(s"Ignored file change: $path")

enum HotReloadError:
  case WatcherFailed(cause: FileWatchError)
  case InvalidationFailed(cause: Throwable)

Key decisions (per CONTEXT.md Claude's discretion):

  • Template changes: targeted cache invalidation
  • Page changes: also rebuild router (URL might have changed)
  • YAML changes: full cache invalidation
  • Asset changes: logged only (Vite handles HMR)
  • Debounce: 100ms default to handle editor save-on-type ./mill summercms.compile succeeds HotReloadService methods have correct signatures TemplateInvalidator provides targeted Pebble cache invalidation. HotReloadService orchestrates reload by processing file events and invalidating appropriate caches.
Task 3: Integrate hot reload with application startup summercms/src/Main.scala summercms/resources/application.conf **Update Main.scala:** Conditionally start hot reload in development mode: ```scala object Main extends ZIOAppDefault: override def run: ZIO[Any, Throwable, Unit] = for _ <- printBanner config <- ZIO.service[AppConfig] _ <- ZIO.logInfo(s"Starting SummerCMS on port ${config.server.port}")
  // Initialize Pebble engine
  pebbleEngine <- buildPebbleEngine(config)

  // Start hot reload if enabled
  _ <- ZIO.when(config.dev.enabled && config.dev.hotReload) {
    for
      _ <- ZIO.logInfo("Development mode: Hot reload enabled")
      themePath <- ZIO.succeed(Path.of(config.theme.path))
      watchPaths = themePath :: config.dev.watchPaths.map(Path.of)

      hotReloadConfig = HotReloadConfig(
        enabled = true,
        watchPaths = watchPaths,
        debounceMs = config.dev.watchDebounceMs
      )

      // Build hot reload layers
      fileWatcherLayer = FileWatcher.live(watchPaths, config.dev.watchDebounceMs)
      invalidatorLayer = TemplateInvalidator.live(pebbleEngine, themePath)
      hotReloadLayer = HotReloadService.live(hotReloadConfig)

      // Start hot reload service
      _ <- ZIO.serviceWithZIO[HotReloadService](_.start)
        .provide(
          fileWatcherLayer,
          invalidatorLayer,
          hotReloadLayer,
          CmsRouter.live(themePath)
        )
    yield ()
  }

  // Start HTTP server
  _ <- startServer(config)
yield ()
.provide(
  AppConfig.layer,
  // ... other layers
)

private def buildPebbleEngine(config: AppConfig): UIO[PebbleEngine] = ZIO.succeed { new PebbleEngine.Builder() .loader(new FileLoader()) .cacheActive(!config.dev.hotReload) // Disable cache in hot reload mode .strictVariables(config.dev.enabled) // Strict mode in dev .extension(new CmsPebbleExtension(...)) .build() }


**Update application.conf:**
Add theme path configuration:
```hocon
theme {
  path = "themes/default"
  path = ${?THEME_PATH}
}

dev {
  enabled = false
  enabled = ${?DEV_MODE}
  hot-reload = false
  hot-reload = ${?HOT_RELOAD}
  watch-paths = []
  watch-debounce-ms = 100
}

Development startup script (optional): Create scripts/dev.sh:

#!/bin/bash
export DEV_MODE=true
export HOT_RELOAD=true
./mill -w summercms.run

Note: Mill's --watch mode (-w) restarts on Scala code changes. Combined with our hot reload, this provides:

  • Scala code changes: Mill restarts JVM
  • Template changes: Hot reload invalidates cache (no restart)
  • Asset changes: Vite HMR in browser

Production mode: All caching enabled, hot reload disabled, strict mode off. ./mill summercms.compile succeeds DEV_MODE=true ./mill summercms.run starts with hot reload logging Modify template file -> see "Template changed" log message Hot reload integrated with application startup. Development mode enables file watching and cache invalidation. Production mode uses full caching.

After all tasks complete: 1. `./mill summercms.compile` - all code compiles 2. Test development mode: - DEV_MODE=true HOT_RELOAD=true ./mill summercms.run - See "Hot reload enabled" in logs 3. Test file watching: - Modify themes/default/pages/home.htm - See "Template changed" log - Refresh browser -> see updated content immediately 4. Test cache invalidation: - Modify layout -> layout changes reflected - Modify partial -> partial changes reflected - Add new page -> router rebuilt, new URL accessible 5. Test production mode: - DEV_MODE=false ./mill summercms.run - No hot reload logs - Templates cached (verify via response time)

<success_criteria>

  • Hot reload only active when DEV_MODE=true and HOT_RELOAD=true
  • FileWatcher uses OS-native WatchService (not polling)
  • Template changes invalidate specific template cache entry
  • Page changes also trigger router rebuild
  • Changes reflect immediately without JVM restart
  • Production mode uses full caching for performance
  • Event debouncing prevents rapid reload spam </success_criteria>
After completion, create `.planning/phases/09-content-management/09-06-SUMMARY.md`