fix(09): revise plans based on checker feedback

- Plan 02: Added key_link clarifying CmsPageService.render populates
  PageRenderContext.components before template rendering. Updated
  Task 2/3 actions to emphasize component initialization flow.

- Plan 03: Split into 03 (storage + library core) and 03b (image
  processing + routes) to reduce scope from 9 files to 7+4 files.
  Estimated context reduced from ~65% to ~45% each.

- Plan 03b: New plan for ImageProcessor and MediaRoutes. Added
  specific curl command with -F flags and expected JSON response.

- Plan 05: Added plugin integration test (MenuPluginIntegrationSpec)
  demonstrating custom menu item type registration, resolution, and
  template rendering per CONT-09 requirement.

- Plan 06: Reframed must_haves truths from implementation details
  to user-observable outcomes (e.g., 'Developer edits template file,
  browser refresh shows changes without server restart')

- Roadmap: Updated Phase 9 from 6 to 7 plans.
This commit is contained in:
Jakub Zych
2026-02-05 15:41:50 +01:00
parent dca89e10cd
commit edbee885ac
6 changed files with 506 additions and 296 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**: 6 plans
**Plans**: 7 plans
Plans:
- [ ] 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-03-PLAN.md - Media library core (StorageBackend, LocalStorage, S3Storage, MediaLibrary)
- [ ] 09-03b-PLAN.md - Media routes and image processing (ImageProcessor, MediaRoutes)
- [ ] 09-04-PLAN.md - Content states and revisions (ContentRevision, RevisionService, publish/unpublish)
- [ ] 09-05-PLAN.md - Navigation menus (Menu, MenuItem, MenuItemTypeRegistry, MenuService)
- [ ] 09-05-PLAN.md - Navigation menus with plugin integration (Menu, MenuItem, MenuItemTypeRegistry, MenuService, plugin tests)
- [ ] 09-06-PLAN.md - Hot reload (FileWatcher, HotReloadService, TemplateInvalidator)
### Phase 10: Core Plugins
@@ -217,7 +218,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/6 | Planned | - |
| 9. Content Management | 0/7 | Planned | - |
| 10. Core Plugins | 0/4 | Not started | - |
---

View File

@@ -40,6 +40,10 @@ must_haves:
to: "PageRenderContext.placeholderContent"
via: "checks for put content before rendering default"
pattern: "placeholderContent\\.get"
- from: "CmsPageService.render"
to: "PageRenderContext.components"
via: "initializes components from page config before rendering"
pattern: "PageRenderContext.*components.*="
---
<objective>
@@ -136,6 +140,10 @@ Implement `{% component 'alias' prop1='value' %}`:
- Call component.render() using pageContext.runtime.unsafeRun
- Write rendered HTML to output
**CRITICAL: ComponentTag depends on PageRenderContext.components being pre-populated.**
The components map is populated by CmsPageService.render (Task 3) BEFORE the template is rendered.
ComponentTag only looks up by alias - it does NOT initialize components.
Component lookup: Page's INI section defines components like:
```
[blogPosts posts]
@@ -190,17 +198,24 @@ class CmsPebbleExtension extends Extension:
```
**Update CmsPageService.scala render method:**
Implement two-pass rendering:
Implement two-pass rendering with COMPONENT INITIALIZATION:
1. Initialize components from page config:
- Parse component definitions from page settings
- For each `[componentClass alias]` section, create ComponentInstance
- Run component lifecycle (init, onRun)
1. **Initialize components from page config (CRITICAL - populates PageRenderContext.components):**
- Parse component definitions from page settings (INI format)
- For each `[componentClass alias]` section:
a. Look up component class from ComponentRegistry (from Phase 3)
b. Create ComponentInstance with properties from section
c. Run component lifecycle (init, onRun)
d. Store in components Map keyed by alias
- This Map is passed to PageRenderContext BEFORE any template rendering
2. First pass - render page content:
- Create PageRenderContext with mutable.Map for placeholderContent
- Create PageRenderContext with:
- `components`: the Map populated in step 1
- `placeholderContent`: empty mutable.Map
- Render page.markup with Pebble
- This collects {% put %} content into placeholderContent
- {% component 'alias' %} tags can now resolve from pre-populated components Map
3. Second pass - render layout:
- Get layout from page.config.layout
@@ -219,7 +234,7 @@ 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.
CmsPebbleExtension registers all CMS tags with Pebble. CmsPageService.render initializes components from page config into PageRenderContext.components Map, then uses two-pass rendering: first to collect put content, then to render layout with placeholders resolved.
</done>
</task>
@@ -267,6 +282,8 @@ property = "value"
- {% 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
- CmsPageService.render initializes components from page config BEFORE template rendering
- PageRenderContext.components is populated when ComponentTag executes
- Two-pass rendering correctly resolves all template composition
</success_criteria>

View File

@@ -11,16 +11,13 @@ files_modified:
- 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:
@@ -30,9 +27,6 @@ must_haves:
- 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"
@@ -41,18 +35,14 @@ must_haves:
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
Implement media library core with storage abstraction
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.
Purpose: Enable admin to upload and manage media files. Storage backend is pluggable (local filesystem for dev, S3 for production). This plan focuses on the core storage and library service.
Output: StorageBackend trait with Local/S3 implementations, MediaLibrary service, ImageProcessor for resize/crop, database migration for metadata, admin upload routes.
Output: StorageBackend trait with Local/S3 implementations, MediaLibrary service, database migration for metadata.
</objective>
<execution_context>
@@ -65,7 +55,6 @@ Output: StorageBackend trait with Local/S3 implementations, MediaLibrary service
@.planning/ROADMAP.md
@.planning/phases/09-content-management/09-RESEARCH.md
@summercms/src/db/QuillContext.scala
@summercms/src/api/Routes.scala
@build.mill
</context>
@@ -83,10 +72,6 @@ Output: StorageBackend trait with Local/S3 implementations, MediaLibrary service
<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"
```
@@ -171,54 +156,12 @@ StorageBackend trait with LocalStorage and S3Storage implementations. MediaItem
</task>
<task type="auto">
<name>Task 2: Implement ImageProcessor and MediaLibrary service</name>
<name>Task 2: Implement MediaLibrary service and database migration</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 (
@@ -266,132 +209,15 @@ case class MediaFolder(id: Long, path: String, name: String, parentPath: Option[
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)
Migration file exists and is syntactically valid
</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.
MediaLibrary service handles uploads and metadata tracking. Database migration creates tables for files and folders.
</done>
</task>
@@ -402,18 +228,15 @@ 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
4. Test folder operations: create folder, list contents
</verification>
<success_criteria>
- Files upload via multipart POST to configurable storage backend
- LocalStorage writes to local filesystem
- Files upload via MediaLibrary to configurable storage backend
- LocalStorage writes to local filesystem with path traversal protection
- 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
- Folder hierarchy supported
</success_criteria>
<output>

View File

@@ -0,0 +1,310 @@
---
phase: 09-content-management
plan: 03b
type: execute
wave: 2
depends_on: ["09-03"]
files_modified:
- build.mill
- summercms/src/content/media/ImageProcessor.scala
- summercms/src/api/admin/MediaRoutes.scala
- summercms/src/api/Routes.scala
autonomous: true
must_haves:
truths:
- "Images can be resized and cropped via ImageProcessor"
- "Admin can upload files through HTTP multipart endpoint"
- "Admin can resize and crop images through API"
- "Admin can delete media files through API"
artifacts:
- path: "summercms/src/content/media/ImageProcessor.scala"
provides: "Image resize/crop operations"
contains: "trait ImageProcessor"
- path: "summercms/src/api/admin/MediaRoutes.scala"
provides: "Admin media API routes"
contains: "object MediaRoutes"
key_links:
- from: "MediaRoutes"
to: "MediaLibrary.upload"
via: "HTTP multipart upload"
pattern: "MediaLibrary.*upload"
- from: "MediaRoutes.resize"
to: "ImageProcessor.resize"
via: "image processing call"
pattern: "processor\\.resize"
---
<objective>
Implement image processing and admin media upload routes
Purpose: Enable image resize/crop operations and expose media library functionality through admin HTTP API. This plan builds on the core MediaLibrary from Plan 03.
Output: ImageProcessor for resize/crop using Scrimage, admin upload routes with multipart handling.
</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-03-SUMMARY.md
@summercms/src/content/media/MediaLibrary.scala
@summercms/src/api/Routes.scala
@build.mill
</context>
<tasks>
<task type="auto">
<name>Task 1: Implement ImageProcessor using Scrimage</name>
<files>
build.mill
summercms/src/content/media/ImageProcessor.scala
</files>
<action>
**build.mill - Add Scrimage dependencies:**
```scala
// Image processing
mvn"com.sksamuel.scrimage::scrimage-core:4.1.0",
mvn"com.sksamuel.scrimage::scrimage-filters:4.1.0",
```
**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(_))
}
```
</action>
<verify>
`./mill summercms.compile` succeeds with Scrimage dependencies
ImageProcessor can resize a test image (manual test with sample JPEG)
</verify>
<done>
ImageProcessor provides resize/crop/thumbnail using Scrimage with EXIF orientation handling.
</done>
</task>
<task type="auto">
<name>Task 2: 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:
```bash
curl -X POST http://localhost:8080/admin/api/media/upload \
-H "Authorization: Bearer $TOKEN" \
-F "file=@test.jpg" \
-F "folder=/uploads"
```
Expected response (HTTP 201):
```json
{
"id": 1,
"path": "uploads/2024/01/abc123-test.jpg",
"publicUrl": "/media/uploads/2024/01/abc123-test.jpg",
"fileName": "test.jpg",
"size": 12345,
"mimeType": "image/jpeg",
"folder": "/uploads",
"itemType": "Image",
"width": 800,
"height": 600,
"createdAt": "2024-01-15T10:30:00Z",
"updatedAt": "2024-01-15T10:30:00Z"
}
```
Test list endpoint:
```bash
curl http://localhost:8080/admin/api/media/files?folder=/uploads \
-H "Authorization: Bearer $TOKEN"
```
Expected response (HTTP 200):
```json
{
"items": [...],
"total": 1,
"page": 1,
"perPage": 50
}
```
</verify>
<done>
Admin media API routes provide upload, list, resize, crop, delete, folder operations. Routes integrated with main application and protected by admin authentication.
</done>
</task>
</tasks>
<verification>
After all tasks complete:
1. `./mill summercms.compile` - all code compiles
2. Test image resize: upload JPEG, call resize endpoint, verify dimensions changed
3. Test upload with curl:
```bash
curl -X POST http://localhost:8080/admin/api/media/upload \
-H "Authorization: Bearer $TOKEN" \
-F "file=@test.jpg" \
-F "folder=/uploads"
```
Expect: HTTP 201 with JSON containing id, path, publicUrl, mimeType, dimensions
4. Test folder creation and listing
</verification>
<success_criteria>
- ImageProcessor resizes/crops images using Scrimage with EXIF handling
- Upload endpoint accepts multipart form with file and folder fields
- Upload returns JSON with complete MediaItem including id, path, publicUrl
- Resize endpoint creates new resized version of image
- All routes protected by admin authentication
</success_criteria>
<output>
After completion, create `.planning/phases/09-content-management/09-03b-SUMMARY.md`
</output>

View File

@@ -13,6 +13,7 @@ files_modified:
- summercms/resources/db/migration/V012__navigation_menus.sql
- summercms/src/api/admin/MenuRoutes.scala
- summercms/src/content/cms/pebble/MenuFunction.scala
- summercms/test/content/menu/MenuPluginIntegrationSpec.scala
autonomous: true
must_haves:
@@ -20,7 +21,7 @@ must_haves:
- "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"
- "Plugins can register custom menu item types that render correctly"
- "Templates can access menus via Pebble function"
artifacts:
- path: "summercms/src/content/menu/Menu.scala"
@@ -35,6 +36,9 @@ must_haves:
- path: "summercms/resources/db/migration/V012__navigation_menus.sql"
provides: "Menu and item tables"
contains: "CREATE TABLE menus"
- path: "summercms/test/content/menu/MenuPluginIntegrationSpec.scala"
provides: "Plugin integration test"
contains: "MenuPluginIntegrationSpec"
key_links:
- from: "MenuService.resolve"
to: "MenuItemTypeRegistry"
@@ -44,6 +48,10 @@ must_haves:
to: "MenuService"
via: "Pebble function fetches resolved menu"
pattern: "menuService\\.getResolved"
- from: "Plugin registration"
to: "MenuItemTypeRegistry.register"
via: "plugin boot registers custom type"
pattern: "registry\\.register"
---
<objective>
@@ -51,7 +59,7 @@ 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.
Output: Menu/MenuItem models, MenuItemType polymorphism, MenuItemTypeRegistry for plugin extensibility, MenuService, database migration, admin routes, Pebble menu() function, plugin integration test.
</objective>
<execution_context>
@@ -327,11 +335,12 @@ MenuItemTypeRegistry allows plugins to register custom item types. MenuService r
</task>
<task type="auto">
<name>Task 3: Create admin routes and Pebble menu function</name>
<name>Task 3: Create admin routes, Pebble function, and plugin integration test</name>
<files>
summercms/src/api/admin/MenuRoutes.scala
summercms/src/content/cms/pebble/MenuFunction.scala
summercms/src/content/cms/pebble/CmsPebbleExtension.scala
summercms/test/content/menu/MenuPluginIntegrationSpec.scala
</files>
<action>
**MenuRoutes.scala:**
@@ -341,88 +350,33 @@ 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)
},
Method.GET / "admin" / "api" / "menus" -> handler { ... },
// 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)
},
Method.GET / "admin" / "api" / "menus" / string("code") -> handler { ... },
// 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)
},
Method.POST / "admin" / "api" / "menus" -> handler { ... },
// 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)
},
Method.PUT / "admin" / "api" / "menus" / long("id") -> handler { ... },
// 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
},
Method.DELETE / "admin" / "api" / "menus" / long("id") -> handler { ... },
// 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)
},
Method.POST / "admin" / "api" / "menus" / long("menuId") / "items" -> handler { ... },
// 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)
},
Method.PUT / "admin" / "api" / "menus" / "items" / long("id") -> handler { ... },
// 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
},
Method.DELETE / "admin" / "api" / "menus" / "items" / long("id") -> handler { ... },
// 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
},
Method.POST / "admin" / "api" / "menus" / long("menuId") / "reorder" -> handler { ... },
// List available item types
// List available item types (includes plugin-registered types)
Method.GET / "admin" / "api" / "menus" / "item-types" -> handler { (req: Request) =>
for
registry <- ZIO.service[MenuItemTypeRegistry]
@@ -465,25 +419,124 @@ class MenuFunction(menuService: MenuService, runtime: Runtime[Any]) extends Func
```
**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
```
Register MenuFunction (constructor now needs MenuService).
Note: Extension now needs MenuService injected at construction time.
**MenuPluginIntegrationSpec.scala:**
Test demonstrating plugin integration (CONT-09 requirement):
```scala
class MenuPluginIntegrationSpec extends ZIOSpecDefault:
// Example plugin-provided menu item type: "blog-category"
// Resolves blog category IDs to category pages
class BlogCategoryResolver(blogService: BlogService) extends MenuItemResolver:
def resolve(item: MenuItem, currentUrl: String): IO[MenuError, ResolvedMenuItem] =
for
categoryId <- ZIO.fromOption(item.reference.flatMap(_.toLongOption))
.orElseFail(MenuError.InvalidReference(item.id, "Expected category ID"))
category <- blogService.getCategory(categoryId)
.someOrFail(MenuError.ReferenceNotFound(item.id, s"Category $categoryId"))
url = s"/blog/category/${category.slug}"
yield ResolvedMenuItem(
title = item.title.getOrElse(category.name),
url = url,
isActive = currentUrl == url || currentUrl.startsWith(s"$url/"),
cssClass = item.cssClass,
items = List.empty
)
def spec = suite("MenuPluginIntegration")(
test("plugin can register custom menu item type") {
for
registry <- ZIO.service[MenuItemTypeRegistry]
blogService = TestBlogService()
resolver = new BlogCategoryResolver(blogService)
// Plugin registers its type at boot
_ <- registry.register("blog-category", resolver)
// Verify type is registered
types <- registry.listTypes
_ <- assertTrue(types.contains("blog-category"))
// Verify type appears in admin API
// GET /admin/api/menus/item-types should include "blog-category"
yield assertTrue(types.contains("url"), types.contains("cms-page"), types.contains("blog-category"))
},
test("plugin-registered type resolves correctly in menu") {
for
registry <- ZIO.service[MenuItemTypeRegistry]
menuService <- ZIO.service[MenuService]
blogService = TestBlogService.withCategory(1L, "Tech", "tech")
resolver = new BlogCategoryResolver(blogService)
_ <- registry.register("blog-category", resolver)
// Create menu with plugin item type
menu <- menuService.createMenu("test-menu", "Test Menu")
_ <- menuService.addItem(menu.id, MenuItem(
id = 0,
menuId = menu.id,
parentId = None,
itemType = "blog-category", // Plugin-provided type
title = None, // Will inherit from category
reference = Some("1"), // Category ID
cssClass = None,
nestDepth = 0,
sortOrder = 0,
isHidden = false
))
// Resolve menu - plugin type should work
resolved <- menuService.getResolved("test-menu", "/other")
yield assertTrue(
resolved.isDefined,
resolved.get.items.head.title == "Tech",
resolved.get.items.head.url == "/blog/category/tech",
resolved.get.items.head.isActive == false
)
},
test("plugin type renders correctly in Pebble template") {
for
registry <- ZIO.service[MenuItemTypeRegistry]
menuService <- ZIO.service[MenuService]
blogService = TestBlogService.withCategory(1L, "Tech", "tech")
_ <- registry.register("blog-category", new BlogCategoryResolver(blogService))
// Setup menu with plugin item
menu <- menuService.createMenu("nav", "Navigation")
_ <- menuService.addItem(menu.id, MenuItem(/* blog-category type */))
// Render template using menu
template = """
{% set nav = menu('nav') %}
{% for item in nav.items %}
<a href="{{ item.url }}">{{ item.title }}</a>
{% endfor %}
"""
rendered <- renderPebbleTemplate(template, Map("currentUrl" -> "/"))
yield assertTrue(
rendered.contains("""<a href="/blog/category/tech">Tech</a>""")
)
}
).provide(
MenuItemTypeRegistry.live,
MenuService.live,
MenuRepository.live,
// ... test dependencies
)
```
</action>
<verify>
`./mill summercms.compile` succeeds
MenuRoutes provide full CRUD
`./mill summercms.test` - MenuPluginIntegrationSpec passes
menu('main') function works in templates
Plugin-registered type appears in item-types API endpoint
</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.
Admin menu routes provide full CRUD for menus and items. Pebble menu() function allows templates to access resolved menus. Plugin integration test demonstrates custom menu item type registration, resolution, and template rendering.
</done>
</task>
@@ -492,21 +545,24 @@ Admin menu routes provide full CRUD for menus and items. Pebble menu() function
<verification>
After all tasks complete:
1. `./mill summercms.compile` - all code compiles
2. Migration V012 ready for Flyway
3. Test menu CRUD:
2. `./mill summercms.test` - plugin integration tests pass
3. Migration V012 ready for Flyway
4. Test menu CRUD:
- Create menu with code "test"
- Add URL item, CMS page item
- Nest items under parent
- Reorder items
4. Test menu resolution:
5. Test menu resolution:
- GET /admin/api/menus/main returns menu with items
- Items have correct URLs and active state
5. Test Pebble function:
6. 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
7. Test plugin extensibility (CONT-09):
- Plugin can register custom item type via registry
- Custom type appears in GET /admin/api/menus/item-types
- Menu items with custom type resolve correctly
- Custom type renders correctly in Pebble template
</verification>
<success_criteria>
@@ -515,8 +571,12 @@ After all tasks complete:
- Nested items maintain parent-child hierarchy
- Resolved menu includes active state for current URL
- Plugins can register custom menu item types via registry
- Plugin-registered types appear in item-types API
- Plugin types resolve to valid ResolvedMenuItem with correct URLs
- Plugin types render correctly in Pebble templates
- Templates access menus via menu('code') Pebble function
- Admin API provides full menu management
- Integration test proves end-to-end plugin type functionality
</success_criteria>
<output>

View File

@@ -14,14 +14,14 @@ 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)"
- "Developer edits template file, browser refresh shows changes without server restart"
- "Page/layout/partial modifications reflect immediately after save"
- "Hot reload only active when DEV_MODE environment variable is true"
- "File system changes detected efficiently without CPU overhead"
- "Only changed template is reloaded, not the entire cache"
artifacts:
- path: "summercms/src/hot/FileWatcher.scala"
provides: "File change detection using WatchService"
provides: "File change detection"
contains: "class FileWatcher"
- path: "summercms/src/hot/HotReloadService.scala"
provides: "Reload orchestration service"
@@ -500,12 +500,11 @@ After all tasks complete:
<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
- Template file saved -> changes visible on next browser refresh (no restart)
- Targeted invalidation: only changed template reloaded, not all templates
- Page changes also trigger router rebuild for URL changes
- Production mode uses full caching for performance
- Event debouncing prevents rapid reload spam
- Event debouncing prevents rapid reload spam during fast typing
</success_criteria>
<output>