---
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"
---
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.
@/home/jin/.claude/get-shit-done/workflows/execute-plan.md
@/home/jin/.claude/get-shit-done/templates/summary.md
@.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
Task 1: Add dependencies and create CMS content models
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
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.
`./mill summercms.compile` succeeds with new models
CmsPage, CmsLayout, CmsPartial, ContentState models exist and compile. CmsPage.parse can load WinterCMS-format .htm files.
Task 2: Implement CMS URL router with parameter extraction
summercms/src/content/cms/CmsRouter.scala
summercms/src/content/cms/RouteSegment.scala
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)
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
CmsRouter can parse WinterCMS URL patterns, match incoming URLs to pages, and extract route parameters. Routes sorted by specificity for correct matching.
Task 3: Create CmsPageService and page metadata migration
summercms/src/content/cms/CmsPageService.scala
summercms/resources/db/migration/V009__cms_page_metadata.sql
**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.
`./mill summercms.compile` succeeds
Migration file exists at correct path
CmsPageService.load can parse a sample .htm file (create test file manually if needed)
CmsPageService provides CRUD operations for CMS pages. Pages load from filesystem, render with Pebble through layouts. Metadata cached in database for admin list views.
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"
==
Welcome
```
4. CmsRouter.parsePattern correctly handles WinterCMS URL patterns
5. CmsPageService.render produces HTML with layout wrapping
- 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`