- 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.
513 lines
17 KiB
Markdown
513 lines
17 KiB
Markdown
---
|
|
phase: 09-content-management
|
|
plan: 06
|
|
type: execute
|
|
wave: 3
|
|
depends_on: ["09-01", "09-02"]
|
|
files_modified:
|
|
- summercms/src/hot/FileWatcher.scala
|
|
- summercms/src/hot/HotReloadService.scala
|
|
- summercms/src/hot/TemplateInvalidator.scala
|
|
- summercms/src/config/AppConfig.scala
|
|
- summercms/src/Main.scala
|
|
autonomous: true
|
|
|
|
must_haves:
|
|
truths:
|
|
- "Developer edits template file, browser refresh shows changes without server restart"
|
|
- "Page/layout/partial modifications reflect immediately after save"
|
|
- "Hot reload only active when DEV_MODE environment variable is true"
|
|
- "File system changes detected efficiently without CPU overhead"
|
|
- "Only changed template is reloaded, not the entire cache"
|
|
artifacts:
|
|
- path: "summercms/src/hot/FileWatcher.scala"
|
|
provides: "File change detection"
|
|
contains: "class FileWatcher"
|
|
- path: "summercms/src/hot/HotReloadService.scala"
|
|
provides: "Reload orchestration service"
|
|
contains: "trait HotReloadService"
|
|
- path: "summercms/src/hot/TemplateInvalidator.scala"
|
|
provides: "Pebble cache invalidation"
|
|
contains: "class TemplateInvalidator"
|
|
key_links:
|
|
- from: "FileWatcher"
|
|
to: "HotReloadService"
|
|
via: "file change events trigger reload"
|
|
pattern: "HotReloadService.*onFileChanged"
|
|
- from: "HotReloadService"
|
|
to: "PebbleEngine.cache"
|
|
via: "invalidates template cache"
|
|
pattern: "pebbleEngine.*invalidate"
|
|
---
|
|
|
|
<objective>
|
|
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.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@/home/jin/.claude/get-shit-done/workflows/execute-plan.md
|
|
@/home/jin/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<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
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto">
|
|
<name>Task 1: Create FileWatcher using JDK WatchService</name>
|
|
<files>
|
|
summercms/src/hot/FileWatcher.scala
|
|
summercms/src/config/AppConfig.scala
|
|
</files>
|
|
<action>
|
|
**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:
|
|
```scala
|
|
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
|
|
</action>
|
|
<verify>
|
|
`./mill summercms.compile` succeeds
|
|
FileWatcher can be instantiated (manual test)
|
|
</verify>
|
|
<done>
|
|
FileWatcher uses JDK WatchService for OS-native file change detection. Supports recursive watching, debouncing, and deduplication.
|
|
</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: Implement TemplateInvalidator and HotReloadService</name>
|
|
<files>
|
|
summercms/src/hot/TemplateInvalidator.scala
|
|
summercms/src/hot/HotReloadService.scala
|
|
</files>
|
|
<action>
|
|
**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
|
|
</action>
|
|
<verify>
|
|
`./mill summercms.compile` succeeds
|
|
HotReloadService methods have correct signatures
|
|
</verify>
|
|
<done>
|
|
TemplateInvalidator provides targeted Pebble cache invalidation. HotReloadService orchestrates reload by processing file events and invalidating appropriate caches.
|
|
</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 3: Integrate hot reload with application startup</name>
|
|
<files>
|
|
summercms/src/Main.scala
|
|
summercms/resources/application.conf
|
|
</files>
|
|
<action>
|
|
**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:
|
|
```bash
|
|
#!/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.
|
|
</action>
|
|
<verify>
|
|
`./mill summercms.compile` succeeds
|
|
DEV_MODE=true ./mill summercms.run starts with hot reload logging
|
|
Modify template file -> see "Template changed" log message
|
|
</verify>
|
|
<done>
|
|
Hot reload integrated with application startup. Development mode enables file watching and cache invalidation. Production mode uses full caching.
|
|
</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<verification>
|
|
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)
|
|
</verification>
|
|
|
|
<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>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/09-content-management/09-06-SUMMARY.md`
|
|
</output>
|