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:
Jakub Zych
2026-02-05 15:33:51 +01:00
parent a12cde5c0c
commit dca89e10cd
7 changed files with 2425 additions and 7 deletions

View File

@@ -171,14 +171,15 @@ Plans:
8. Navigation menus can be created and managed
9. Menu items support different types (URL, page reference, plugin-generated)
10. Changes reflect immediately during development (hot reload)
**Plans**: TBD
**Plans**: 6 plans
Plans:
- [ ] 09-01: CMS pages and layouts
- [ ] 09-02: Component embedding
- [ ] 09-03: Media library
- [ ] 09-04: Content states and revisions
- [ ] 09-05: Navigation menus
- [ ] 09-01-PLAN.md - CMS pages and layouts (CmsPage, CmsLayout, CmsRouter, CmsPageService)
- [ ] 09-02-PLAN.md - Component embedding (ComponentTag, PlaceholderTag, PutTag, PartialTag, CmsPebbleExtension)
- [ ] 09-03-PLAN.md - Media library (StorageBackend, LocalStorage, S3Storage, MediaLibrary, ImageProcessor)
- [ ] 09-04-PLAN.md - Content states and revisions (ContentRevision, RevisionService, publish/unpublish)
- [ ] 09-05-PLAN.md - Navigation menus (Menu, MenuItem, MenuItemTypeRegistry, MenuService)
- [ ] 09-06-PLAN.md - Hot reload (FileWatcher, HotReloadService, TemplateInvalidator)
### Phase 10: Core Plugins
**Goal**: Deliver User and Blog plugins demonstrating the complete plugin system
@@ -216,7 +217,7 @@ Phases execute in numeric order: 1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7 -> 8 -> 9 -> 10
| 6. Backend Authentication | 0/3 | Planned | - |
| 7. Admin Forms & Lists | 0/3 | Planned | - |
| 8. Admin Dashboard | 0/4 | Planned | - |
| 9. Content Management | 0/5 | Not started | - |
| 9. Content Management | 0/6 | Planned | - |
| 10. Core Plugins | 0/4 | Not started | - |
---

View File

@@ -0,0 +1,257 @@
---
phase: 09-content-management
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- build.mill
- summercms/src/content/cms/CmsPage.scala
- summercms/src/content/cms/CmsLayout.scala
- summercms/src/content/cms/CmsPartial.scala
- summercms/src/content/cms/ContentState.scala
- summercms/src/content/cms/CmsRouter.scala
- summercms/src/content/cms/CmsPageService.scala
- summercms/resources/db/migration/V009__cms_page_metadata.sql
autonomous: true
must_haves:
truths:
- "CMS pages can be loaded from .htm files with INI-style settings and Twig markup"
- "Pages specify layout in settings section and layout wraps page content"
- "URL routes with parameters (/blog/:slug) match to pages"
- "Layouts render with {% page %} placeholder filled by page content"
artifacts:
- path: "summercms/src/content/cms/CmsPage.scala"
provides: "CMS page model with compound file parsing"
contains: "case class CmsPage"
- path: "summercms/src/content/cms/CmsLayout.scala"
provides: "Layout model with placeholder support"
contains: "case class CmsLayout"
- path: "summercms/src/content/cms/CmsRouter.scala"
provides: "URL pattern matching router"
contains: "trait CmsRouter"
- path: "summercms/src/content/cms/CmsPageService.scala"
provides: "Page CRUD and rendering"
exports: ["load", "render", "list"]
key_links:
- from: "CmsRouter"
to: "CmsPage"
via: "findByUrl returns page with extracted params"
pattern: "findByUrl.*CmsPage.*Map\\[String, String\\]"
- from: "CmsPageService.render"
to: "PebbleEngine"
via: "template rendering"
pattern: "pebble.*render"
---
<objective>
Implement CMS pages and layouts with URL routing - the core content management infrastructure
Purpose: Enable admin to create CMS pages stored as .htm files (WinterCMS compound format), specify layouts for structure, and have pages accessible via URL patterns with parameter extraction. This is the foundation of the content management system.
Output: CmsPage/CmsLayout/CmsPartial models, CmsRouter for URL matching, CmsPageService for CRUD and rendering, database migration for page metadata caching.
</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/STATE.md
@.planning/phases/09-content-management/09-RESEARCH.md
@.planning/phases/04-theme-engine/04-RESEARCH.md (Pebble template setup)
@summercms/src/db/QuillContext.scala
@build.mill
</context>
<tasks>
<task type="auto">
<name>Task 1: Add dependencies and create CMS content models</name>
<files>
build.mill
summercms/src/content/cms/CmsPage.scala
summercms/src/content/cms/CmsLayout.scala
summercms/src/content/cms/CmsPartial.scala
summercms/src/content/cms/ContentState.scala
</files>
<action>
Add Pebble dependencies to build.mill (if not already present from Phase 4):
- io.pebbletemplates:pebble:4.1.1
- com.sfxcode.templating::pebble-scala:1.0.2
Create CMS content models in summercms/src/content/cms/:
**ContentState.scala:**
- Enum with Draft, Published values
- Companion object with fromString/toString
**CmsPage.scala:**
- CmsPageConfig case class: url, layout (Option), title (Option), description (Option), isHidden (Boolean), state (ContentState), components (Map[String, ComponentConfig])
- CmsPage case class: fileName, config, markup (String), code (Option[String]), themePath (Path), mtime (Instant)
- Companion object with parse(path: Path, themePath: Path): IO[ParseError, CmsPage]
- Parse WinterCMS compound format: INI-style settings, then `==`, then Twig markup, optional second `==` for code section
- Use regex pattern: `(?s)(.*?)^==\s*$(.*?)(?:^==\s*$(.*?))?`
**CmsLayout.scala:**
- CmsLayout case class: fileName, markup (String), themePath (Path), mtime (Instant)
- Parse simple .htm files (no settings section, pure Twig)
**CmsPartial.scala:**
- CmsPartial case class: fileName, markup (String), themePath (Path), mtime (Instant)
- Same format as layouts
Note: Per CONTEXT.md decision, flat layouts only (no inheritance). Pages declare layout via `layout = "default"` in settings.
</action>
<verify>
`./mill summercms.compile` succeeds with new models
</verify>
<done>
CmsPage, CmsLayout, CmsPartial, ContentState models exist and compile. CmsPage.parse can load WinterCMS-format .htm files.
</done>
</task>
<task type="auto">
<name>Task 2: Implement CMS URL router with parameter extraction</name>
<files>
summercms/src/content/cms/CmsRouter.scala
summercms/src/content/cms/RouteSegment.scala
</files>
<action>
Create URL router based on WinterCMS Router pattern:
**RouteSegment.scala:**
- Sealed trait RouteSegment with:
- Literal(value: String)
- Parameter(name: String, optional: Boolean, default: Option[String], regex: Option[String])
**CmsRouter.scala:**
- Trait CmsRouter with:
- findByUrl(url: String): IO[RouterError, Option[(CmsPage, Map[String, String])]]
- pageUrl(page: CmsPage, params: Map[String, String]): IO[RouterError, String]
- Case class Route(pattern: String, pageFile: String, segments: List[RouteSegment])
- Live implementation:
- Build route map from all pages in theme on construction
- parsePattern: Parse URL patterns supporting:
- /blog/:slug - required parameter
- /blog/:page? - optional parameter
- /blog/:page?1 - optional with default
- /blog/:id|^[0-9]+$ - with regex validation
- matchUrl: Try routes in order of specificity (more literal segments = higher priority)
- Extract parameters into Map[String, String]
- Specificity calculation: Count literal segments, subtract optional params
Router should handle:
- Trailing slash normalization
- URL decoding
- Regex validation failure returns None (not error)
</action>
<verify>
Unit test or REPL verification:
- parsePattern("/blog/:slug") returns [Literal("blog"), Parameter("slug", false, None, None)]
- parsePattern("/posts/:page?1") extracts optional param with default
- Route matching extracts correct parameters
</verify>
<done>
CmsRouter can parse WinterCMS URL patterns, match incoming URLs to pages, and extract route parameters. Routes sorted by specificity for correct matching.
</done>
</task>
<task type="auto">
<name>Task 3: Create CmsPageService and page metadata migration</name>
<files>
summercms/src/content/cms/CmsPageService.scala
summercms/resources/db/migration/V009__cms_page_metadata.sql
</files>
<action>
**V009__cms_page_metadata.sql:**
Create migration for caching page metadata (for admin list views, not primary storage):
```sql
CREATE TABLE cms_page_metadata (
id BIGSERIAL PRIMARY KEY,
file_name VARCHAR(255) UNIQUE NOT NULL,
url_pattern VARCHAR(255) NOT NULL,
title VARCHAR(255),
layout VARCHAR(100),
state VARCHAR(20) NOT NULL DEFAULT 'draft',
is_hidden BOOLEAN NOT NULL DEFAULT false,
mtime TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_cms_page_metadata_url ON cms_page_metadata(url_pattern);
CREATE INDEX idx_cms_page_metadata_state ON cms_page_metadata(state);
```
**CmsPageService.scala:**
Create service trait and live implementation:
- trait CmsPageService:
- load(fileName: String): IO[PageError, CmsPage]
- list: IO[PageError, List[CmsPageSummary]] (from metadata table)
- render(page: CmsPage, context: Map[String, Any]): IO[PageError, String]
- save(page: CmsPage): IO[PageError, CmsPage]
- delete(fileName: String): IO[PageError, Unit]
- syncMetadata(themePath: Path): IO[PageError, Unit] (scan files, update DB)
- CmsPageSummary case class: fileName, url, title, state, mtime
- Live implementation needs:
- themePath: Path (from config)
- PebbleEngine for rendering
- Quill context for metadata queries
- render method:
1. Load layout if specified
2. Render page markup with Pebble, passing context
3. If layout exists, render layout with pageContent variable set to rendered page
4. Return final HTML
Note: Primary source of truth is filesystem. Database only caches metadata for listing.
</action>
<verify>
`./mill summercms.compile` succeeds
Migration file exists at correct path
CmsPageService.load can parse a sample .htm file (create test file manually if needed)
</verify>
<done>
CmsPageService provides CRUD operations for CMS pages. Pages load from filesystem, render with Pebble through layouts. Metadata cached in database for admin list views.
</done>
</task>
</tasks>
<verification>
After all tasks complete:
1. `./mill summercms.compile` - all code compiles
2. Migration V009 ready for Flyway
3. Sample page file can be parsed: create `themes/test/pages/home.htm` with:
```
url = "/"
layout = "default"
title = "Home"
==
<h1>Welcome</h1>
```
4. CmsRouter.parsePattern correctly handles WinterCMS URL patterns
5. CmsPageService.render produces HTML with layout wrapping
</verification>
<success_criteria>
- CMS pages load from .htm files in WinterCMS compound format
- Pages specify layouts which wrap rendered content
- URL router matches patterns with parameter extraction
- Page metadata cached in database for admin queries
- All code compiles with `./mill summercms.compile`
</success_criteria>
<output>
After completion, create `.planning/phases/09-content-management/09-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,275 @@
---
phase: 09-content-management
plan: 02
type: execute
wave: 2
depends_on: ["09-01"]
files_modified:
- summercms/src/content/cms/pebble/ComponentTag.scala
- summercms/src/content/cms/pebble/PlaceholderTag.scala
- summercms/src/content/cms/pebble/PartialTag.scala
- summercms/src/content/cms/pebble/PutTag.scala
- summercms/src/content/cms/pebble/CmsPebbleExtension.scala
- summercms/src/content/cms/PageRenderContext.scala
- summercms/src/content/cms/CmsPageService.scala
autonomous: true
must_haves:
truths:
- "Components can be embedded in CMS pages via {% component 'alias' %} syntax"
- "Components receive properties from page config and inline overrides"
- "Layouts support {% placeholder 'name' %}default{% endplaceholder %} blocks"
- "Pages can fill placeholders with {% put 'name' %}content{% endput %}"
- "Partials render via {% partial 'name' %} with variable passing"
artifacts:
- path: "summercms/src/content/cms/pebble/ComponentTag.scala"
provides: "{% component %} tag implementation"
contains: "class ComponentTag extends TokenParser"
- path: "summercms/src/content/cms/pebble/PlaceholderTag.scala"
provides: "{% placeholder %} tag with defaults"
contains: "class PlaceholderTag extends TokenParser"
- path: "summercms/src/content/cms/pebble/CmsPebbleExtension.scala"
provides: "Extension registering all CMS tags"
contains: "class CmsPebbleExtension extends Extension"
key_links:
- from: "ComponentTag"
to: "PageRenderContext.components"
via: "resolves component by alias from page context"
pattern: "pageContext\\.components\\.get"
- from: "PlaceholderTag"
to: "PageRenderContext.placeholderContent"
via: "checks for put content before rendering default"
pattern: "placeholderContent\\.get"
---
<objective>
Implement component embedding and placeholder system for CMS pages
Purpose: Enable pages to embed dynamic components via Twig-like syntax and use layouts with placeholders that pages can fill. This completes the template composition system per CONTEXT.md decisions.
Output: Custom Pebble tags (component, placeholder, put, partial), CmsPebbleExtension registering all tags, updated CmsPageService render pipeline.
</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/03-component-system/03-RESEARCH.md (Component system patterns)
@summercms/src/content/cms/CmsPage.scala
@summercms/src/content/cms/CmsPageService.scala
</context>
<tasks>
<task type="auto">
<name>Task 1: Create PageRenderContext and placeholder/put tags</name>
<files>
summercms/src/content/cms/PageRenderContext.scala
summercms/src/content/cms/pebble/PlaceholderTag.scala
summercms/src/content/cms/pebble/PutTag.scala
</files>
<action>
**PageRenderContext.scala:**
Create render context passed through Pebble evaluation:
```scala
case class PageRenderContext(
page: CmsPage,
components: Map[String, ComponentInstance], // alias -> initialized component
placeholderContent: mutable.Map[String, String], // name -> rendered content
runtime: Runtime[Any] // for ZIO effect execution in sync context
)
```
**PlaceholderTag.scala:**
Implement Pebble TokenParser for `{% placeholder 'name' %}default{% endplaceholder %}`:
- Parse placeholder name expression
- Parse body until `endplaceholder`
- Create PlaceholderNode that:
- Evaluates name from context
- Checks PageRenderContext.placeholderContent for that name
- If content exists, write it
- Otherwise, render default body
Per CONTEXT.md: Flat layouts only, placeholders with defaults.
**PutTag.scala:**
Implement `{% put 'name' %}content{% endput %}`:
- Parse placeholder name expression
- Parse body until `endput`
- Create PutNode that:
- Renders body to string
- Stores in PageRenderContext.placeholderContent[name]
- Writes nothing to output (content stored for later use)
Two-pass rendering needed: First pass collects `put` content, second pass renders layout with placeholders resolved.
</action>
<verify>
`./mill summercms.compile` succeeds with new Pebble tags
</verify>
<done>
PlaceholderTag and PutTag allow layouts to define placeholders with defaults that pages can override. PageRenderContext stores render state.
</done>
</task>
<task type="auto">
<name>Task 2: Implement component and partial tags</name>
<files>
summercms/src/content/cms/pebble/ComponentTag.scala
summercms/src/content/cms/pebble/PartialTag.scala
</files>
<action>
**ComponentTag.scala:**
Implement `{% component 'alias' prop1='value' %}`:
- Parse required alias expression (string)
- Parse optional key=value property pairs until EXECUTE_END
- Create ComponentNode that:
- Gets PageRenderContext from evaluation context
- Looks up component by alias in pageContext.components
- If not found, throw PebbleException with line number
- Override component properties with tag-provided values
- Call component.render() using pageContext.runtime.unsafeRun
- Write rendered HTML to output
Component lookup: Page's INI section defines components like:
```
[blogPosts posts]
postsPerPage = 5
```
Where `blogPosts` is component class, `posts` is alias, properties follow.
**PartialTag.scala:**
Implement `{% partial 'name' var1='value' %}`:
- Parse partial name expression
- Parse optional variable assignments
- Create PartialNode that:
- Resolves partial from theme (partials/{name}.htm)
- Or from plugin namespace (plugin::partialName)
- Renders partial template with provided variables merged into context
- Write output
Per CONTEXT.md: `plugin::partial` for plugin partials, plain name for theme partials.
</action>
<verify>
`./mill summercms.compile` succeeds
ComponentTag parses component alias and properties
PartialTag resolves partial files correctly
</verify>
<done>
ComponentTag embeds components by alias with property overrides. PartialTag includes partial templates with variable passing. Both integrate with Pebble rendering.
</done>
</task>
<task type="auto">
<name>Task 3: Create CmsPebbleExtension and update render pipeline</name>
<files>
summercms/src/content/cms/pebble/CmsPebbleExtension.scala
summercms/src/content/cms/CmsPageService.scala
</files>
<action>
**CmsPebbleExtension.scala:**
Create Pebble Extension registering all CMS tags:
```scala
class CmsPebbleExtension extends Extension:
override def getTokenParsers: java.util.List[TokenParser] =
java.util.Arrays.asList(
new ComponentTag(),
new PlaceholderTag(),
new PutTag(),
new PartialTag()
)
override def getGlobalVariables: java.util.Map[String, Object] = null
override def getFilters: java.util.Map[String, Filter] = null
// ... other Extension methods return null/empty
```
**Update CmsPageService.scala render method:**
Implement two-pass rendering:
1. Initialize components from page config:
- Parse component definitions from page settings
- For each `[componentClass alias]` section, create ComponentInstance
- Run component lifecycle (init, onRun)
2. First pass - render page content:
- Create PageRenderContext with mutable.Map for placeholderContent
- Render page.markup with Pebble
- This collects {% put %} content into placeholderContent
3. Second pass - render layout:
- Get layout from page.config.layout
- Add pageContent variable with rendered page HTML
- Render layout.markup
- {% placeholder %} tags now have access to collected content
- {% page %} tag outputs pageContent variable
Add `{% page %}` as simple Pebble output: `{{ pageContent }}` or custom PageTag.
Note: Must configure PebbleEngine with CmsPebbleExtension when building the engine layer.
</action>
<verify>
`./mill summercms.compile` succeeds
End-to-end test: Create sample page with component, partial, and placeholder
Render should produce composed HTML with all elements
</verify>
<done>
CmsPebbleExtension registers all CMS tags with Pebble. CmsPageService.render uses two-pass rendering: first to collect put content, then to render layout with placeholders resolved.
</done>
</task>
</tasks>
<verification>
After all tasks complete:
1. `./mill summercms.compile` - all code compiles
2. Create test page with all features:
```
url = "/test"
layout = "default"
[myComponent demo]
property = "value"
==
{% put 'sidebar' %}
<aside>Custom sidebar</aside>
{% endput %}
<main>
{% component 'demo' extraProp='override' %}
{% partial 'shared-block' title='Hello' %}
</main>
```
3. Create test layout:
```
<!DOCTYPE html>
<html>
<body>
{% placeholder 'sidebar' %}
<aside>Default sidebar</aside>
{% endplaceholder %}
<div class="content">
{% page %}
</div>
</body>
</html>
```
4. Render produces correct composed HTML with custom sidebar
</verification>
<success_criteria>
- {% component 'alias' %} embeds components with property passing
- {% placeholder 'name' %}default{% endplaceholder %} in layouts with page-fillable content
- {% put 'name' %}content{% endput %} in pages fills layout placeholders
- {% partial 'name' %} includes partials with variables
- Two-pass rendering correctly resolves all template composition
</success_criteria>
<output>
After completion, create `.planning/phases/09-content-management/09-02-SUMMARY.md`
</output>

View File

@@ -0,0 +1,421 @@
---
phase: 09-content-management
plan: 03
type: execute
wave: 1
depends_on: []
files_modified:
- build.mill
- summercms/src/content/media/StorageBackend.scala
- summercms/src/content/media/LocalStorage.scala
- summercms/src/content/media/S3Storage.scala
- summercms/src/content/media/MediaItem.scala
- summercms/src/content/media/MediaLibrary.scala
- summercms/src/content/media/ImageProcessor.scala
- summercms/resources/db/migration/V010__media_library.sql
- summercms/src/api/admin/MediaRoutes.scala
autonomous: true
must_haves:
truths:
- "Admin can upload files to media library"
- "Files stored via configurable storage backend (local or S3)"
- "Images can be resized and cropped via ImageProcessor"
- "Media items organized in folder hierarchy"
- "File metadata tracked in database for listing and search"
artifacts:
- path: "summercms/src/content/media/StorageBackend.scala"
provides: "Storage abstraction trait"
contains: "trait StorageBackend"
- path: "summercms/src/content/media/MediaLibrary.scala"
provides: "Media library service"
contains: "trait MediaLibrary"
- path: "summercms/src/content/media/ImageProcessor.scala"
provides: "Image resize/crop operations"
contains: "trait ImageProcessor"
- path: "summercms/resources/db/migration/V010__media_library.sql"
provides: "Media metadata tables"
contains: "CREATE TABLE media_files"
key_links:
- from: "MediaLibrary.upload"
to: "StorageBackend.put"
via: "stream to storage"
pattern: "storage\\.put"
- from: "MediaRoutes"
to: "MediaLibrary.upload"
via: "HTTP multipart upload"
pattern: "MediaLibrary.*upload"
---
<objective>
Implement media library with storage abstraction and image processing
Purpose: Enable admin to upload, organize, and manage media files. Storage backend is pluggable (local filesystem for dev, S3 for production). Images can be resized/cropped per CONTEXT.md decisions.
Output: StorageBackend trait with Local/S3 implementations, MediaLibrary service, ImageProcessor for resize/crop, database migration for metadata, admin upload routes.
</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
@summercms/src/db/QuillContext.scala
@summercms/src/api/Routes.scala
@build.mill
</context>
<tasks>
<task type="auto">
<name>Task 1: Add dependencies and create storage backend abstraction</name>
<files>
build.mill
summercms/src/content/media/StorageBackend.scala
summercms/src/content/media/LocalStorage.scala
summercms/src/content/media/S3Storage.scala
summercms/src/content/media/MediaItem.scala
</files>
<action>
**build.mill - Add dependencies:**
```scala
// Image processing
mvn"com.sksamuel.scrimage::scrimage-core:4.1.0",
mvn"com.sksamuel.scrimage::scrimage-filters:4.1.0",
// S3 storage backend
mvn"dev.zio::zio-s3:0.4.2.1"
```
**MediaItem.scala:**
```scala
case class MediaItem(
id: Long,
path: String, // Storage path: "uploads/2024/01/image.jpg"
publicUrl: String, // Public access URL
fileName: String, // Original file name
size: Long,
mimeType: String,
folder: String, // Folder path: "uploads/2024/01"
itemType: MediaItemType,
width: Option[Int], // For images
height: Option[Int], // For images
createdAt: Instant,
updatedAt: Instant
)
enum MediaItemType:
case File, Image, Video, Audio, Document
object MediaItemType:
def fromMimeType(mime: String): MediaItemType = mime match
case m if m.startsWith("image/") => Image
case m if m.startsWith("video/") => Video
case m if m.startsWith("audio/") => Audio
case m if m.startsWith("application/pdf") => Document
// ... more mappings
case _ => File
```
**StorageBackend.scala:**
```scala
trait StorageBackend:
def put(path: String, content: ZStream[Any, Throwable, Byte], contentType: String): IO[StorageError, Unit]
def get(path: String): IO[StorageError, ZStream[Any, Throwable, Byte]]
def delete(path: String): IO[StorageError, Unit]
def exists(path: String): IO[StorageError, Boolean]
def url(path: String): IO[StorageError, String]
def list(folder: String): IO[StorageError, List[String]]
enum StorageError:
case NotFound(path: String)
case AccessDenied(path: String)
case WriteError(path: String, cause: Throwable)
case ReadError(path: String, cause: Throwable)
```
**LocalStorage.scala:**
Implementation using java.nio.file:
- basePath: Path (e.g., /var/www/media)
- publicUrlPrefix: String (e.g., /media)
- put: Create directories, stream to file
- get: Stream from file
- delete: Files.delete with existence check
- url: Combine publicUrlPrefix with path
- Validate paths don't escape basePath (security: prevent path traversal)
**S3Storage.scala:**
Implementation using zio-s3:
- bucket: String
- region: String
- Provide ZLayer for S3 client
- put: Use putObject with streaming
- get: Use getObject
- delete: Use deleteObject
- url: Generate public URL or presigned URL
- List uses listObjectsV2
Note: S3 implementation should be optional - compile without AWS credentials. Use config to select backend.
</action>
<verify>
`./mill summercms.compile` succeeds with new dependencies
LocalStorage can write/read files (unit test or manual verification)
</verify>
<done>
StorageBackend trait with LocalStorage and S3Storage implementations. MediaItem model for file metadata.
</done>
</task>
<task type="auto">
<name>Task 2: Implement ImageProcessor and MediaLibrary service</name>
<files>
summercms/src/content/media/ImageProcessor.scala
summercms/src/content/media/MediaLibrary.scala
summercms/resources/db/migration/V010__media_library.sql
</files>
<action>
**ImageProcessor.scala:**
Using Scrimage for image operations (per CONTEXT.md: resize and crop only):
```scala
trait ImageProcessor:
def resize(input: Array[Byte], width: Int, height: Int): IO[ImageError, Array[Byte]]
def crop(input: Array[Byte], x: Int, y: Int, width: Int, height: Int): IO[ImageError, Array[Byte]]
def thumbnail(input: Array[Byte], maxWidth: Int, maxHeight: Int): IO[ImageError, Array[Byte]]
def dimensions(input: Array[Byte]): IO[ImageError, (Int, Int)]
object ImageProcessor:
val live: ULayer[ImageProcessor] = ZLayer.succeed {
new ImageProcessor:
def resize(input: Array[Byte], width: Int, height: Int): IO[ImageError, Array[Byte]] =
ZIO.attemptBlocking {
ImmutableImage.loader()
.detectOrientation(true) // Handle EXIF rotation
.fromBytes(input)
.fit(width, height)
.bytes(JpegWriter.Default)
}.mapError(ImageError.ProcessingFailed(_))
def crop(input: Array[Byte], x: Int, y: Int, width: Int, height: Int): IO[ImageError, Array[Byte]] =
ZIO.attemptBlocking {
ImmutableImage.loader()
.detectOrientation(true)
.fromBytes(input)
.subimage(x, y, width, height)
.bytes(JpegWriter.Default)
}.mapError(ImageError.ProcessingFailed(_))
def thumbnail(input: Array[Byte], maxWidth: Int, maxHeight: Int): IO[ImageError, Array[Byte]] =
ZIO.attemptBlocking {
ImmutableImage.loader()
.detectOrientation(true)
.fromBytes(input)
.bound(maxWidth, maxHeight)
.bytes(JpegWriter.Default)
}.mapError(ImageError.ProcessingFailed(_))
}
```
**V010__media_library.sql:**
```sql
CREATE TABLE media_files (
id BIGSERIAL PRIMARY KEY,
path VARCHAR(500) UNIQUE NOT NULL,
file_name VARCHAR(255) NOT NULL,
size BIGINT NOT NULL,
mime_type VARCHAR(100) NOT NULL,
folder VARCHAR(500) NOT NULL,
item_type VARCHAR(20) NOT NULL,
width INT,
height INT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE media_folders (
id BIGSERIAL PRIMARY KEY,
path VARCHAR(500) UNIQUE NOT NULL,
name VARCHAR(255) NOT NULL,
parent_path VARCHAR(500),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_media_files_folder ON media_files(folder);
CREATE INDEX idx_media_files_type ON media_files(item_type);
CREATE INDEX idx_media_folders_parent ON media_folders(parent_path);
```
**MediaLibrary.scala:**
```scala
trait MediaLibrary:
def upload(folder: String, fileName: String, content: ZStream[Any, Throwable, Byte], contentType: String): IO[MediaError, MediaItem]
def list(folder: String, filter: Option[MediaItemType] = None, page: Int = 1, perPage: Int = 50): IO[MediaError, MediaPage]
def get(id: Long): IO[MediaError, Option[MediaItem]]
def delete(ids: List[Long]): IO[MediaError, Unit]
def move(id: Long, newFolder: String): IO[MediaError, MediaItem]
def createFolder(path: String): IO[MediaError, MediaFolder]
def listFolders(parent: Option[String]): IO[MediaError, List[MediaFolder]]
case class MediaPage(items: List[MediaItem], total: Long, page: Int, perPage: Int)
case class MediaFolder(id: Long, path: String, name: String, parentPath: Option[String])
```
Live implementation:
- Generate unique path: folder/YYYY/MM/uuid-filename
- Upload to storage backend
- If image: extract dimensions, generate thumbnail (store at path_thumb)
- Insert metadata into database
- Return MediaItem with public URL
</action>
<verify>
`./mill summercms.compile` succeeds
Migration file exists
ImageProcessor can resize a test image (manual test with sample JPEG)
</verify>
<done>
ImageProcessor provides resize/crop/thumbnail using Scrimage. MediaLibrary service handles uploads, metadata tracking, folder management.
</done>
</task>
<task type="auto">
<name>Task 3: Create admin media upload routes</name>
<files>
summercms/src/api/admin/MediaRoutes.scala
summercms/src/api/Routes.scala
</files>
<action>
**MediaRoutes.scala:**
Create admin API routes for media operations:
```scala
object MediaRoutes:
val routes: Routes[MediaLibrary & ImageProcessor, Response] =
Routes(
// List files in folder
Method.GET / "admin" / "api" / "media" / "files" -> handler { (req: Request) =>
for
folder <- ZIO.succeed(req.url.queryParams.getOrElse("folder", "/"))
pageNum <- ZIO.succeed(req.url.queryParams.getOrElse("page", "1").toIntOption.getOrElse(1))
library <- ZIO.service[MediaLibrary]
page <- library.list(folder, None, pageNum, 50)
yield Response.json(page.toJson)
},
// Upload file (multipart)
Method.POST / "admin" / "api" / "media" / "upload" -> handler { (req: Request) =>
for
form <- req.body.asMultipartFormStream
folder <- extractField(form, "folder").orElseSucceed("/")
file <- extractFile(form, "file")
library <- ZIO.service[MediaLibrary]
item <- library.upload(folder, file.fileName, file.content, file.contentType)
yield Response.json(item.toJson).status(Status.Created)
},
// Resize image
Method.POST / "admin" / "api" / "media" / "resize" / long("id") -> handler { (id: Long, req: Request) =>
for
body <- req.body.asJson[ResizeRequest]
library <- ZIO.service[MediaLibrary]
processor <- ZIO.service[ImageProcessor]
item <- library.get(id).someOrFail(MediaError.NotFound(id))
// Fetch, resize, re-upload
content <- library.storage.get(item.path).flatMap(_.runCollect)
resized <- processor.resize(content.toArray, body.width, body.height)
newItem <- library.upload(item.folder, item.fileName, ZStream.fromChunk(Chunk.fromArray(resized)), item.mimeType)
yield Response.json(newItem.toJson)
},
// Crop image
Method.POST / "admin" / "api" / "media" / "crop" / long("id") -> handler { (id: Long, req: Request) =>
for
body <- req.body.asJson[CropRequest]
library <- ZIO.service[MediaLibrary]
processor <- ZIO.service[ImageProcessor]
item <- library.get(id).someOrFail(MediaError.NotFound(id))
content <- library.storage.get(item.path).flatMap(_.runCollect)
cropped <- processor.crop(content.toArray, body.x, body.y, body.width, body.height)
newItem <- library.upload(item.folder, item.fileName, ZStream.fromChunk(Chunk.fromArray(cropped)), item.mimeType)
yield Response.json(newItem.toJson)
},
// Delete files
Method.DELETE / "admin" / "api" / "media" / "files" -> handler { (req: Request) =>
for
body <- req.body.asJson[DeleteRequest]
library <- ZIO.service[MediaLibrary]
_ <- library.delete(body.ids)
yield Response.ok
},
// Create folder
Method.POST / "admin" / "api" / "media" / "folders" -> handler { (req: Request) =>
for
body <- req.body.asJson[CreateFolderRequest]
library <- ZIO.service[MediaLibrary]
folder <- library.createFolder(body.path)
yield Response.json(folder.toJson).status(Status.Created)
},
// List folders
Method.GET / "admin" / "api" / "media" / "folders" -> handler { (req: Request) =>
for
parent <- ZIO.succeed(req.url.queryParams.get("parent"))
library <- ZIO.service[MediaLibrary]
folders <- library.listFolders(parent)
yield Response.json(folders.toJson)
}
)
case class ResizeRequest(width: Int, height: Int)
case class CropRequest(x: Int, y: Int, width: Int, height: Int)
case class DeleteRequest(ids: List[Long])
case class CreateFolderRequest(path: String)
```
**Update Routes.scala:**
Add MediaRoutes to admin route composition. Ensure admin routes require authentication (from Phase 6).
Security considerations:
- Validate file size limits (configurable, default 10MB)
- Validate mime types (whitelist images, documents)
- Sanitize file names (remove special chars, limit length)
- Path traversal protection in folder paths
</action>
<verify>
`./mill summercms.compile` succeeds
Routes registered in main Routes composition
Test upload endpoint with curl multipart form
</verify>
<done>
Admin media API routes provide upload, list, resize, crop, delete, folder operations. Routes integrated with main application.
</done>
</task>
</tasks>
<verification>
After all tasks complete:
1. `./mill summercms.compile` - all code compiles
2. Migration V010 ready for Flyway
3. Test local storage: upload file, verify on disk, retrieve via URL
4. Test image resize: upload JPEG, call resize endpoint, verify dimensions changed
5. Test folder operations: create folder, list contents
6. curl -X POST -F "file=@test.jpg" -F "folder=/uploads" http://localhost:8080/admin/api/media/upload
</verification>
<success_criteria>
- Files upload via multipart POST to configurable storage backend
- LocalStorage writes to local filesystem
- S3Storage integrates with AWS S3 (compile-time only, runtime optional)
- ImageProcessor resizes/crops images using Scrimage with EXIF handling
- Media metadata tracked in database
- Admin API provides full media management capabilities
</success_criteria>
<output>
After completion, create `.planning/phases/09-content-management/09-03-SUMMARY.md`
</output>

View File

@@ -0,0 +1,427 @@
---
phase: 09-content-management
plan: 04
type: execute
wave: 2
depends_on: ["09-01"]
files_modified:
- summercms/src/content/revision/ContentRevision.scala
- summercms/src/content/revision/RevisionService.scala
- summercms/src/content/revision/RevisionRepository.scala
- summercms/resources/db/migration/V011__content_revisions.sql
- summercms/src/content/cms/CmsPageService.scala
- summercms/src/api/admin/RevisionRoutes.scala
autonomous: true
must_haves:
truths:
- "Content revisions are tracked when pages are saved"
- "Revisions include full content snapshot with version number"
- "Admin can list revision history for any content item"
- "Admin can view a specific revision's content"
- "Content state (draft/published) transitions are recorded"
artifacts:
- path: "summercms/src/content/revision/ContentRevision.scala"
provides: "Revision model with version tracking"
contains: "case class ContentRevision"
- path: "summercms/src/content/revision/RevisionService.scala"
provides: "Revision CRUD operations"
contains: "trait RevisionService"
- path: "summercms/resources/db/migration/V011__content_revisions.sql"
provides: "Revision history table"
contains: "CREATE TABLE content_revisions"
key_links:
- from: "CmsPageService.save"
to: "RevisionService.createRevision"
via: "creates revision before saving"
pattern: "revisions\\.createRevision"
- from: "RevisionService"
to: "RevisionRepository"
via: "database operations"
pattern: "repo\\.(insert|find)"
---
<objective>
Implement content states and revision history tracking
Purpose: Track draft/published states and maintain revision history for CMS pages. Per CONTEXT.md decisions: two states only (Draft, Published), revision behavior at Claude's discretion.
Output: ContentRevision model, RevisionService with retention policy, database migration, CmsPageService integration, admin revision viewing routes.
</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
@summercms/src/content/cms/CmsPage.scala
@summercms/src/content/cms/CmsPageService.scala
@summercms/src/content/cms/ContentState.scala
</context>
<tasks>
<task type="auto">
<name>Task 1: Create revision model and database migration</name>
<files>
summercms/src/content/revision/ContentRevision.scala
summercms/resources/db/migration/V011__content_revisions.sql
</files>
<action>
**ContentRevision.scala:**
```scala
case class ContentRevision(
id: Long,
contentType: String, // "page", "layout", "partial"
contentKey: String, // File path: "pages/blog/post.htm"
version: Int, // Incremental version number
content: String, // Full content snapshot
state: ContentState, // State at this revision
authorId: Long, // Backend user who made change
description: Option[String], // Optional change description
createdAt: Instant
)
case class RevisionSummary(
id: Long,
version: Int,
state: ContentState,
authorId: Long,
authorName: String, // Joined from backend_users
description: Option[String],
createdAt: Instant
)
// For listing revisions without full content
```
**V011__content_revisions.sql:**
```sql
CREATE TABLE content_revisions (
id BIGSERIAL PRIMARY KEY,
content_type VARCHAR(50) NOT NULL,
content_key VARCHAR(255) NOT NULL,
version INT NOT NULL,
content TEXT NOT NULL,
state VARCHAR(20) NOT NULL,
author_id BIGINT NOT NULL,
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Composite unique constraint
CONSTRAINT uq_revision_version UNIQUE(content_type, content_key, version)
);
-- Index for efficient lookup
CREATE INDEX idx_revisions_lookup ON content_revisions(content_type, content_key, version DESC);
CREATE INDEX idx_revisions_author ON content_revisions(author_id);
-- Note: author_id not FK to backend_users for now to avoid Phase 6 dependency
-- Will add FK constraint after Phase 6 executes
COMMENT ON TABLE content_revisions IS 'Stores full snapshots of content for revision history';
```
</action>
<verify>
`./mill summercms.compile` succeeds with new models
Migration file syntactically valid
</verify>
<done>
ContentRevision model for tracking content changes. Migration creates revision table with version uniqueness constraint and efficient indexes.
</done>
</task>
<task type="auto">
<name>Task 2: Implement RevisionService with retention policy</name>
<files>
summercms/src/content/revision/RevisionRepository.scala
summercms/src/content/revision/RevisionService.scala
</files>
<action>
**RevisionRepository.scala:**
Repository for revision database operations using Quill:
```scala
trait RevisionRepository:
def insert(revision: ContentRevision): IO[RepositoryError, ContentRevision]
def find(id: Long): IO[RepositoryError, Option[ContentRevision]]
def findByKey(contentType: String, contentKey: String, version: Int): IO[RepositoryError, Option[ContentRevision]]
def listByKey(contentType: String, contentKey: String, limit: Int): IO[RepositoryError, List[RevisionSummary]]
def getLatestVersion(contentType: String, contentKey: String): IO[RepositoryError, Int]
def deleteOlderThan(contentType: String, contentKey: String, keepCount: Int): IO[RepositoryError, Long]
def deleteByKey(contentType: String, contentKey: String): IO[RepositoryError, Long]
object RevisionRepository:
val live: ZLayer[QuillContext, Nothing, RevisionRepository] = ...
```
**RevisionService.scala:**
Service layer with revision logic:
```scala
trait RevisionService:
def createRevision(
contentType: String,
contentKey: String,
content: String,
state: ContentState,
authorId: Long,
description: Option[String] = None
): IO[RevisionError, ContentRevision]
def listRevisions(
contentType: String,
contentKey: String,
limit: Int = 50
): IO[RevisionError, List[RevisionSummary]]
def getRevision(id: Long): IO[RevisionError, Option[ContentRevision]]
def getRevisionByVersion(
contentType: String,
contentKey: String,
version: Int
): IO[RevisionError, Option[ContentRevision]]
def rollback(
contentType: String,
contentKey: String,
toVersion: Int,
authorId: Long
): IO[RevisionError, ContentRevision]
case class RevisionConfig(
maxRevisionsPerItem: Int = 50, // Keep last N revisions per content item
retentionDays: Int = 90 // Or delete if older than N days
)
object RevisionService:
def live(config: RevisionConfig): ZLayer[RevisionRepository, Nothing, RevisionService] =
ZLayer.fromFunction { (repo: RevisionRepository) =>
new RevisionService:
def createRevision(
contentType: String,
contentKey: String,
content: String,
state: ContentState,
authorId: Long,
description: Option[String]
): IO[RevisionError, ContentRevision] =
for
currentVersion <- repo.getLatestVersion(contentType, contentKey)
revision = ContentRevision(
id = 0, // DB-generated
contentType = contentType,
contentKey = contentKey,
version = currentVersion + 1,
content = content,
state = state,
authorId = authorId,
description = description,
createdAt = Instant.now
)
saved <- repo.insert(revision)
// Cleanup old revisions beyond max count
_ <- repo.deleteOlderThan(contentType, contentKey, config.maxRevisionsPerItem)
yield saved
def rollback(
contentType: String,
contentKey: String,
toVersion: Int,
authorId: Long
): IO[RevisionError, ContentRevision] =
for
oldRevision <- getRevisionByVersion(contentType, contentKey, toVersion)
.someOrFail(RevisionError.VersionNotFound(toVersion))
// Create new revision with old content
newRevision <- createRevision(
contentType,
contentKey,
oldRevision.content,
oldRevision.state,
authorId,
Some(s"Rollback to version $toVersion")
)
yield newRevision
}
```
Revision decisions (per CONTEXT.md "Claude's discretion"):
- Create revision on every save (not just publish)
- Keep last 50 revisions per item
- Rollback creates new revision (preserves history)
</action>
<verify>
`./mill summercms.compile` succeeds
RevisionService methods have correct signatures
</verify>
<done>
RevisionRepository handles database operations. RevisionService provides revision creation with automatic version numbering, cleanup policy, and rollback capability.
</done>
</task>
<task type="auto">
<name>Task 3: Integrate with CmsPageService and add revision routes</name>
<files>
summercms/src/content/cms/CmsPageService.scala
summercms/src/api/admin/RevisionRoutes.scala
summercms/src/api/admin/PageRoutes.scala
</files>
<action>
**Update CmsPageService.scala:**
Add RevisionService dependency and create revisions on save:
```scala
trait CmsPageService:
def save(page: CmsPage, authorId: Long, description: Option[String] = None): IO[PageError, CmsPage]
def publish(fileName: String, authorId: Long): IO[PageError, CmsPage]
def unpublish(fileName: String, authorId: Long): IO[PageError, CmsPage]
// ... existing methods
// In live implementation:
def save(page: CmsPage, authorId: Long, description: Option[String]): IO[PageError, CmsPage] =
for
// Create revision before saving
content <- ZIO.succeed(serializePage(page))
_ <- revisions.createRevision(
"page",
page.fileName,
content,
page.config.state,
authorId,
description
)
// Write to file
_ <- ZIO.attemptBlocking {
Files.writeString(themePath.resolve(page.fileName), content)
}
// Update metadata cache
_ <- updateMetadata(page)
// Reload to get fresh mtime
saved <- load(page.fileName)
yield saved
def publish(fileName: String, authorId: Long): IO[PageError, CmsPage] =
for
page <- load(fileName)
updated = page.copy(config = page.config.copy(state = ContentState.Published))
saved <- save(updated, authorId, Some("Published"))
yield saved
def unpublish(fileName: String, authorId: Long): IO[PageError, CmsPage] =
for
page <- load(fileName)
updated = page.copy(config = page.config.copy(state = ContentState.Draft))
saved <- save(updated, authorId, Some("Unpublished"))
yield saved
```
**RevisionRoutes.scala:**
Admin routes for revision management:
```scala
object RevisionRoutes:
val routes: Routes[RevisionService, Response] =
Routes(
// List revisions for content item
Method.GET / "admin" / "api" / "revisions" / string("type") / trailing -> handler {
(contentType: String, path: Path, req: Request) =>
for
contentKey <- ZIO.succeed(path.encode)
limit <- ZIO.succeed(req.url.queryParams.getOrElse("limit", "50").toIntOption.getOrElse(50))
service <- ZIO.service[RevisionService]
revisions <- service.listRevisions(contentType, contentKey, limit)
yield Response.json(revisions.toJson)
},
// Get specific revision content
Method.GET / "admin" / "api" / "revisions" / long("id") -> handler { (id: Long, req: Request) =>
for
service <- ZIO.service[RevisionService]
revision <- service.getRevision(id)
yield revision match
case Some(r) => Response.json(r.toJson)
case None => Response.status(Status.NotFound)
},
// Rollback to specific version
Method.POST / "admin" / "api" / "revisions" / string("type") / trailing / "rollback" / int("version") ->
handler { (contentType: String, path: Path, version: Int, req: Request) =>
for
contentKey <- ZIO.succeed(path.encode)
authorId <- extractAuthorId(req) // From session/JWT
service <- ZIO.service[RevisionService]
revision <- service.rollback(contentType, contentKey, version, authorId)
yield Response.json(revision.toJson)
}
)
```
**PageRoutes.scala (new or update existing):**
Admin routes for page operations including state changes:
```scala
// Publish endpoint
Method.POST / "admin" / "api" / "pages" / trailing / "publish" -> handler {
(path: Path, req: Request) =>
for
authorId <- extractAuthorId(req)
service <- ZIO.service[CmsPageService]
page <- service.publish(path.encode, authorId)
yield Response.json(page.toJson)
}
// Unpublish endpoint
Method.POST / "admin" / "api" / "pages" / trailing / "unpublish" -> handler {
(path: Path, req: Request) =>
for
authorId <- extractAuthorId(req)
service <- ZIO.service[CmsPageService]
page <- service.unpublish(path.encode, authorId)
yield Response.json(page.toJson)
}
```
</action>
<verify>
`./mill summercms.compile` succeeds
CmsPageService.save creates revision
Revision routes accessible
</verify>
<done>
CmsPageService creates revisions on save/publish/unpublish. RevisionRoutes provides admin API for viewing history and rollback. State changes tracked with descriptions.
</done>
</task>
</tasks>
<verification>
After all tasks complete:
1. `./mill summercms.compile` - all code compiles
2. Migration V011 ready for Flyway
3. Test revision creation:
- Save page -> revision created with version 1
- Save again -> revision created with version 2
- List revisions returns both
4. Test publish/unpublish:
- Publish page -> state changes to Published, revision created
- Unpublish -> state changes to Draft, revision created
5. Test rollback:
- Rollback to version 1 -> creates version 3 with version 1 content
- Original versions preserved
</verification>
<success_criteria>
- Content revisions created automatically on save
- Version numbers increment correctly per content item
- Revisions include full content snapshot and state
- Publish/unpublish create revisions with state change
- Revision list available via admin API
- Rollback creates new revision (history preserved)
- Retention policy limits revision count per item
</success_criteria>
<output>
After completion, create `.planning/phases/09-content-management/09-04-SUMMARY.md`
</output>

View File

@@ -0,0 +1,524 @@
---
phase: 09-content-management
plan: 05
type: execute
wave: 2
depends_on: ["09-01"]
files_modified:
- summercms/src/content/menu/Menu.scala
- summercms/src/content/menu/MenuItem.scala
- summercms/src/content/menu/MenuItemType.scala
- summercms/src/content/menu/MenuService.scala
- summercms/src/content/menu/MenuItemTypeRegistry.scala
- summercms/resources/db/migration/V012__navigation_menus.sql
- summercms/src/api/admin/MenuRoutes.scala
- summercms/src/content/cms/pebble/MenuFunction.scala
autonomous: true
must_haves:
truths:
- "Admin can create navigation menus with unique codes"
- "Menu items support URL, CMS page reference, and plugin-generated types"
- "Menu items can be nested (parent-child hierarchy)"
- "Plugins can register custom menu item types"
- "Templates can access menus via Pebble function"
artifacts:
- path: "summercms/src/content/menu/Menu.scala"
provides: "Menu model"
contains: "case class Menu"
- path: "summercms/src/content/menu/MenuItem.scala"
provides: "Menu item with polymorphic types"
contains: "case class MenuItem"
- path: "summercms/src/content/menu/MenuItemTypeRegistry.scala"
provides: "Extensible item type registry"
contains: "trait MenuItemTypeRegistry"
- path: "summercms/resources/db/migration/V012__navigation_menus.sql"
provides: "Menu and item tables"
contains: "CREATE TABLE menus"
key_links:
- from: "MenuService.resolve"
to: "MenuItemTypeRegistry"
via: "resolves item type for URL generation"
pattern: "typeRegistry\\.resolve"
- from: "MenuFunction"
to: "MenuService"
via: "Pebble function fetches resolved menu"
pattern: "menuService\\.getResolved"
---
<objective>
Implement navigation menus with extensible item types
Purpose: Enable admin to create and manage navigation menus. Menu items support static URLs, CMS page references (dynamic), and plugin-generated items (extensible). Menus accessible in templates via Pebble function.
Output: Menu/MenuItem models, MenuItemType polymorphism, MenuItemTypeRegistry for plugin extensibility, MenuService, database migration, admin routes, Pebble menu() function.
</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 (CmsRouter for page URL resolution)
@summercms/src/content/cms/CmsRouter.scala
</context>
<tasks>
<task type="auto">
<name>Task 1: Create menu models and database migration</name>
<files>
summercms/src/content/menu/Menu.scala
summercms/src/content/menu/MenuItem.scala
summercms/src/content/menu/MenuItemType.scala
summercms/resources/db/migration/V012__navigation_menus.sql
</files>
<action>
**Menu.scala:**
```scala
case class Menu(
id: Long,
code: String, // Unique identifier for theme reference: "main", "footer"
name: String, // Display name: "Main Navigation"
createdAt: Instant,
updatedAt: Instant
)
case class ResolvedMenu(
code: String,
name: String,
items: List[ResolvedMenuItem]
)
```
**MenuItem.scala:**
```scala
case class MenuItem(
id: Long,
menuId: Long,
parentId: Option[Long], // For nested items
itemType: String, // "url", "cms-page", or plugin type
title: Option[String], // Display title (can inherit from page)
reference: Option[String], // URL or page reference or plugin key
cssClass: Option[String], // Optional CSS class
nestDepth: Int, // Computed depth for flat storage
sortOrder: Int,
isHidden: Boolean
)
case class ResolvedMenuItem(
title: String,
url: String,
isActive: Boolean, // True if current URL matches
cssClass: Option[String],
items: List[ResolvedMenuItem] // Nested children
)
```
**MenuItemType.scala:**
Sealed trait for built-in types, extensible via registry:
```scala
sealed trait MenuItemType:
def resolve(item: MenuItem, currentUrl: String, context: MenuResolveContext): IO[MenuError, ResolvedMenuItem]
object MenuItemType:
case object Url extends MenuItemType:
def resolve(item: MenuItem, currentUrl: String, context: MenuResolveContext): IO[MenuError, ResolvedMenuItem] =
ZIO.succeed(ResolvedMenuItem(
title = item.title.getOrElse("Link"),
url = item.reference.getOrElse("#"),
isActive = item.reference.contains(currentUrl),
cssClass = item.cssClass,
items = List.empty
))
case object CmsPage extends MenuItemType:
def resolve(item: MenuItem, currentUrl: String, context: MenuResolveContext): IO[MenuError, ResolvedMenuItem] =
for
pageFile <- ZIO.fromOption(item.reference).orElseFail(MenuError.MissingReference(item.id))
page <- context.cmsRouter.load(pageFile).mapError(MenuError.PageLoadFailed(_))
url <- context.cmsRouter.pageUrl(page, Map.empty)
title = item.title.orElse(page.config.title).getOrElse(page.fileName)
yield ResolvedMenuItem(
title = title,
url = url,
isActive = url == currentUrl,
cssClass = item.cssClass,
items = List.empty
)
case class MenuResolveContext(
cmsRouter: CmsRouter,
pluginResolvers: Map[String, MenuItemResolver] // Plugin-provided resolvers
)
// Interface for plugin-provided menu item types
trait MenuItemResolver:
def resolve(item: MenuItem, currentUrl: String): IO[MenuError, ResolvedMenuItem]
```
**V012__navigation_menus.sql:**
```sql
CREATE TABLE menus (
id BIGSERIAL PRIMARY KEY,
code VARCHAR(100) UNIQUE NOT NULL,
name VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE menu_items (
id BIGSERIAL PRIMARY KEY,
menu_id BIGINT NOT NULL REFERENCES menus(id) ON DELETE CASCADE,
parent_id BIGINT REFERENCES menu_items(id) ON DELETE CASCADE,
item_type VARCHAR(50) NOT NULL DEFAULT 'url',
title VARCHAR(255),
reference VARCHAR(500),
css_class VARCHAR(100),
nest_depth INT NOT NULL DEFAULT 0,
sort_order INT NOT NULL DEFAULT 0,
is_hidden BOOLEAN NOT NULL DEFAULT false
);
CREATE INDEX idx_menu_items_menu ON menu_items(menu_id, sort_order);
CREATE INDEX idx_menu_items_parent ON menu_items(parent_id);
-- Insert default menus
INSERT INTO menus (code, name) VALUES
('main', 'Main Navigation'),
('footer', 'Footer Navigation');
```
</action>
<verify>
`./mill summercms.compile` succeeds with new models
Migration file syntactically valid
</verify>
<done>
Menu and MenuItem models with ResolvedMenuItem for frontend. MenuItemType trait for URL and CMS page types. Migration creates tables with default menus.
</done>
</task>
<task type="auto">
<name>Task 2: Implement MenuItemTypeRegistry and MenuService</name>
<files>
summercms/src/content/menu/MenuItemTypeRegistry.scala
summercms/src/content/menu/MenuService.scala
summercms/src/content/menu/MenuRepository.scala
</files>
<action>
**MenuItemTypeRegistry.scala:**
Registry for extensible menu item types:
```scala
trait MenuItemTypeRegistry:
def register(typeName: String, resolver: MenuItemResolver): UIO[Unit]
def resolve(typeName: String): UIO[Option[MenuItemType | MenuItemResolver]]
def listTypes: UIO[List[String]]
object MenuItemTypeRegistry:
val live: ULayer[MenuItemTypeRegistry] =
ZLayer.fromZIO {
for
registry <- Ref.make(Map[String, MenuItemType | MenuItemResolver](
"url" -> MenuItemType.Url,
"cms-page" -> MenuItemType.CmsPage
))
yield new MenuItemTypeRegistry:
def register(typeName: String, resolver: MenuItemResolver): UIO[Unit] =
registry.update(_ + (typeName -> resolver))
def resolve(typeName: String): UIO[Option[MenuItemType | MenuItemResolver]] =
registry.get.map(_.get(typeName))
def listTypes: UIO[List[String]] =
registry.get.map(_.keys.toList)
}
```
**MenuRepository.scala:**
Database operations:
```scala
trait MenuRepository:
def findByCode(code: String): IO[RepositoryError, Option[Menu]]
def findById(id: Long): IO[RepositoryError, Option[Menu]]
def listAll: IO[RepositoryError, List[Menu]]
def insert(menu: Menu): IO[RepositoryError, Menu]
def update(menu: Menu): IO[RepositoryError, Menu]
def delete(id: Long): IO[RepositoryError, Unit]
def listItems(menuId: Long): IO[RepositoryError, List[MenuItem]]
def insertItem(item: MenuItem): IO[RepositoryError, MenuItem]
def updateItem(item: MenuItem): IO[RepositoryError, MenuItem]
def deleteItem(id: Long): IO[RepositoryError, Unit]
def reorderItems(menuId: Long, itemOrders: List[(Long, Int)]): IO[RepositoryError, Unit]
```
**MenuService.scala:**
Service with menu resolution:
```scala
trait MenuService:
def getMenu(code: String): IO[MenuError, Option[Menu]]
def getResolved(code: String, currentUrl: String): IO[MenuError, Option[ResolvedMenu]]
def listMenus: IO[MenuError, List[Menu]]
def createMenu(code: String, name: String): IO[MenuError, Menu]
def updateMenu(id: Long, name: String): IO[MenuError, Menu]
def deleteMenu(id: Long): IO[MenuError, Unit]
def addItem(menuId: Long, item: MenuItem): IO[MenuError, MenuItem]
def updateItem(item: MenuItem): IO[MenuError, MenuItem]
def removeItem(id: Long): IO[MenuError, Unit]
def reorderItems(menuId: Long, itemOrders: List[(Long, Int)]): IO[MenuError, Unit]
object MenuService:
def live: ZLayer[MenuRepository & MenuItemTypeRegistry & CmsRouter, Nothing, MenuService] =
ZLayer.fromFunction { (repo: MenuRepository, typeRegistry: MenuItemTypeRegistry, cmsRouter: CmsRouter) =>
new MenuService:
def getResolved(code: String, currentUrl: String): IO[MenuError, Option[ResolvedMenu]] =
for
menuOpt <- repo.findByCode(code).mapError(MenuError.RepositoryFailed(_))
result <- menuOpt match
case None => ZIO.succeed(None)
case Some(menu) =>
for
items <- repo.listItems(menu.id).mapError(MenuError.RepositoryFailed(_))
context = MenuResolveContext(cmsRouter, Map.empty) // Plugin resolvers added at boot
resolved <- resolveItems(items, currentUrl, context)
yield Some(ResolvedMenu(menu.code, menu.name, resolved))
yield result
private def resolveItems(
items: List[MenuItem],
currentUrl: String,
context: MenuResolveContext
): IO[MenuError, List[ResolvedMenuItem]] =
// Build tree structure from flat list
val byParent = items.groupBy(_.parentId)
def buildTree(parentId: Option[Long]): IO[MenuError, List[ResolvedMenuItem]] =
ZIO.foreach(byParent.getOrElse(parentId, List.empty).filterNot(_.isHidden).sortBy(_.sortOrder)) { item =>
for
resolverOpt <- typeRegistry.resolve(item.itemType)
resolver <- ZIO.fromOption(resolverOpt).orElseFail(MenuError.UnknownItemType(item.itemType))
base <- resolver match
case t: MenuItemType => t.resolve(item, currentUrl, context)
case r: MenuItemResolver => r.resolve(item, currentUrl)
children <- buildTree(Some(item.id))
yield base.copy(items = children)
}
buildTree(None)
}
```
Handle circular reference protection:
- Validate parentId != id on save
- Limit max depth (e.g., 5 levels)
- Use iterative tree building if recursion depth is concern
</action>
<verify>
`./mill summercms.compile` succeeds
MenuService.getResolved builds nested menu structure
Type registry allows plugin registration
</verify>
<done>
MenuItemTypeRegistry allows plugins to register custom item types. MenuService resolves menus with nested items, URL generation, and active state detection.
</done>
</task>
<task type="auto">
<name>Task 3: Create admin routes and Pebble menu function</name>
<files>
summercms/src/api/admin/MenuRoutes.scala
summercms/src/content/cms/pebble/MenuFunction.scala
summercms/src/content/cms/pebble/CmsPebbleExtension.scala
</files>
<action>
**MenuRoutes.scala:**
Admin API for menu management:
```scala
object MenuRoutes:
val routes: Routes[MenuService, Response] =
Routes(
// List all menus
Method.GET / "admin" / "api" / "menus" -> handler { (req: Request) =>
for
service <- ZIO.service[MenuService]
menus <- service.listMenus
yield Response.json(menus.toJson)
},
// Get menu with items
Method.GET / "admin" / "api" / "menus" / string("code") -> handler { (code: String, req: Request) =>
for
service <- ZIO.service[MenuService]
menu <- service.getMenu(code)
items <- menu.fold(ZIO.succeed(List.empty[MenuItem]))(m =>
ZIO.serviceWithZIO[MenuRepository](_.listItems(m.id))
)
yield menu match
case Some(m) => Response.json(MenuWithItems(m, items).toJson)
case None => Response.status(Status.NotFound)
},
// Create menu
Method.POST / "admin" / "api" / "menus" -> handler { (req: Request) =>
for
body <- req.body.asJson[CreateMenuRequest]
service <- ZIO.service[MenuService]
menu <- service.createMenu(body.code, body.name)
yield Response.json(menu.toJson).status(Status.Created)
},
// Update menu
Method.PUT / "admin" / "api" / "menus" / long("id") -> handler { (id: Long, req: Request) =>
for
body <- req.body.asJson[UpdateMenuRequest]
service <- ZIO.service[MenuService]
menu <- service.updateMenu(id, body.name)
yield Response.json(menu.toJson)
},
// Delete menu
Method.DELETE / "admin" / "api" / "menus" / long("id") -> handler { (id: Long, req: Request) =>
for
service <- ZIO.service[MenuService]
_ <- service.deleteMenu(id)
yield Response.ok
},
// Add menu item
Method.POST / "admin" / "api" / "menus" / long("menuId") / "items" -> handler { (menuId: Long, req: Request) =>
for
body <- req.body.asJson[CreateItemRequest]
service <- ZIO.service[MenuService]
item <- service.addItem(menuId, body.toMenuItem(menuId))
yield Response.json(item.toJson).status(Status.Created)
},
// Update menu item
Method.PUT / "admin" / "api" / "menus" / "items" / long("id") -> handler { (id: Long, req: Request) =>
for
body <- req.body.asJson[UpdateItemRequest]
service <- ZIO.service[MenuService]
item <- service.updateItem(body.toMenuItem(id))
yield Response.json(item.toJson)
},
// Delete menu item
Method.DELETE / "admin" / "api" / "menus" / "items" / long("id") -> handler { (id: Long, req: Request) =>
for
service <- ZIO.service[MenuService]
_ <- service.removeItem(id)
yield Response.ok
},
// Reorder items
Method.POST / "admin" / "api" / "menus" / long("menuId") / "reorder" -> handler { (menuId: Long, req: Request) =>
for
body <- req.body.asJson[ReorderRequest]
service <- ZIO.service[MenuService]
_ <- service.reorderItems(menuId, body.itemOrders)
yield Response.ok
},
// List available item types
Method.GET / "admin" / "api" / "menus" / "item-types" -> handler { (req: Request) =>
for
registry <- ZIO.service[MenuItemTypeRegistry]
types <- registry.listTypes
yield Response.json(types.toJson)
}
)
```
**MenuFunction.scala:**
Pebble function for template access:
```scala
class MenuFunction(menuService: MenuService, runtime: Runtime[Any]) extends Function:
override def getArgumentNames: java.util.List[String] =
java.util.Arrays.asList("code")
override def execute(
args: java.util.Map[String, Object],
self: PebbleTemplate,
context: EvaluationContext,
lineNumber: Int
): Object =
val code = args.get("code").asInstanceOf[String]
val currentUrl = context.getScopeChain.get("currentUrl").asInstanceOf[String]
// Execute ZIO effect synchronously in template context
Unsafe.unsafe { implicit u =>
runtime.unsafe.run(
menuService.getResolved(code, currentUrl)
).getOrThrowFiberFailure()
} match
case Some(menu) => menu.asJava // Convert to Java collections for Pebble
case None => null
// Usage in template:
// {% set mainMenu = menu('main') %}
// {% for item in mainMenu.items %}
// <a href="{{ item.url }}" class="{{ item.isActive ? 'active' : '' }}">{{ item.title }}</a>
// {% endfor %}
```
**Update CmsPebbleExtension.scala:**
Register MenuFunction:
```scala
class CmsPebbleExtension(menuService: MenuService, runtime: Runtime[Any]) extends Extension:
override def getFunctions: java.util.Map[String, Function] =
java.util.Map.of(
"menu", new MenuFunction(menuService, runtime)
)
// ... existing token parsers
```
Note: Extension now needs MenuService injected at construction time.
</action>
<verify>
`./mill summercms.compile` succeeds
MenuRoutes provide full CRUD
menu('main') function works in templates
</verify>
<done>
Admin menu routes provide full CRUD for menus and items. Pebble menu() function allows templates to access resolved menus with active state detection. Item types extensible via registry.
</done>
</task>
</tasks>
<verification>
After all tasks complete:
1. `./mill summercms.compile` - all code compiles
2. Migration V012 ready for Flyway
3. Test menu CRUD:
- Create menu with code "test"
- Add URL item, CMS page item
- Nest items under parent
- Reorder items
4. Test menu resolution:
- GET /admin/api/menus/main returns menu with items
- Items have correct URLs and active state
5. Test Pebble function:
- Template with `{% set nav = menu('main') %}` resolves menu
- Loop over items renders navigation
6. Test extensibility:
- Plugin can register custom item type
- Custom type appears in item-types list
</verification>
<success_criteria>
- Menus created with unique codes for theme reference
- Menu items support URL, CMS page, and extensible plugin types
- Nested items maintain parent-child hierarchy
- Resolved menu includes active state for current URL
- Plugins can register custom menu item types via registry
- Templates access menus via menu('code') Pebble function
- Admin API provides full menu management
</success_criteria>
<output>
After completion, create `.planning/phases/09-content-management/09-05-SUMMARY.md`
</output>

View 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>