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
This commit is contained in:
513
.planning/phases/09-content-management/09-06-PLAN.md
Normal file
513
.planning/phases/09-content-management/09-06-PLAN.md
Normal file
@@ -0,0 +1,513 @@
|
||||
---
|
||||
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:
|
||||
- "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)"
|
||||
artifacts:
|
||||
- path: "summercms/src/hot/FileWatcher.scala"
|
||||
provides: "File change detection using WatchService"
|
||||
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
|
||||
- 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>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/09-content-management/09-06-SUMMARY.md`
|
||||
</output>
|
||||
Reference in New Issue
Block a user