diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index dbf8a0f..2367eae 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -195,13 +195,13 @@ Plans: 6. Blog posts can be organized into categories 7. Blog posts can be tagged with multiple tags 8. Frontend displays blog post listings via components -**Plans**: TBD +**Plans**: 4 plans Plans: -- [ ] 10-01: User plugin - registration and authentication -- [ ] 10-02: User plugin - profiles and password reset -- [ ] 10-03: Blog plugin - posts and editor -- [ ] 10-04: Blog plugin - categories, tags, listing components +- [ ] 10-01-PLAN.md - User plugin models, services, and authentication components +- [ ] 10-02-PLAN.md - User profile management and password reset flow +- [ ] 10-03-PLAN.md - Blog post model, services, and admin controller with TinyMCE +- [ ] 10-04-PLAN.md - Blog categories, tags, and frontend listing components ## Progress @@ -219,7 +219,7 @@ Phases execute in numeric order: 1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7 -> 8 -> 9 -> 10 | 7. Admin Forms & Lists | 0/3 | Planned | - | | 8. Admin Dashboard | 0/4 | Planned | - | | 9. Content Management | 0/7 | Planned | - | -| 10. Core Plugins | 0/4 | Not started | - | +| 10. Core Plugins | 0/4 | Planned | - | --- *Roadmap created: 2026-02-04* diff --git a/.planning/phases/10-core-plugins/10-01-PLAN.md b/.planning/phases/10-core-plugins/10-01-PLAN.md new file mode 100644 index 0000000..f406e8c --- /dev/null +++ b/.planning/phases/10-core-plugins/10-01-PLAN.md @@ -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\"" +--- + + +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 + + + +@/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/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) + + + + + + Task 1: User plugin models and database schema + + 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 + + + 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 + + + ./mill summercms.compile succeeds with new models + SQL migration syntax valid (no syntax errors in IDE) + + + FrontendUser, UserGroup, UserThrottle, UserSettings models exist with Quill mappings. + Migration creates all required tables with proper indexes. + + + + + Task 2: Authentication and user services + + plugins/golem15/user/repositories/FrontendUserRepository.scala + plugins/golem15/user/services/FrontendUserService.scala + plugins/golem15/user/services/FrontendAuthService.scala + plugins/golem15/user/services/ThrottleService.scala + + + 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 + + + ./mill summercms.compile succeeds + All service traits have Live implementations + ZLayers compose correctly (no missing dependencies) + + + FrontendUserRepository provides data access. + FrontendUserService handles registration with Argon2id password hashing. + FrontendAuthService manages JWT sessions with cookie helpers. + ThrottleService prevents brute force attacks. + + + + + Task 3: Session and Account components with templates + + 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 + + + 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 + + + ./mill summercms.compile succeeds + Templates have valid Pebble syntax (no unclosed tags) + Components registered in Plugin.scala boot method + + + 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. + + + + + + +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 + + + +- 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 + + + +After completion, create `.planning/phases/10-core-plugins/10-01-SUMMARY.md` + diff --git a/.planning/phases/10-core-plugins/10-02-PLAN.md b/.planning/phases/10-core-plugins/10-02-PLAN.md new file mode 100644 index 0000000..c0bbd5a --- /dev/null +++ b/.planning/phases/10-core-plugins/10-02-PLAN.md @@ -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" +--- + + +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 + + + +@/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/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 + + + + + + Task 1: Profile management with avatar upload + + 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 + + + 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 + + + ./mill summercms.compile succeeds + Account component has onUpdate and onUploadAvatar handlers + Templates have valid Pebble syntax + + + 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. + + + + + Task 2: Password reset flow and email service + + 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 + + + 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** + + + ./mill summercms.compile succeeds + ResetPassword component registered in Plugin.scala + Email templates have valid Pebble syntax + Reset code generation uses SecureRandom + + + 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). + + + + + + +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 + + + +- 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 + + + +After completion, create `.planning/phases/10-core-plugins/10-02-SUMMARY.md` + diff --git a/.planning/phases/10-core-plugins/10-03-PLAN.md b/.planning/phases/10-core-plugins/10-03-PLAN.md new file mode 100644 index 0000000..925ad18 --- /dev/null +++ b/.planning/phases/10-core-plugins/10-03-PLAN.md @@ -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" +--- + + +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 + + + +@/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/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 + + + + + + Task 1: Blog post model and database schema + + 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 + + + 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 + + + ./mill summercms.compile succeeds with new dependencies + SQL migration syntax valid + BlogPost model has Quill mappings + + + Blog plugin structure created with manifest. + BlogPost model ready with all fields. + Migration creates blog_posts table. + Dependencies added for slug generation and Markdown. + + + + + Task 2: Post service with content processing + + plugins/golem15/blog/repositories/BlogPostRepository.scala + plugins/golem15/blog/services/BlogPostService.scala + plugins/golem15/blog/services/ContentService.scala + + + 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 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 + + + ./mill summercms.compile succeeds + ContentService renders Markdown correctly + BlogPostService generates slugs from titles + Sanitization strips dangerous HTML + + + 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. + + + + + Task 3: Admin controller with TinyMCE editor + + 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 + + + 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** + + + ./mill summercms.compile succeeds + npm install succeeds with TinyMCE + YAML files parse correctly + Controller registered in plugin + + + 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. + + + + + + +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 + + + +- 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 + + + +After completion, create `.planning/phases/10-core-plugins/10-03-SUMMARY.md` + diff --git a/.planning/phases/10-core-plugins/10-04-PLAN.md b/.planning/phases/10-core-plugins/10-04-PLAN.md new file mode 100644 index 0000000..c7020e8 --- /dev/null +++ b/.planning/phases/10-core-plugins/10-04-PLAN.md @@ -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\\." +--- + + +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 + + + +@/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/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 + + + + + + Task 1: Category and tag models with services + + 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 + + + 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 + + + ./mill summercms.compile succeeds + NestedTree rebuild produces valid left/right sequence + SQL migration syntax valid + + + 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. + + + + + Task 2: Update BlogPostService for relations + + 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 + + + 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** + + + ./mill summercms.compile succeeds + Related posts algorithm returns posts with shared categories/tags + Category tree drag-drop works in admin + + + 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. + + + + + Task 3: Frontend listing components + + 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) + + + 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 +
+ {% if posts.items is empty %} +
{{ noPostsMessage }}
+ {% else %} +
+ {% for post in posts.items %} + {% include "posts/item" %} + {% endfor %} +
+ + {% if posts.hasNext %} +
+ +
+ {% endif %} + {% endif %} +
+ ``` + + **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** +
+ + ./mill summercms.compile succeeds + Components registered in Plugin.scala + Templates have valid Pebble syntax + HTMX attributes properly formatted + + + 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. + +
+ +
+ + +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 + + + +- 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 + + + +After completion, create `.planning/phases/10-core-plugins/10-04-SUMMARY.md` +