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:
@@ -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 | - |
|
||||
|
||||
---
|
||||
|
||||
257
.planning/phases/09-content-management/09-01-PLAN.md
Normal file
257
.planning/phases/09-content-management/09-01-PLAN.md
Normal 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>
|
||||
275
.planning/phases/09-content-management/09-02-PLAN.md
Normal file
275
.planning/phases/09-content-management/09-02-PLAN.md
Normal 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>
|
||||
421
.planning/phases/09-content-management/09-03-PLAN.md
Normal file
421
.planning/phases/09-content-management/09-03-PLAN.md
Normal 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>
|
||||
427
.planning/phases/09-content-management/09-04-PLAN.md
Normal file
427
.planning/phases/09-content-management/09-04-PLAN.md
Normal 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>
|
||||
524
.planning/phases/09-content-management/09-05-PLAN.md
Normal file
524
.planning/phases/09-content-management/09-05-PLAN.md
Normal 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>
|
||||
513
.planning/phases/09-content-management/09-06-PLAN.md
Normal file
513
.planning/phases/09-content-management/09-06-PLAN.md
Normal file
@@ -0,0 +1,513 @@
|
||||
---
|
||||
phase: 09-content-management
|
||||
plan: 06
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on: ["09-01", "09-02"]
|
||||
files_modified:
|
||||
- summercms/src/hot/FileWatcher.scala
|
||||
- summercms/src/hot/HotReloadService.scala
|
||||
- summercms/src/hot/TemplateInvalidator.scala
|
||||
- summercms/src/config/AppConfig.scala
|
||||
- summercms/src/Main.scala
|
||||
autonomous: true
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Template file changes trigger Pebble cache invalidation"
|
||||
- "Page/layout/partial modifications reflect immediately in browser"
|
||||
- "Hot reload only active in development mode"
|
||||
- "File watcher uses OS-native WatchService (not polling)"
|
||||
- "Cache invalidation is targeted (not full cache clear)"
|
||||
artifacts:
|
||||
- path: "summercms/src/hot/FileWatcher.scala"
|
||||
provides: "File change detection using WatchService"
|
||||
contains: "class FileWatcher"
|
||||
- path: "summercms/src/hot/HotReloadService.scala"
|
||||
provides: "Reload orchestration service"
|
||||
contains: "trait HotReloadService"
|
||||
- path: "summercms/src/hot/TemplateInvalidator.scala"
|
||||
provides: "Pebble cache invalidation"
|
||||
contains: "class TemplateInvalidator"
|
||||
key_links:
|
||||
- from: "FileWatcher"
|
||||
to: "HotReloadService"
|
||||
via: "file change events trigger reload"
|
||||
pattern: "HotReloadService.*onFileChanged"
|
||||
- from: "HotReloadService"
|
||||
to: "PebbleEngine.cache"
|
||||
via: "invalidates template cache"
|
||||
pattern: "pebbleEngine.*invalidate"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Implement hot reload for development mode
|
||||
|
||||
Purpose: Enable fast iteration during development by automatically reloading templates when files change. Per DIFF-02 requirement and CONTEXT.md decision for Claude's discretion on implementation details.
|
||||
|
||||
Output: FileWatcher using JDK WatchService, HotReloadService orchestrating reloads, TemplateInvalidator for Pebble cache, configuration for dev mode detection.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/home/jin/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@/home/jin/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/phases/09-content-management/09-RESEARCH.md
|
||||
@.planning/phases/09-content-management/09-01-SUMMARY.md
|
||||
@.planning/phases/09-content-management/09-02-SUMMARY.md
|
||||
@summercms/src/config/AppConfig.scala
|
||||
@summercms/src/Main.scala
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Create FileWatcher using JDK WatchService</name>
|
||||
<files>
|
||||
summercms/src/hot/FileWatcher.scala
|
||||
summercms/src/config/AppConfig.scala
|
||||
</files>
|
||||
<action>
|
||||
**Update AppConfig.scala:**
|
||||
Add development mode configuration:
|
||||
```scala
|
||||
case class DevConfig(
|
||||
enabled: Boolean = false, // Enable dev features
|
||||
hotReload: Boolean = false, // Enable hot reload
|
||||
watchPaths: List[String] = List(), // Additional paths to watch
|
||||
watchDebounceMs: Int = 100 // Debounce rapid changes
|
||||
)
|
||||
|
||||
case class AppConfig(
|
||||
server: ServerConfig,
|
||||
database: DatabaseConfig,
|
||||
dev: DevConfig = DevConfig()
|
||||
)
|
||||
```
|
||||
|
||||
Update application.conf:
|
||||
```hocon
|
||||
dev {
|
||||
enabled = false
|
||||
enabled = ${?DEV_MODE}
|
||||
hot-reload = false
|
||||
hot-reload = ${?HOT_RELOAD}
|
||||
watch-paths = []
|
||||
watch-debounce-ms = 100
|
||||
}
|
||||
```
|
||||
|
||||
**FileWatcher.scala:**
|
||||
File change detection using java.nio.file.WatchService:
|
||||
```scala
|
||||
import java.nio.file.*
|
||||
import java.nio.file.attribute.BasicFileAttributes
|
||||
import scala.jdk.CollectionConverters.*
|
||||
|
||||
case class FileChangeEvent(
|
||||
path: Path,
|
||||
kind: FileChangeKind,
|
||||
timestamp: Instant
|
||||
)
|
||||
|
||||
enum FileChangeKind:
|
||||
case Created, Modified, Deleted
|
||||
|
||||
trait FileWatcher:
|
||||
def start: IO[FileWatchError, Unit]
|
||||
def stop: IO[FileWatchError, Unit]
|
||||
def events: ZStream[Any, FileWatchError, FileChangeEvent]
|
||||
|
||||
object FileWatcher:
|
||||
def live(
|
||||
watchPaths: List[Path],
|
||||
debounceMs: Int = 100
|
||||
): ZLayer[Any, Nothing, FileWatcher] =
|
||||
ZLayer.fromZIO {
|
||||
for
|
||||
watchService <- ZIO.attemptBlocking(FileSystems.getDefault.newWatchService())
|
||||
eventQueue <- Queue.unbounded[FileChangeEvent]
|
||||
running <- Ref.make(false)
|
||||
yield new FileWatcher:
|
||||
def start: IO[FileWatchError, Unit] =
|
||||
for
|
||||
_ <- running.set(true)
|
||||
// Register all directories recursively
|
||||
_ <- ZIO.foreach(watchPaths)(registerRecursive(_, watchService))
|
||||
// Start watch loop in background
|
||||
_ <- watchLoop(watchService, eventQueue)
|
||||
.ensuring(ZIO.attemptBlocking(watchService.close()).ignore)
|
||||
.fork
|
||||
yield ()
|
||||
|
||||
def stop: IO[FileWatchError, Unit] =
|
||||
running.set(false)
|
||||
|
||||
def events: ZStream[Any, FileWatchError, FileChangeEvent] =
|
||||
ZStream.fromQueue(eventQueue)
|
||||
.groupedWithin(100, Duration.fromMillis(debounceMs))
|
||||
.map(_.distinctBy(_.path)) // Dedupe rapid changes
|
||||
.flatMap(ZStream.fromIterable)
|
||||
|
||||
private def watchLoop(
|
||||
watcher: WatchService,
|
||||
queue: Queue[FileChangeEvent]
|
||||
): IO[FileWatchError, Unit] =
|
||||
running.get.flatMap { isRunning =>
|
||||
if !isRunning then ZIO.unit
|
||||
else
|
||||
for
|
||||
key <- ZIO.attemptBlocking(watcher.poll(500, java.util.concurrent.TimeUnit.MILLISECONDS))
|
||||
.mapError(FileWatchError.WatchFailed(_))
|
||||
_ <- ZIO.whenCase(key) {
|
||||
case k if k != null =>
|
||||
val events = k.pollEvents().asScala.toList
|
||||
val dir = k.watchable().asInstanceOf[Path]
|
||||
ZIO.foreach(events) { event =>
|
||||
val kind = event.kind()
|
||||
if kind != StandardWatchEventKinds.OVERFLOW then
|
||||
val relativePath = event.context().asInstanceOf[Path]
|
||||
val fullPath = dir.resolve(relativePath)
|
||||
val changeKind = kind match
|
||||
case StandardWatchEventKinds.ENTRY_CREATE => FileChangeKind.Created
|
||||
case StandardWatchEventKinds.ENTRY_MODIFY => FileChangeKind.Modified
|
||||
case StandardWatchEventKinds.ENTRY_DELETE => FileChangeKind.Deleted
|
||||
case _ => FileChangeKind.Modified
|
||||
queue.offer(FileChangeEvent(fullPath, changeKind, Instant.now))
|
||||
else ZIO.unit
|
||||
} *> ZIO.attemptBlocking(k.reset()).ignore
|
||||
}
|
||||
_ <- watchLoop(watcher, queue)
|
||||
yield ()
|
||||
}
|
||||
|
||||
private def registerRecursive(path: Path, watcher: WatchService): IO[FileWatchError, Unit] =
|
||||
ZIO.attemptBlocking {
|
||||
if Files.isDirectory(path) then
|
||||
Files.walkFileTree(path, new SimpleFileVisitor[Path] {
|
||||
override def preVisitDirectory(dir: Path, attrs: BasicFileAttributes): FileVisitResult = {
|
||||
dir.register(
|
||||
watcher,
|
||||
StandardWatchEventKinds.ENTRY_CREATE,
|
||||
StandardWatchEventKinds.ENTRY_MODIFY,
|
||||
StandardWatchEventKinds.ENTRY_DELETE
|
||||
)
|
||||
FileVisitResult.CONTINUE
|
||||
}
|
||||
})
|
||||
}.mapError(FileWatchError.RegistrationFailed(_))
|
||||
}
|
||||
|
||||
enum FileWatchError:
|
||||
case WatchFailed(cause: Throwable)
|
||||
case RegistrationFailed(cause: Throwable)
|
||||
```
|
||||
|
||||
Key features:
|
||||
- Recursive directory registration
|
||||
- Event debouncing to handle rapid saves
|
||||
- Deduplication of same-path events
|
||||
- Non-blocking poll with timeout
|
||||
- Graceful shutdown via running flag
|
||||
</action>
|
||||
<verify>
|
||||
`./mill summercms.compile` succeeds
|
||||
FileWatcher can be instantiated (manual test)
|
||||
</verify>
|
||||
<done>
|
||||
FileWatcher uses JDK WatchService for OS-native file change detection. Supports recursive watching, debouncing, and deduplication.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Implement TemplateInvalidator and HotReloadService</name>
|
||||
<files>
|
||||
summercms/src/hot/TemplateInvalidator.scala
|
||||
summercms/src/hot/HotReloadService.scala
|
||||
</files>
|
||||
<action>
|
||||
**TemplateInvalidator.scala:**
|
||||
Targeted Pebble cache invalidation:
|
||||
```scala
|
||||
import io.pebbletemplates.pebble.PebbleEngine
|
||||
import io.pebbletemplates.pebble.cache.PebbleCache
|
||||
|
||||
trait TemplateInvalidator:
|
||||
def invalidate(path: Path): UIO[Unit]
|
||||
def invalidateAll: UIO[Unit]
|
||||
|
||||
object TemplateInvalidator:
|
||||
def live(pebbleEngine: PebbleEngine, themePath: Path): ULayer[TemplateInvalidator] =
|
||||
ZLayer.succeed {
|
||||
new TemplateInvalidator:
|
||||
def invalidate(path: Path): UIO[Unit] =
|
||||
ZIO.attemptBlocking {
|
||||
// Convert path to template name
|
||||
val templateName = themePath.relativize(path).toString
|
||||
// Pebble's template cache is keyed by template name
|
||||
val cache = pebbleEngine.getTemplateCache
|
||||
cache.invalidate(templateName)
|
||||
|
||||
// Also invalidate tag cache (for custom tags)
|
||||
pebbleEngine.getTagCache.invalidateAll()
|
||||
}.ignore
|
||||
|
||||
def invalidateAll: UIO[Unit] =
|
||||
ZIO.attemptBlocking {
|
||||
pebbleEngine.getTemplateCache.invalidateAll()
|
||||
pebbleEngine.getTagCache.invalidateAll()
|
||||
}.ignore
|
||||
}
|
||||
```
|
||||
|
||||
**HotReloadService.scala:**
|
||||
Orchestrates hot reload on file changes:
|
||||
```scala
|
||||
trait HotReloadService:
|
||||
def start: IO[HotReloadError, Unit]
|
||||
def stop: IO[HotReloadError, Unit]
|
||||
def onFileChanged(event: FileChangeEvent): UIO[Unit]
|
||||
|
||||
case class HotReloadConfig(
|
||||
enabled: Boolean,
|
||||
watchPaths: List[Path],
|
||||
debounceMs: Int
|
||||
)
|
||||
|
||||
object HotReloadService:
|
||||
def live(config: HotReloadConfig): ZLayer[
|
||||
FileWatcher & TemplateInvalidator & CmsRouter,
|
||||
Nothing,
|
||||
HotReloadService
|
||||
] =
|
||||
ZLayer.fromZIO {
|
||||
for
|
||||
watcher <- ZIO.service[FileWatcher]
|
||||
invalidator <- ZIO.service[TemplateInvalidator]
|
||||
cmsRouter <- ZIO.service[CmsRouter]
|
||||
running <- Ref.make(false)
|
||||
yield new HotReloadService:
|
||||
def start: IO[HotReloadError, Unit] =
|
||||
if !config.enabled then
|
||||
ZIO.logInfo("Hot reload disabled") *> ZIO.unit
|
||||
else
|
||||
for
|
||||
_ <- ZIO.logInfo(s"Starting hot reload for paths: ${config.watchPaths}")
|
||||
_ <- running.set(true)
|
||||
_ <- watcher.start.mapError(HotReloadError.WatcherFailed(_))
|
||||
// Process events in background
|
||||
_ <- watcher.events
|
||||
.tap(event => onFileChanged(event))
|
||||
.runDrain
|
||||
.fork
|
||||
yield ()
|
||||
|
||||
def stop: IO[HotReloadError, Unit] =
|
||||
for
|
||||
_ <- running.set(false)
|
||||
_ <- watcher.stop.mapError(HotReloadError.WatcherFailed(_))
|
||||
_ <- ZIO.logInfo("Hot reload stopped")
|
||||
yield ()
|
||||
|
||||
def onFileChanged(event: FileChangeEvent): UIO[Unit] =
|
||||
val path = event.path
|
||||
val extension = path.getFileName.toString.split('.').lastOption.getOrElse("")
|
||||
|
||||
extension match
|
||||
case "htm" | "html" =>
|
||||
// Template file changed
|
||||
for
|
||||
_ <- ZIO.logDebug(s"Template changed: $path")
|
||||
_ <- invalidator.invalidate(path)
|
||||
// Rebuild router if page URL might have changed
|
||||
_ <- ZIO.when(path.toString.contains("/pages/")) {
|
||||
cmsRouter.rebuild.ignore
|
||||
}
|
||||
yield ()
|
||||
|
||||
case "css" | "js" =>
|
||||
// Asset changed - just log, browser handles via Vite
|
||||
ZIO.logDebug(s"Asset changed: $path")
|
||||
|
||||
case "yaml" | "yml" =>
|
||||
// Config changed - may need full invalidation
|
||||
for
|
||||
_ <- ZIO.logDebug(s"Config changed: $path")
|
||||
_ <- invalidator.invalidateAll
|
||||
yield ()
|
||||
|
||||
case _ =>
|
||||
ZIO.logDebug(s"Ignored file change: $path")
|
||||
|
||||
enum HotReloadError:
|
||||
case WatcherFailed(cause: FileWatchError)
|
||||
case InvalidationFailed(cause: Throwable)
|
||||
```
|
||||
|
||||
Key decisions (per CONTEXT.md Claude's discretion):
|
||||
- Template changes: targeted cache invalidation
|
||||
- Page changes: also rebuild router (URL might have changed)
|
||||
- YAML changes: full cache invalidation
|
||||
- Asset changes: logged only (Vite handles HMR)
|
||||
- Debounce: 100ms default to handle editor save-on-type
|
||||
</action>
|
||||
<verify>
|
||||
`./mill summercms.compile` succeeds
|
||||
HotReloadService methods have correct signatures
|
||||
</verify>
|
||||
<done>
|
||||
TemplateInvalidator provides targeted Pebble cache invalidation. HotReloadService orchestrates reload by processing file events and invalidating appropriate caches.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Integrate hot reload with application startup</name>
|
||||
<files>
|
||||
summercms/src/Main.scala
|
||||
summercms/resources/application.conf
|
||||
</files>
|
||||
<action>
|
||||
**Update Main.scala:**
|
||||
Conditionally start hot reload in development mode:
|
||||
```scala
|
||||
object Main extends ZIOAppDefault:
|
||||
override def run: ZIO[Any, Throwable, Unit] =
|
||||
for
|
||||
_ <- printBanner
|
||||
config <- ZIO.service[AppConfig]
|
||||
_ <- ZIO.logInfo(s"Starting SummerCMS on port ${config.server.port}")
|
||||
|
||||
// Initialize Pebble engine
|
||||
pebbleEngine <- buildPebbleEngine(config)
|
||||
|
||||
// Start hot reload if enabled
|
||||
_ <- ZIO.when(config.dev.enabled && config.dev.hotReload) {
|
||||
for
|
||||
_ <- ZIO.logInfo("Development mode: Hot reload enabled")
|
||||
themePath <- ZIO.succeed(Path.of(config.theme.path))
|
||||
watchPaths = themePath :: config.dev.watchPaths.map(Path.of)
|
||||
|
||||
hotReloadConfig = HotReloadConfig(
|
||||
enabled = true,
|
||||
watchPaths = watchPaths,
|
||||
debounceMs = config.dev.watchDebounceMs
|
||||
)
|
||||
|
||||
// Build hot reload layers
|
||||
fileWatcherLayer = FileWatcher.live(watchPaths, config.dev.watchDebounceMs)
|
||||
invalidatorLayer = TemplateInvalidator.live(pebbleEngine, themePath)
|
||||
hotReloadLayer = HotReloadService.live(hotReloadConfig)
|
||||
|
||||
// Start hot reload service
|
||||
_ <- ZIO.serviceWithZIO[HotReloadService](_.start)
|
||||
.provide(
|
||||
fileWatcherLayer,
|
||||
invalidatorLayer,
|
||||
hotReloadLayer,
|
||||
CmsRouter.live(themePath)
|
||||
)
|
||||
yield ()
|
||||
}
|
||||
|
||||
// Start HTTP server
|
||||
_ <- startServer(config)
|
||||
yield ()
|
||||
.provide(
|
||||
AppConfig.layer,
|
||||
// ... other layers
|
||||
)
|
||||
|
||||
private def buildPebbleEngine(config: AppConfig): UIO[PebbleEngine] =
|
||||
ZIO.succeed {
|
||||
new PebbleEngine.Builder()
|
||||
.loader(new FileLoader())
|
||||
.cacheActive(!config.dev.hotReload) // Disable cache in hot reload mode
|
||||
.strictVariables(config.dev.enabled) // Strict mode in dev
|
||||
.extension(new CmsPebbleExtension(...))
|
||||
.build()
|
||||
}
|
||||
```
|
||||
|
||||
**Update application.conf:**
|
||||
Add theme path configuration:
|
||||
```hocon
|
||||
theme {
|
||||
path = "themes/default"
|
||||
path = ${?THEME_PATH}
|
||||
}
|
||||
|
||||
dev {
|
||||
enabled = false
|
||||
enabled = ${?DEV_MODE}
|
||||
hot-reload = false
|
||||
hot-reload = ${?HOT_RELOAD}
|
||||
watch-paths = []
|
||||
watch-debounce-ms = 100
|
||||
}
|
||||
```
|
||||
|
||||
**Development startup script (optional):**
|
||||
Create scripts/dev.sh:
|
||||
```bash
|
||||
#!/bin/bash
|
||||
export DEV_MODE=true
|
||||
export HOT_RELOAD=true
|
||||
./mill -w summercms.run
|
||||
```
|
||||
|
||||
Note: Mill's --watch mode (-w) restarts on Scala code changes. Combined with our hot reload, this provides:
|
||||
- Scala code changes: Mill restarts JVM
|
||||
- Template changes: Hot reload invalidates cache (no restart)
|
||||
- Asset changes: Vite HMR in browser
|
||||
|
||||
Production mode: All caching enabled, hot reload disabled, strict mode off.
|
||||
</action>
|
||||
<verify>
|
||||
`./mill summercms.compile` succeeds
|
||||
DEV_MODE=true ./mill summercms.run starts with hot reload logging
|
||||
Modify template file -> see "Template changed" log message
|
||||
</verify>
|
||||
<done>
|
||||
Hot reload integrated with application startup. Development mode enables file watching and cache invalidation. Production mode uses full caching.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
After all tasks complete:
|
||||
1. `./mill summercms.compile` - all code compiles
|
||||
2. Test development mode:
|
||||
- DEV_MODE=true HOT_RELOAD=true ./mill summercms.run
|
||||
- See "Hot reload enabled" in logs
|
||||
3. Test file watching:
|
||||
- Modify themes/default/pages/home.htm
|
||||
- See "Template changed" log
|
||||
- Refresh browser -> see updated content immediately
|
||||
4. Test cache invalidation:
|
||||
- Modify layout -> layout changes reflected
|
||||
- Modify partial -> partial changes reflected
|
||||
- Add new page -> router rebuilt, new URL accessible
|
||||
5. Test production mode:
|
||||
- DEV_MODE=false ./mill summercms.run
|
||||
- No hot reload logs
|
||||
- Templates cached (verify via response time)
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Hot reload only active when DEV_MODE=true and HOT_RELOAD=true
|
||||
- FileWatcher uses OS-native WatchService (not polling)
|
||||
- Template changes invalidate specific template cache entry
|
||||
- Page changes also trigger router rebuild
|
||||
- Changes reflect immediately without JVM restart
|
||||
- Production mode uses full caching for performance
|
||||
- Event debouncing prevents rapid reload spam
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/09-content-management/09-06-SUMMARY.md`
|
||||
</output>
|
||||
Reference in New Issue
Block a user