Files
summercms-initial-research/.planning/phases/09-content-management/09-01-PLAN.md
Jakub Zych dca89e10cd 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
2026-02-05 15:33:51 +01:00

9.3 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, must_haves
phase plan type wave depends_on files_modified autonomous must_haves
09-content-management 01 execute 1
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
true
truths artifacts key_links
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
path provides contains
summercms/src/content/cms/CmsPage.scala CMS page model with compound file parsing case class CmsPage
path provides contains
summercms/src/content/cms/CmsLayout.scala Layout model with placeholder support case class CmsLayout
path provides contains
summercms/src/content/cms/CmsRouter.scala URL pattern matching router trait CmsRouter
path provides exports
summercms/src/content/cms/CmsPageService.scala Page CRUD and rendering
load
render
list
from to via pattern
CmsRouter CmsPage findByUrl returns page with extracted params findByUrl.*CmsPage.*Map[String, String]
from to via pattern
CmsPageService.render PebbleEngine template rendering 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.

<execution_context> @/home/jin/.claude/get-shit-done/workflows/execute-plan.md @/home/jin/.claude/get-shit-done/templates/summary.md </execution_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 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.
  </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"

Welcome

``` 4. CmsRouter.parsePattern correctly handles WinterCMS URL patterns 5. CmsPageService.render produces HTML with layout wrapping

<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>
After completion, create `.planning/phases/09-content-management/09-01-SUMMARY.md`