- Plan 02: Added key_link clarifying CmsPageService.render populates PageRenderContext.components before template rendering. Updated Task 2/3 actions to emphasize component initialization flow. - Plan 03: Split into 03 (storage + library core) and 03b (image processing + routes) to reduce scope from 9 files to 7+4 files. Estimated context reduced from ~65% to ~45% each. - Plan 03b: New plan for ImageProcessor and MediaRoutes. Added specific curl command with -F flags and expected JSON response. - Plan 05: Added plugin integration test (MenuPluginIntegrationSpec) demonstrating custom menu item type registration, resolution, and template rendering per CONT-09 requirement. - Plan 06: Reframed must_haves truths from implementation details to user-observable outcomes (e.g., 'Developer edits template file, browser refresh shows changes without server restart') - Roadmap: Updated Phase 9 from 6 to 7 plans.
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 |
|
|
true |
|
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.compilesucceeds FileWatcher can be instantiated (manual test) FileWatcher uses JDK WatchService for OS-native file change detection. Supports recursive watching, debouncing, and deduplication.
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.compilesucceeds HotReloadService methods have correct signatures TemplateInvalidator provides targeted Pebble cache invalidation. HotReloadService orchestrates reload by processing file events and invalidating appropriate caches.
// 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.
<success_criteria>
- Hot reload only active when DEV_MODE=true and HOT_RELOAD=true
- Template file saved -> changes visible on next browser refresh (no restart)
- Targeted invalidation: only changed template reloaded, not all templates
- Page changes also trigger router rebuild for URL changes
- Production mode uses full caching for performance
- Event debouncing prevents rapid reload spam during fast typing </success_criteria>