docs(10): create phase plan

Phase 10: Core Plugins
- 4 plans in 2 waves
- Wave 1: 10-01 (User auth), 10-03 (Blog posts) - parallel
- Wave 2: 10-02 (User profiles), 10-04 (Blog categories/tags) - sequential
- Ready for execution
This commit is contained in:
Jakub Zych
2026-02-05 16:07:32 +01:00
parent 85dc7c7464
commit 4d8d5719d5
5 changed files with 1539 additions and 6 deletions

View File

@@ -0,0 +1,333 @@
---
phase: 10-core-plugins
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- plugins/golem15/user/plugin.yaml
- plugins/golem15/user/Plugin.scala
- plugins/golem15/user/models/FrontendUser.scala
- plugins/golem15/user/models/UserGroup.scala
- plugins/golem15/user/models/UserThrottle.scala
- plugins/golem15/user/models/UserSettings.scala
- plugins/golem15/user/services/FrontendUserService.scala
- plugins/golem15/user/services/FrontendAuthService.scala
- plugins/golem15/user/services/ThrottleService.scala
- plugins/golem15/user/components/Session.scala
- plugins/golem15/user/components/Account.scala
- plugins/golem15/user/resources/db/migration/V10_1_1__user_plugin.sql
- plugins/golem15/user/resources/views/account/login.peb
- plugins/golem15/user/resources/views/account/register.peb
autonomous: true
must_haves:
truths:
- "Frontend visitor can register with email, password, name, surname"
- "Registered user receives activation email if activation mode is user"
- "User can log in with email and password"
- "User can log out and session is terminated"
- "Session component restricts page access based on security mode"
- "Login throttling prevents brute force attacks"
artifacts:
- path: "plugins/golem15/user/models/FrontendUser.scala"
provides: "Frontend user domain model with validation"
contains: "case class FrontendUser"
- path: "plugins/golem15/user/services/FrontendAuthService.scala"
provides: "Authentication logic with JWT session tokens"
exports: ["FrontendAuthService", "login", "logout", "getCurrentUser"]
- path: "plugins/golem15/user/components/Account.scala"
provides: "Registration and login HTMX handlers"
exports: ["AccountComponent", "onSignin", "onRegister"]
- path: "plugins/golem15/user/components/Session.scala"
provides: "Page access control component"
exports: ["SessionComponent", "onLogout"]
- path: "plugins/golem15/user/resources/db/migration/V10_1_1__user_plugin.sql"
provides: "Database schema for frontend users"
contains: "CREATE TABLE frontend_users"
key_links:
- from: "plugins/golem15/user/components/Account.scala"
to: "FrontendAuthService"
via: "ZIO service injection"
pattern: "ZIO\\.service\\[FrontendAuthService\\]"
- from: "plugins/golem15/user/services/FrontendAuthService.scala"
to: "FrontendUserRepository"
via: "repository lookup"
pattern: "userRepo\\.findByEmail"
- from: "plugins/golem15/user/components/Session.scala"
to: "pageContext"
via: "user injection"
pattern: "pageContext\\.set\\(\"user\""
---
<objective>
Create the User plugin foundation with registration and authentication functionality.
Purpose: Establish frontend user management as the first core plugin, demonstrating the complete plugin pattern with models, services, components, and migrations.
Output: Working User plugin with:
- FrontendUser model with validation rules
- Registration flow with configurable activation
- Login/logout with JWT cookie sessions
- Session component for page access control
- Login throttling for security
</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/10-core-plugins/10-CONTEXT.md
@.planning/phases/10-core-plugins/10-RESEARCH.md
# Reference patterns from prior phases
# Phase 2: Plugin manifest and lifecycle patterns
# Phase 3: Component system with HTMX handlers
# Phase 6: Authentication patterns (Argon2id, JWT)
</context>
<tasks>
<task type="auto">
<name>Task 1: User plugin models and database schema</name>
<files>
plugins/golem15/user/plugin.yaml
plugins/golem15/user/Plugin.scala
plugins/golem15/user/models/FrontendUser.scala
plugins/golem15/user/models/UserGroup.scala
plugins/golem15/user/models/UserThrottle.scala
plugins/golem15/user/models/UserSettings.scala
plugins/golem15/user/resources/db/migration/V10_1_1__user_plugin.sql
</files>
<action>
Create User plugin structure following Phase 2 plugin patterns:
**plugin.yaml:**
- name: Golem15.User
- description: Frontend user authentication and management
- author: Golem15
- version: 1.0.0
- require: [] (no plugin dependencies)
**Plugin.scala:**
- Extend SummerPlugin trait
- Register components: Session, Account (ResetPassword in plan 02)
- Register settings model
- Boot method for any initialization
**FrontendUser.scala:**
- Case class with fields from research Pattern 1:
id, email, passwordHash, name, surname, username (Option), avatarPath (Option),
isActivated, activationCode, activatedAt, persistCode, resetPasswordCode,
lastLogin, lastSeen, createdIpAddress, lastIpAddress, isGuest, isSuperuser,
deletedAt (soft delete), createdAt, updatedAt
- Validation rules object: email (required, unique, 6-255), password (required, 8-255),
name (required, 2-100), surname (required, 2-100)
- Quill table mapping to `frontend_users`
**UserGroup.scala:**
- Case class: id, name, code (unique), description, createdAt, updatedAt
- Quill table mapping to `user_groups`
- UsersGroups pivot case class for many-to-many
**UserThrottle.scala:**
- Case class: id, userId (Option), ipAddress (Option), attempts, lastAttemptAt,
isSuspended, suspendedAt, isBanned, bannedAt
- Quill table mapping to `user_throttle`
**UserSettings.scala:**
- Sealed traits: ActivateMode (Auto, User, Admin), RememberLogin (Always, Never, Ask)
- Case class UserPluginSettings with fields from research Pattern 2:
allowRegistration, requireActivation, activateMode, useThrottle,
useRegisterThrottle, blockPersistence, rememberLogin, minPasswordLength
- Default values matching WinterCMS
**V10_1_1__user_plugin.sql:**
- CREATE TABLE frontend_users (all fields with proper types, indexes)
- CREATE TABLE user_groups (id, name, code unique, description, timestamps)
- CREATE TABLE users_groups (user_id, group_id, composite PK)
- CREATE TABLE user_throttle (id, user_id nullable, ip_address inet, attempts, timestamps, ban flags)
- Indexes on email, username, persist_code, throttle lookups
</action>
<verify>
./mill summercms.compile succeeds with new models
SQL migration syntax valid (no syntax errors in IDE)
</verify>
<done>
FrontendUser, UserGroup, UserThrottle, UserSettings models exist with Quill mappings.
Migration creates all required tables with proper indexes.
</done>
</task>
<task type="auto">
<name>Task 2: Authentication and user services</name>
<files>
plugins/golem15/user/repositories/FrontendUserRepository.scala
plugins/golem15/user/services/FrontendUserService.scala
plugins/golem15/user/services/FrontendAuthService.scala
plugins/golem15/user/services/ThrottleService.scala
</files>
<action>
Create service layer for user operations:
**FrontendUserRepository.scala:**
- Trait with ZIO effects following Phase 1 repository pattern
- Methods: findById, findByEmail, findByPersistCode, create, update, delete (soft)
- Live implementation using QuillContext
- Error handling with RepositoryError ADT
**FrontendUserService.scala:**
- Trait following research Pattern 1
- Methods:
- findByEmail(email): Option[FrontendUser]
- findById(id): Option[FrontendUser]
- register(data: RegistrationData): FrontendUser (handles activation code generation)
- activate(userId, code): FrontendUser
- updateProfile(userId, data: ProfileUpdate): FrontendUser
- touchLastSeen(userId): Unit
- touchIpAddress(userId, ip): Unit
- RegistrationData case class: email, password, name, surname, ipAddress
- ProfileUpdate case class: name, surname, newPassword (Option)
- Use Password4j for hashing (Argon2id, matching Phase 6 patterns)
- Live implementation with ZLayer
**FrontendAuthService.scala:**
- Trait following research Pattern 10
- Methods:
- login(user, remember): String (returns JWT token)
- logout: Unit (clears session, rotates persistCode)
- getCurrentUser: Option[FrontendUser]
- isAuthenticated: Boolean
- verifyPassword(userId, password): Boolean
- validateAndSetSession(token): Unit (for middleware)
- JWT token generation using jwt-scala (15min session, 7day remember)
- Cookie configuration helper (httpOnly, secure in prod, SameSite Lax)
- Session state via Ref[Option[FrontendUser]]
- Live implementation with ZLayer
**ThrottleService.scala:**
- Trait for login/registration throttling
- Methods:
- isThrottled(ip): Boolean
- recordAttempt(ip, success): Unit
- clearAttempts(ip): Unit
- isBanned(ip): Boolean
- Configurable thresholds (5 attempts, 15 min lockout)
- Live implementation with ZLayer
</action>
<verify>
./mill summercms.compile succeeds
All service traits have Live implementations
ZLayers compose correctly (no missing dependencies)
</verify>
<done>
FrontendUserRepository provides data access.
FrontendUserService handles registration with Argon2id password hashing.
FrontendAuthService manages JWT sessions with cookie helpers.
ThrottleService prevents brute force attacks.
</done>
</task>
<task type="auto">
<name>Task 3: Session and Account components with templates</name>
<files>
plugins/golem15/user/components/Session.scala
plugins/golem15/user/components/Account.scala
plugins/golem15/user/resources/views/session/default.peb
plugins/golem15/user/resources/views/account/login.peb
plugins/golem15/user/resources/views/account/register.peb
plugins/golem15/user/resources/views/account/success.peb
</files>
<action>
Create frontend components following Phase 3 component patterns:
**Session.scala (research Pattern 3):**
- Extend SummerComponent trait
- ComponentDetails: name "Session", description "User session and access control"
- Property schema:
- security: Dropdown (all/user/guest), default "all"
- allowedUserGroups: Set, optional group filtering
- redirect: Dropdown, page to redirect unauthorized users
- onRun lifecycle:
- Check security mode against current auth state
- If unauthorized and redirect set, fail with ComponentRedirect
- If unauthorized and no redirect, fail with AccessDenied
- Inject "user" into pageContext (null if guest)
- Touch lastSeen for authenticated users
- HTMX handler onLogout:
- Call authService.logout
- Return HtmxResponse with HX-Redirect to configured page
- Trigger "userLoggedOut" event
**Account.scala (research Pattern 4):**
- Extend SummerComponent trait
- ComponentDetails: name "Account", description "Registration, login, profile"
- Property schema:
- redirect: Dropdown, page after login/register
- paramCode: String, activation code URL param name, default "code"
- requirePassword: Checkbox, require current password for profile update
- HTMX handlers:
- onSignin: Parse login/password from form, validate length, call authService.authenticate,
handle errors (InvalidCredentials, AccountBanned, NotActivated) with generic message,
record IP, return HX-Redirect on success
- onRegister: Check allowRegistration setting, check throttle, parse form data,
call userService.register, send activation email if mode is User,
auto-login if auto-activated, return HX-Redirect on success
- onActivate: Get code from URL param, call userService.activate, auto-login, redirect
**Templates (Pebble):**
- session/default.peb: Empty by default (component injects user context only)
- account/login.peb:
- Form with hx-post to onSignin handler
- Email input, password input, remember checkbox (if settings allow)
- Error display area with hx-swap="innerHTML"
- CSRF token hidden field
- account/register.peb:
- Form with hx-post to onRegister handler
- Email, password, password_confirmation, name, surname inputs
- Error display area
- CSRF token hidden field
- account/success.peb:
- Success message partial for HTMX responses
</action>
<verify>
./mill summercms.compile succeeds
Templates have valid Pebble syntax (no unclosed tags)
Components registered in Plugin.scala boot method
</verify>
<done>
Session component controls page access with security modes.
Account component handles login and registration via HTMX.
Templates render forms with CSRF protection and error handling.
User plugin fully functional for registration and authentication flows.
</done>
</task>
</tasks>
<verification>
After all tasks complete:
1. User plugin compiles: `./mill summercms.compile`
2. Migration valid: Check SQL syntax for frontend_users, user_groups, users_groups, user_throttle
3. Component registration: Plugin.scala registers Session and Account components
4. Service wiring: All ZLayers compose without missing dependencies
5. Template syntax: Pebble templates parse without errors
</verification>
<success_criteria>
- Frontend visitor can submit registration form (model, service, component exist)
- Registration creates FrontendUser with hashed password
- Login authenticates user and sets JWT cookie
- Session component restricts page access based on security mode
- Logout clears session and rotates persist code
- Throttle service tracks failed login attempts
</success_criteria>
<output>
After completion, create `.planning/phases/10-core-plugins/10-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,267 @@
---
phase: 10-core-plugins
plan: 02
type: execute
wave: 2
depends_on: ["10-01"]
files_modified:
- plugins/golem15/user/components/ResetPassword.scala
- plugins/golem15/user/services/FrontendUserService.scala
- plugins/golem15/user/services/UserMailService.scala
- plugins/golem15/user/resources/views/account/profile.peb
- plugins/golem15/user/resources/views/account/avatar.peb
- plugins/golem15/user/resources/views/resetpassword/request.peb
- plugins/golem15/user/resources/views/resetpassword/reset.peb
- plugins/golem15/user/resources/views/mail/activation.peb
- plugins/golem15/user/resources/views/mail/reset.peb
autonomous: true
must_haves:
truths:
- "Logged-in user can view and edit their profile"
- "User can change password with current password verification"
- "User can upload and change avatar"
- "Visitor can request password reset via email"
- "User can reset password using email link with token"
- "Activation email sent when activation mode is user"
artifacts:
- path: "plugins/golem15/user/components/ResetPassword.scala"
provides: "Password reset flow component"
exports: ["ResetPasswordComponent", "onRequest", "onReset"]
- path: "plugins/golem15/user/services/UserMailService.scala"
provides: "Email sending for activation and reset"
exports: ["UserMailService", "sendActivationEmail", "sendResetEmail"]
- path: "plugins/golem15/user/resources/views/account/profile.peb"
provides: "Profile edit form template"
contains: "hx-post"
key_links:
- from: "plugins/golem15/user/components/Account.scala"
to: "UserMailService"
via: "activation email on register"
pattern: "mailService\\.sendActivationEmail"
- from: "plugins/golem15/user/components/ResetPassword.scala"
to: "FrontendUserService"
via: "password reset"
pattern: "userService\\.resetPassword"
- from: "plugins/golem15/user/services/FrontendUserService.scala"
to: "MediaLibrary"
via: "avatar upload"
pattern: "mediaLibrary\\.upload"
---
<objective>
Complete the User plugin with profile management and password reset functionality.
Purpose: Enable users to manage their accounts and recover access, completing the frontend user experience.
Output: Working profile and reset features:
- Profile edit form with name/surname/password change
- Avatar upload using media library
- Password reset request and completion flow
- Email templates for activation and reset
</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/10-core-plugins/10-CONTEXT.md
@.planning/phases/10-core-plugins/10-RESEARCH.md
# Prior plan in this phase
@.planning/phases/10-core-plugins/10-01-SUMMARY.md
# Reference: Phase 9 media library for avatar upload
</context>
<tasks>
<task type="auto">
<name>Task 1: Profile management with avatar upload</name>
<files>
plugins/golem15/user/components/Account.scala (modify)
plugins/golem15/user/services/FrontendUserService.scala (modify)
plugins/golem15/user/resources/views/account/profile.peb
plugins/golem15/user/resources/views/account/avatar.peb
</files>
<action>
Add profile management to Account component:
**Account.scala additions:**
- Add onUpdate HTMX handler (research Pattern 4):
- Get current user from authService, fail if not authenticated
- If requirePassword property set, validate current password
- Parse ProfileUpdate from form (name, surname, newPassword optional)
- Validate new password length if provided (min from settings)
- Call userService.updateProfile
- If password changed, re-authenticate user (new JWT)
- Return success partial with "Profile updated" message
- Add onUploadAvatar HTMX handler:
- Get current user, fail if not authenticated
- Get uploaded file from multipart form
- Validate file type (image/jpeg, image/png, image/gif, image/webp)
- Validate file size (max 2MB from settings or default)
- Call userService.updateAvatar
- Return updated avatar partial with new image
**FrontendUserService.scala additions:**
- Add updateAvatar(userId, file: UploadedFile): FrontendUser
- Use MediaLibrary from Phase 9 to store file
- Generate path: users/{userId}/avatar.{ext}
- Update user.avatarPath
- Delete old avatar if exists
- Modify updateProfile to handle password change:
- If newPassword provided, hash with Argon2id
- Update passwordHash field
- Rotate persistCode to invalidate other sessions
**profile.peb template:**
- Form with hx-post to onUpdate handler, hx-target for response
- Name input (pre-filled from user)
- Surname input (pre-filled from user)
- Current password input (if requirePassword property)
- New password input (optional)
- Confirm new password input
- Submit button
- Error/success display area
- CSRF token
**avatar.peb template:**
- Current avatar display (or placeholder)
- Form with hx-post to onUploadAvatar, hx-encoding="multipart/form-data"
- File input with accept="image/*"
- Upload button
- Preview area that updates on success
</action>
<verify>
./mill summercms.compile succeeds
Account component has onUpdate and onUploadAvatar handlers
Templates have valid Pebble syntax
</verify>
<done>
Users can edit profile (name, surname, password).
Password change requires current password if configured.
Avatar upload stores image via media library.
Templates render forms with proper HTMX attributes.
</done>
</task>
<task type="auto">
<name>Task 2: Password reset flow and email service</name>
<files>
plugins/golem15/user/components/ResetPassword.scala
plugins/golem15/user/services/UserMailService.scala
plugins/golem15/user/services/FrontendUserService.scala (modify)
plugins/golem15/user/resources/views/resetpassword/request.peb
plugins/golem15/user/resources/views/resetpassword/reset.peb
plugins/golem15/user/resources/views/mail/activation.peb
plugins/golem15/user/resources/views/mail/reset.peb
</files>
<action>
Create password reset component and email service:
**UserMailService.scala:**
- Trait with ZIO effects:
- sendActivationEmail(user: FrontendUser, code: String): IO[MailError, Unit]
- sendResetEmail(user: FrontendUser, code: String): IO[MailError, Unit]
- Live implementation using ZIO email service (or stub for now):
- Load Pebble template for email body
- Include activation/reset URL with code
- Send via configured SMTP or email service
- MailError ADT: SendFailed, TemplateError, ConfigurationError
**FrontendUserService.scala additions:**
- requestPasswordReset(email: String): Unit
- Find user by email (don't reveal if not found - timing attack)
- Generate secure random reset code (32 bytes hex)
- Set resetPasswordCode on user with expiry tracking
- Call mailService.sendResetEmail
- Always succeed (don't reveal user existence)
- resetPassword(code: String, newPassword: String): FrontendUser
- Find user by resetPasswordCode
- Validate code not expired (24 hours)
- Hash new password with Argon2id
- Clear resetPasswordCode
- Rotate persistCode (invalidate other sessions)
- Return updated user
**ResetPassword.scala component:**
- Extend SummerComponent trait
- ComponentDetails: name "ResetPassword", description "Password recovery flow"
- Property schema:
- paramCode: String, URL parameter for reset code, default "code"
- redirect: Dropdown, page after successful reset
- HTMX handlers:
- onRequest: Parse email from form, call userService.requestPasswordReset,
always show success message (no email enumeration)
- onReset: Get code from URL param, parse new password + confirmation,
validate password length and match, call userService.resetPassword,
auto-login user, redirect to configured page
**Templates:**
- resetpassword/request.peb:
- Form with hx-post to onRequest
- Email input
- Submit button "Send Reset Link"
- Success message: "If an account exists, you will receive an email"
- resetpassword/reset.peb:
- Form with hx-post to onReset
- Hidden input with reset code from URL
- New password input
- Confirm password input
- Submit button "Reset Password"
- Error display for invalid/expired code
- mail/activation.peb (Pebble email template):
- Subject: "Activate your account"
- Body: Welcome message, activation link with code
- Plain text fallback
- mail/reset.peb (Pebble email template):
- Subject: "Reset your password"
- Body: Reset link with code, expiry notice (24 hours)
- Plain text fallback
**Register ResetPassword component in Plugin.scala**
</action>
<verify>
./mill summercms.compile succeeds
ResetPassword component registered in Plugin.scala
Email templates have valid Pebble syntax
Reset code generation uses SecureRandom
</verify>
<done>
Password reset request sends email (or stubs if no SMTP).
Reset link with token allows setting new password.
Activation email template ready for registration flow.
No email enumeration possible (generic success messages).
</done>
</task>
</tasks>
<verification>
After all tasks complete:
1. User plugin compiles: `./mill summercms.compile`
2. Account component has all handlers: onSignin, onRegister, onActivate, onUpdate, onUploadAvatar
3. ResetPassword component handles: onRequest, onReset
4. Email service can send activation and reset emails
5. Templates render valid forms with HTMX
</verification>
<success_criteria>
- Logged-in user can view profile form pre-filled with their data
- Profile update saves name/surname changes
- Password change requires current password confirmation
- Avatar upload stores image and updates user record
- Password reset request always shows success (no enumeration)
- Reset link with valid code allows new password entry
- User automatically logged in after successful reset
</success_criteria>
<output>
After completion, create `.planning/phases/10-core-plugins/10-02-SUMMARY.md`
</output>

View File

@@ -0,0 +1,421 @@
---
phase: 10-core-plugins
plan: 03
type: execute
wave: 1
depends_on: []
files_modified:
- plugins/golem15/blog/plugin.yaml
- plugins/golem15/blog/Plugin.scala
- plugins/golem15/blog/models/BlogPost.scala
- plugins/golem15/blog/models/BlogSettings.scala
- plugins/golem15/blog/repositories/BlogPostRepository.scala
- plugins/golem15/blog/services/BlogPostService.scala
- plugins/golem15/blog/services/ContentService.scala
- plugins/golem15/blog/controllers/Posts.scala
- plugins/golem15/blog/resources/db/migration/V10_2_1__blog_posts.sql
- plugins/golem15/blog/resources/controllers/posts/fields.yaml
- plugins/golem15/blog/resources/controllers/posts/columns.yaml
- plugins/golem15/blog/resources/views/posts/form.peb
- build.mill
- package.json
autonomous: true
must_haves:
truths:
- "Admin can create a new blog post"
- "Admin can edit existing blog posts"
- "Blog post has title, slug, content with WYSIWYG editor"
- "Blog post content supports both Markdown and HTML"
- "Blog posts have published/draft state with scheduled publishing"
- "Blog posts can be marked as featured"
artifacts:
- path: "plugins/golem15/blog/models/BlogPost.scala"
provides: "Blog post domain model"
contains: "case class BlogPost"
- path: "plugins/golem15/blog/services/BlogPostService.scala"
provides: "Blog post CRUD operations"
exports: ["BlogPostService", "create", "update", "findBySlug"]
- path: "plugins/golem15/blog/controllers/Posts.scala"
provides: "Admin controller for post management"
exports: ["PostsController"]
- path: "plugins/golem15/blog/resources/controllers/posts/fields.yaml"
provides: "YAML-driven form definition"
contains: "content:"
- path: "plugins/golem15/blog/resources/db/migration/V10_2_1__blog_posts.sql"
provides: "Blog posts table schema"
contains: "CREATE TABLE blog_posts"
key_links:
- from: "plugins/golem15/blog/controllers/Posts.scala"
to: "BlogPostService"
via: "ZIO service injection"
pattern: "ZIO\\.service\\[BlogPostService\\]"
- from: "plugins/golem15/blog/services/BlogPostService.scala"
to: "BlogPostRepository"
via: "data access"
pattern: "postRepo\\."
- from: "plugins/golem15/blog/services/ContentService.scala"
to: "commonmark"
via: "Markdown parsing"
pattern: "Parser\\.builder"
---
<objective>
Create the Blog plugin foundation with post management and WYSIWYG editor integration.
Purpose: Establish blog content management as the second core plugin, demonstrating admin CRUD patterns with YAML-driven forms.
Output: Working Blog plugin with:
- BlogPost model with content, publishing state, and metadata
- Admin controller for post CRUD
- TinyMCE WYSIWYG editor integration
- Markdown and HTML content support
- Draft/published state with scheduled publishing
</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/10-core-plugins/10-CONTEXT.md
@.planning/phases/10-core-plugins/10-RESEARCH.md
# Reference patterns from prior phases
# Phase 2: Plugin manifest and lifecycle patterns
# Phase 7: Admin forms with YAML definitions
# Phase 9: Media library for image uploads in editor
</context>
<tasks>
<task type="auto">
<name>Task 1: Blog post model and database schema</name>
<files>
plugins/golem15/blog/plugin.yaml
plugins/golem15/blog/Plugin.scala
plugins/golem15/blog/models/BlogPost.scala
plugins/golem15/blog/models/BlogSettings.scala
plugins/golem15/blog/resources/db/migration/V10_2_1__blog_posts.sql
build.mill
</files>
<action>
Create Blog plugin structure and post model:
**build.mill additions:**
- Add slugify library: mvn"com.github.slugify:slugify:3.0.7"
- Add commonmark: mvn"org.commonmark:commonmark:0.22.0"
**plugin.yaml:**
- name: Golem15.Blog
- description: Blog posts with categories and tags
- author: Golem15
- version: 1.0.0
- require: [] (no plugin dependencies for posts)
**Plugin.scala:**
- Extend SummerPlugin trait
- Register controllers: Posts (Categories in plan 04)
- Register components: Posts, Post (in plan 04)
- Register settings model
- Boot method for initialization
**BlogPost.scala (research Pattern 5):**
- Case class with fields:
id, authorId (BackendUser reference), title, slug (unique), excerpt (Option),
content (raw Markdown/HTML), contentHtml (rendered), charactersCount,
published (Boolean), publishedAt (Option[Instant]), isFeatured,
mainCategoryId (Option, for plan 04), metadata (Map[String, Json]),
createdAt, updatedAt
- Validation rules: title required, slug required + unique + pattern, content required
- allowedSortingOptions map (title asc/desc, created_at, published_at, random)
- Quill table mapping to `blog_posts`
**BlogSettings.scala:**
- Case class BlogPluginSettings:
postsPerPage (Int, default 10), defaultSorting (String),
urlPattern (String, e.g., "/blog/:slug"), enableComments (Boolean, false),
excerptLength (Int, default 200)
- Settings YAML schema definition
**V10_2_1__blog_posts.sql:**
- CREATE TABLE blog_posts with all fields from research:
id BIGSERIAL PRIMARY KEY
author_id BIGINT NOT NULL REFERENCES backend_users(id)
title VARCHAR(255) NOT NULL
slug VARCHAR(255) NOT NULL UNIQUE
excerpt TEXT
content TEXT NOT NULL
content_html TEXT NOT NULL
characters_count INT DEFAULT 0
published BOOLEAN DEFAULT false
published_at TIMESTAMPTZ
is_featured BOOLEAN DEFAULT false
main_category_id BIGINT (FK added in plan 04)
metadata JSONB DEFAULT '{}'
created_at, updated_at TIMESTAMPTZ
- Indexes on slug, published+published_at, author_id, is_featured
</action>
<verify>
./mill summercms.compile succeeds with new dependencies
SQL migration syntax valid
BlogPost model has Quill mappings
</verify>
<done>
Blog plugin structure created with manifest.
BlogPost model ready with all fields.
Migration creates blog_posts table.
Dependencies added for slug generation and Markdown.
</done>
</task>
<task type="auto">
<name>Task 2: Post service with content processing</name>
<files>
plugins/golem15/blog/repositories/BlogPostRepository.scala
plugins/golem15/blog/services/BlogPostService.scala
plugins/golem15/blog/services/ContentService.scala
</files>
<action>
Create service layer for post operations:
**BlogPostRepository.scala:**
- Trait with ZIO effects following repository pattern:
- findById(id): Option[BlogPost]
- findBySlug(slug): Option[BlogPost]
- create(post): BlogPost
- update(post): BlogPost
- delete(id): Unit (hard delete or soft)
- listPublished(options): PaginatedResult[BlogPost]
- listAll(options): PaginatedResult[BlogPost] (admin)
- countByAuthor(authorId): Int
- Live implementation using QuillContext
- Published filter: published = true AND published_at <= NOW()
**ContentService.scala:**
- Trait for content processing:
- renderMarkdown(content): String (Markdown to HTML)
- sanitizeHtml(html): String (XSS protection)
- generateExcerpt(contentHtml, maxLength): String
- calculateReadingTime(contentHtml): Int (minutes)
- countCharacters(content): Int
- Live implementation:
- Use CommonMark parser for Markdown (research Pattern 5)
- Use Jsoup with Safelist.relaxed() for sanitization
- Excerpt: Split on <!-- more --> marker or truncate to maxLength
- Reading time: Strip HTML, count words, divide by 200 WPM, round up
**BlogPostService.scala (research Pattern 5):**
- Trait:
- create(data: PostCreateData): BlogPost
- update(id, data: PostUpdateData): BlogPost
- delete(id): Unit
- findBySlug(slug): Option[BlogPostWithRelations]
- listFrontend(options: PostListOptions): PaginatedResult[BlogPostWithRelations]
- publish(id): BlogPost (sets published=true, publishedAt=now if null)
- unpublish(id): BlogPost (sets published=false)
- PostCreateData case class: title, slug (Option - auto-generate if empty),
content, excerpt (Option), published, publishedAt (Option), isFeatured, authorId
- PostUpdateData case class: title, slug, content, excerpt, published, publishedAt, isFeatured
- PostListOptions from research: page, perPage, sort, published, exceptPost, exceptCategories, search
- Before save:
- If slug empty, generate from title using Slugify
- Render content to contentHtml using ContentService
- Calculate charactersCount
- Sanitize HTML content
- BlogPostWithRelations case class for joins (categories, tags, author in plan 04)
- Live implementation with ZLayer
</action>
<verify>
./mill summercms.compile succeeds
ContentService renders Markdown correctly
BlogPostService generates slugs from titles
Sanitization strips dangerous HTML
</verify>
<done>
BlogPostRepository provides data access.
ContentService handles Markdown rendering and XSS sanitization.
BlogPostService manages CRUD with auto-slug and content processing.
Reading time and character count auto-calculated.
</done>
</task>
<task type="auto">
<name>Task 3: Admin controller with TinyMCE editor</name>
<files>
plugins/golem15/blog/controllers/Posts.scala
plugins/golem15/blog/resources/controllers/posts/fields.yaml
plugins/golem15/blog/resources/controllers/posts/columns.yaml
plugins/golem15/blog/resources/views/posts/form.peb
plugins/golem15/blog/resources/views/posts/list.peb
package.json
</files>
<action>
Create admin controller for post management:
**package.json additions:**
- Add TinyMCE: "tinymce": "^7.0.0"
**Posts.scala controller:**
- Extend AdminController (Phase 7 pattern)
- Configure: modelClass = BlogPost, listUrl = "/admin/blog/posts", formUrl = "/admin/blog/posts/:id/edit"
- Actions:
- index: List posts using BlogPostService.listAll, render list.peb
- create: New post form
- store: Parse form, call BlogPostService.create, redirect to edit
- edit: Load post by ID, render form.peb
- update: Parse form, call BlogPostService.update, return success response
- delete: Call BlogPostService.delete, redirect to list
- publish/unpublish: Toggle published state
- HTMX handlers for inline actions (delete confirmation, publish toggle)
**fields.yaml (Phase 7 form definition):**
```yaml
fields:
title:
type: text
label: Title
required: true
span: full
slug:
type: text
label: Slug
span: auto
comment: "Leave blank to auto-generate from title"
preset:
field: title
type: slug
published:
type: checkbox
label: Published
span: auto
published_at:
type: datepicker
label: Publish Date
mode: datetime
span: auto
dependsOn: published
is_featured:
type: checkbox
label: Featured Post
span: auto
content:
type: richeditor
label: Content
size: huge
span: full
excerpt:
type: textarea
label: Excerpt
size: small
span: full
comment: "Leave blank to auto-generate from content"
tabs:
fields:
tab: Content
_categories:
tab: Categories
_tags:
tab: Tags
```
**columns.yaml:**
```yaml
columns:
title:
label: Title
sortable: true
slug:
label: Slug
sortable: true
author:
label: Author
relation: author
select: name
published:
label: Status
type: switch
published_at:
label: Published
type: datetime
sortable: true
updated_at:
label: Updated
type: datetime
sortable: true
```
**form.peb template:**
- Form structure following Phase 7 patterns
- Include TinyMCE initialization script for content field:
```javascript
initBlogEditor('#Form-field-Post-content');
```
- Reference research Pattern 9 for TinyMCE config
- Image upload handler pointing to media library endpoint
- Save button with HTMX submit
- Publish/Unpublish button
**list.peb template:**
- List structure following Phase 7 patterns
- Columns from columns.yaml
- Row actions: Edit, Delete, Publish/Unpublish
- Bulk actions: Delete selected, Publish/Unpublish selected
- Filters: published, author, date range
**Register controller in Plugin.scala**
</action>
<verify>
./mill summercms.compile succeeds
npm install succeeds with TinyMCE
YAML files parse correctly
Controller registered in plugin
</verify>
<done>
Admin can access /admin/blog/posts to list posts.
Create/edit forms render with TinyMCE editor.
Posts can be saved, published, unpublished, deleted.
List shows posts with sorting and filtering.
</done>
</task>
</tasks>
<verification>
After all tasks complete:
1. Blog plugin compiles: `./mill summercms.compile`
2. Dependencies installed: `npm install` includes TinyMCE
3. Migration creates blog_posts table: Check SQL syntax
4. Controller routes registered: Posts controller in Plugin.scala
5. Form fields render TinyMCE: richeditor type maps to TinyMCE
</verification>
<success_criteria>
- Admin can navigate to /admin/blog/posts and see post list
- Create post form shows title, slug, content editor, excerpt, publish options
- TinyMCE editor loads for content field with image upload
- Saving post generates slug from title if empty
- Content rendered to HTML with Markdown support
- Published posts have published_at timestamp
- Featured flag can be toggled
</success_criteria>
<output>
After completion, create `.planning/phases/10-core-plugins/10-03-SUMMARY.md`
</output>

View File

@@ -0,0 +1,512 @@
---
phase: 10-core-plugins
plan: 04
type: execute
wave: 2
depends_on: ["10-03"]
files_modified:
- plugins/golem15/blog/models/Category.scala
- plugins/golem15/blog/models/Tag.scala
- plugins/golem15/blog/repositories/CategoryRepository.scala
- plugins/golem15/blog/repositories/TagRepository.scala
- plugins/golem15/blog/services/CategoryService.scala
- plugins/golem15/blog/services/TagService.scala
- plugins/golem15/blog/services/BlogPostService.scala
- plugins/golem15/blog/controllers/Categories.scala
- plugins/golem15/blog/components/Posts.scala
- plugins/golem15/blog/components/Post.scala
- plugins/golem15/blog/components/Categories.scala
- plugins/golem15/blog/components/RelatedPosts.scala
- plugins/golem15/blog/resources/db/migration/V10_2_2__blog_categories_tags.sql
- plugins/golem15/blog/resources/controllers/categories/fields.yaml
- plugins/golem15/blog/resources/views/posts/default.peb
- plugins/golem15/blog/resources/views/posts/item.peb
- plugins/golem15/blog/resources/views/post/default.peb
autonomous: true
must_haves:
truths:
- "Blog posts can be assigned to categories"
- "Categories are hierarchical (parent/child)"
- "Blog posts can have multiple tags"
- "Frontend displays post listings with pagination"
- "Frontend displays individual post pages"
- "Related posts shown based on shared categories/tags"
artifacts:
- path: "plugins/golem15/blog/models/Category.scala"
provides: "Hierarchical category model with nested set"
contains: "case class Category"
- path: "plugins/golem15/blog/models/Tag.scala"
provides: "Tag model for free-form labeling"
contains: "case class Tag"
- path: "plugins/golem15/blog/components/Posts.scala"
provides: "Frontend post listing component"
exports: ["PostsComponent", "onLoadMore"]
- path: "plugins/golem15/blog/components/Post.scala"
provides: "Single post display component"
exports: ["PostComponent"]
- path: "plugins/golem15/blog/resources/db/migration/V10_2_2__blog_categories_tags.sql"
provides: "Categories, tags, and pivot tables"
contains: "CREATE TABLE blog_categories"
key_links:
- from: "plugins/golem15/blog/components/Posts.scala"
to: "BlogPostService"
via: "post listing"
pattern: "postService\\.listFrontend"
- from: "plugins/golem15/blog/services/BlogPostService.scala"
to: "CategoryService"
via: "category filtering"
pattern: "categoryService\\.getAllChildrenAndSelf"
- from: "plugins/golem15/blog/services/CategoryService.scala"
to: "NestedTree"
via: "tree operations"
pattern: "NestedTree\\."
---
<objective>
Complete the Blog plugin with categories, tags, and frontend listing components.
Purpose: Enable content organization and frontend display, demonstrating hierarchical data structures and frontend component patterns.
Output: Complete Blog plugin with:
- Hierarchical categories using nested set model
- Tags for free-form labeling
- Frontend Posts component with pagination/infinite scroll
- Frontend Post component for single post display
- Related posts based on shared categories/tags
</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/10-core-plugins/10-CONTEXT.md
@.planning/phases/10-core-plugins/10-RESEARCH.md
# Prior plan in this phase
@.planning/phases/10-core-plugins/10-03-SUMMARY.md
# Reference: Phase 3 component patterns for HTMX
</context>
<tasks>
<task type="auto">
<name>Task 1: Category and tag models with services</name>
<files>
plugins/golem15/blog/models/Category.scala
plugins/golem15/blog/models/Tag.scala
plugins/golem15/blog/models/PostCategory.scala
plugins/golem15/blog/models/PostTag.scala
plugins/golem15/blog/repositories/CategoryRepository.scala
plugins/golem15/blog/repositories/TagRepository.scala
plugins/golem15/blog/services/CategoryService.scala
plugins/golem15/blog/services/TagService.scala
plugins/golem15/blog/services/NestedTree.scala
plugins/golem15/blog/resources/db/migration/V10_2_2__blog_categories_tags.sql
</files>
<action>
Create category and tag models with nested tree support:
**Category.scala (research Pattern 6):**
- Case class with fields:
id, name, slug (unique), description (Option), parentId (Option),
nestLeft (Int), nestRight (Int), nestDepth (Int),
published, publishedAt, createdAt, updatedAt
- Quill table mapping to `blog_categories`
- CategoryTreeNode case class: category, children (List), postCount
**Tag.scala (research Pattern 7):**
- Case class: id, name, slug (unique), createdAt, updatedAt
- Quill table mapping to `blog_tags`
- TagWithCount case class: tag, postCount
**PostCategory.scala & PostTag.scala:**
- Pivot case classes for many-to-many relationships
- PostCategory: postId, categoryId
- PostTag: postId, tagId
**NestedTree.scala (utility object):**
- Functions for nested set operations:
- rebuild(categories: List[Category]): List[Category] - Recalculate left/right
- descendants(category, all): List[Category] - Get all children
- ancestors(category, all): List[Category] - Get all parents
- moveNode(category, newParent, all): List[Category] - Move category in tree
- insertNode(category, parent, all): List[Category] - Add new category
- deleteNode(category, all): List[Category] - Remove category and adjust tree
**CategoryRepository.scala:**
- Trait:
- findById(id): Option[Category]
- findBySlug(slug): Option[Category]
- listAll: List[Category]
- create(category): Category
- update(category): Category
- delete(id): Unit
- getPostCount(categoryId): Int
- getNestedPostCount(categoryId): Int (includes children)
- Live implementation using QuillContext
**TagRepository.scala:**
- Trait:
- findById(id): Option[Tag]
- findBySlug(slug): Option[Tag]
- findByName(name): Option[Tag]
- listAll: List[Tag]
- create(tag): Tag
- listForPost(postId): List[Tag]
- getPopular(limit): List[TagWithCount]
- Live implementation
**CategoryService.scala:**
- Trait:
- create(data): Category (auto-generate slug, rebuild tree)
- update(id, data): Category (rebuild tree if parent changed)
- delete(id): Unit (rebuild tree after)
- findBySlug(slug): Option[Category]
- listAll: List[Category]
- getTree: List[CategoryTreeNode]
- getChildren(parentId): List[Category]
- getAllChildrenAndSelf(categoryId): List[Category] (for nested queries)
- getPostCount(categoryId): Int
- getNestedPostCount(categoryId): Int
- Live implementation with NestedTree for tree operations
**TagService.scala:**
- Trait:
- findOrCreate(name): Tag (create with auto-slug if not exists)
- findBySlug(slug): Option[Tag]
- listAll: List[Tag]
- listForPost(postId): List[Tag]
- getPopularTags(limit): List[TagWithCount]
- syncPostTags(postId, tagNames): List[Tag] (update pivot table)
- Live implementation
**V10_2_2__blog_categories_tags.sql:**
- CREATE TABLE blog_categories (all fields, nested set indexes)
- CREATE TABLE blog_tags (id, name, slug unique, timestamps)
- CREATE TABLE blog_posts_categories (post_id, category_id, composite PK, FKs)
- CREATE TABLE blog_posts_tags (post_id, tag_id, composite PK, FKs)
- ALTER TABLE blog_posts ADD CONSTRAINT fk_main_category REFERENCES blog_categories
- Indexes on slugs, nested set columns, pivot table lookups
</action>
<verify>
./mill summercms.compile succeeds
NestedTree rebuild produces valid left/right sequence
SQL migration syntax valid
</verify>
<done>
Category model with nested set tree support.
Tag model for free-form labeling.
Services handle CRUD with automatic tree maintenance.
Pivot tables link posts to categories and tags.
</done>
</task>
<task type="auto">
<name>Task 2: Update BlogPostService for relations</name>
<files>
plugins/golem15/blog/services/BlogPostService.scala (modify)
plugins/golem15/blog/repositories/BlogPostRepository.scala (modify)
plugins/golem15/blog/controllers/Posts.scala (modify)
plugins/golem15/blog/controllers/Categories.scala
plugins/golem15/blog/resources/controllers/posts/fields.yaml (modify)
plugins/golem15/blog/resources/controllers/categories/fields.yaml
plugins/golem15/blog/resources/controllers/categories/columns.yaml
</files>
<action>
Integrate categories and tags with blog posts:
**BlogPostRepository.scala additions:**
- findBySlugWithRelations(slug): Option[BlogPostWithRelations]
- listFrontendWithRelations(options): PaginatedResult[BlogPostWithRelations]
- listByCategory(categoryId, options): PaginatedResult[BlogPostWithRelations]
- listByTag(tagSlug, options): PaginatedResult[BlogPostWithRelations]
- getRelatedPosts(postId, limit): List[BlogPostWithRelations]
- Join queries to load categories, tags, author in single query
**BlogPostService.scala modifications:**
- BlogPostWithRelations case class:
post: BlogPost, categories: List[Category], tags: List[Tag],
featuredImages: List[MediaItem], author: Option[BackendUser],
readingTime: Int
- Update create/update to handle categories and tags:
- PostCreateData/UpdateData add: categoryIds (List[Long]), tagNames (List[String])
- On save: Sync pivot tables via services
- Set mainCategoryId if categories provided
- Update listFrontend to use listFrontendWithRelations
- Add listByCategory(categoryId, options)
- Add listByTag(tagSlug, options)
- Add getRelatedPosts(postId, limit):
- Score = 2 * shared_categories + 1 * shared_tags
- Exclude current post
- Return top N by score
**Posts.scala controller modifications:**
- Update store/update actions to handle categories and tags from form
- Add category/tag selection to form context
**Categories.scala controller (new):**
- Admin controller for category management
- Actions: index, create, store, edit, update, delete, reorder (tree)
- HTMX handler for drag-drop tree reordering
**fields.yaml modifications (Posts):**
- Add categories tab fields:
```yaml
_categories:
type: relation
label: Categories
nameFrom: name
list: true
tree: true
tab: Categories
_tags:
type: taglist
label: Tags
mode: array
separator: ','
tab: Tags
```
**categories/fields.yaml:**
```yaml
fields:
name:
type: text
label: Name
required: true
slug:
type: text
label: Slug
preset:
field: name
type: slug
parent_id:
type: relation
label: Parent Category
emptyOption: -- None --
nameFrom: name
description:
type: textarea
label: Description
size: small
published:
type: checkbox
label: Published
```
**categories/columns.yaml:**
```yaml
columns:
name:
label: Name
sortable: true
tree: true
slug:
label: Slug
published:
label: Published
type: switch
post_count:
label: Posts
type: number
```
**Register Categories controller in Plugin.scala**
</action>
<verify>
./mill summercms.compile succeeds
Related posts algorithm returns posts with shared categories/tags
Category tree drag-drop works in admin
</verify>
<done>
Posts can be assigned categories and tags.
Admin can manage categories in tree view.
Related posts calculated by shared categories/tags.
Post queries include relations efficiently.
</done>
</task>
<task type="auto">
<name>Task 3: Frontend listing components</name>
<files>
plugins/golem15/blog/components/Posts.scala
plugins/golem15/blog/components/Post.scala
plugins/golem15/blog/components/Categories.scala
plugins/golem15/blog/components/RelatedPosts.scala
plugins/golem15/blog/resources/views/posts/default.peb
plugins/golem15/blog/resources/views/posts/item.peb
plugins/golem15/blog/resources/views/post/default.peb
plugins/golem15/blog/resources/views/categories/default.peb
plugins/golem15/blog/resources/views/relatedposts/default.peb
plugins/golem15/blog/Plugin.scala (modify)
</files>
<action>
Create frontend components for blog display:
**Posts.scala (research Pattern 8):**
- Extend SummerComponent trait
- ComponentDetails: name "Posts", description "Blog post listing"
- Property schema:
- pageNumber: String, URL param for pagination, default "{{ :page }}"
- categoryFilter: String, filter by category slug
- postsPerPage: String, default "10", validation 1-100
- noPostsMessage: String, default "No posts found."
- sortOrder: Dropdown with BlogPost.allowedSortingOptions
- throwNotFound: Checkbox, 404 on empty results
- Page variables: posts (PaginatedResult), category (Option), noPostsMessage
- onRun lifecycle:
- Load category if categoryFilter set
- Build PostListOptions from properties
- Call postService.listFrontend or listByCategory
- Handle empty results per throwNotFound
- Set page context variables
- onLoadMore HTMX handler:
- Parse page number from form
- Load next page of posts
- Return items partial
- Trigger "postsExhausted" when no more
**Post.scala component:**
- ComponentDetails: name "Post", description "Single post display"
- Property schema:
- slug: String, URL param for post slug, default "{{ :slug }}"
- notFoundMessage: String
- Page variables: post (BlogPostWithRelations)
- onRun lifecycle:
- Get slug from URL
- Call postService.findBySlug
- 404 if not found
- Set page context: post, author, categories, tags, readingTime
- Set OpenGraph meta tags for social sharing
**Categories.scala component:**
- ComponentDetails: name "Categories", description "Category listing/tree"
- Property schema:
- displayMode: Dropdown (tree/flat), default "tree"
- showPostCount: Checkbox, default true
- includeEmpty: Checkbox, show categories with no posts
- Page variables: categories (tree or flat list)
- onRun: Load category tree, filter empty if needed
**RelatedPosts.scala component:**
- ComponentDetails: name "RelatedPosts", description "Related posts by category/tag"
- Property schema:
- limit: String, max posts to show, default "3"
- postSlug: String, current post slug (from context)
- Page variables: relatedPosts (List)
- onRun: Call postService.getRelatedPosts, set context
**Templates:**
**posts/default.peb (research code example):**
```html
<div class="blog-posts" id="posts-{{ __SELF__ }}">
{% if posts.items is empty %}
<div class="no-posts">{{ noPostsMessage }}</div>
{% else %}
<div class="posts-list">
{% for post in posts.items %}
{% include "posts/item" %}
{% endfor %}
</div>
{% if posts.hasNext %}
<div class="load-more">
<button
hx-post="{{ componentHandler('onLoadMore') }}"
hx-vals='{"page": {{ posts.currentPage + 1 }}}'
hx-target="#posts-{{ __SELF__ }} .posts-list"
hx-swap="beforeend"
hx-indicator=".htmx-indicator">
Load More
<span class="htmx-indicator">Loading...</span>
</button>
</div>
{% endif %}
{% endif %}
</div>
```
**posts/item.peb:**
- Article element with post data
- Featured image (lazy load)
- Title as link to post page
- Excerpt or auto-generated summary
- Author name, published date, reading time
- Category and tag links
**post/default.peb:**
- Full post display
- OpenGraph meta tags in head
- Title, featured image, author, date
- Content HTML
- Categories and tags
- Share buttons (copy link, Twitter, Facebook)
- Include RelatedPosts component placeholder
**categories/default.peb:**
- Tree or flat list based on displayMode
- Category name as link
- Post count if showPostCount
**relatedposts/default.peb:**
- Grid of related post cards
- Image, title, excerpt snippet
**Register all components in Plugin.scala boot method**
</action>
<verify>
./mill summercms.compile succeeds
Components registered in Plugin.scala
Templates have valid Pebble syntax
HTMX attributes properly formatted
</verify>
<done>
Posts component displays paginated listings with infinite scroll.
Post component displays single post with meta tags.
Categories component shows category tree/list.
RelatedPosts component shows related content.
All templates render with HTMX interactions.
</done>
</task>
</tasks>
<verification>
After all tasks complete:
1. Blog plugin compiles: `./mill summercms.compile`
2. Migration creates all tables: blog_categories, blog_tags, pivot tables
3. Category nested tree rebuilds correctly on insert/delete
4. Posts component loads and paginates
5. Post component displays with OpenGraph meta
6. Related posts algorithm returns relevant content
</verification>
<success_criteria>
- Categories form hierarchical tree structure
- Nested set left/right values correct after operations
- Tags can be created on-the-fly when typing
- Post admin form shows category tree selector and tag input
- Frontend /blog shows post listing with load more
- Frontend /blog/:slug shows full post with related posts
- Category pages filter posts by category and children
- Tag pages filter posts by tag
</success_criteria>
<output>
After completion, create `.planning/phases/10-core-plugins/10-04-SUMMARY.md`
</output>