# TASK-004: Native CMS and WordPress Removal **Status:** ✅ complete (verified) **Created:** 2026-01-02 **Last Updated:** 2026-01-02 16:30 by Claude Opus 4.5 (Implementation Agent) **Assignee:** Claude Opus 4.5 (Implementation Agent) **Verifier:** Claude Opus 4.5 (Verification Agent) --- ## Critical Context (READ FIRST) **WordPress served its purpose. Now we own the content layer.** ### The Current State WordPress (hestia.host.uk.com) has been the content backend for satellite sites: - Blog posts and help articles synced via REST API - ContentItem model stores local copies with sync metadata - SatelliteService uses "local-first" pattern (check DB, fallback to WP API) - Webhook integration for real-time sync This worked for bootstrapping, but creates: - **Operational overhead** — two systems to maintain - **Sync complexity** — race conditions, stale content, webhook failures - **Stack mismatch** — PHP ↔ WordPress when we could be pure Laravel - **Future friction** — MCP integration wants native content, not WP bridges ### The Vision A fully native content management system where: - ContentItem is the **source of truth** (not a sync cache) - Content Editor at `/hub/content-editor/{workspace}/new/{contentType}` is the primary authoring tool - AI assistance via MCP for content generation, SEO, translation - Satellite sites serve directly from Laravel (no WordPress dependency) - WordPress can re-integrate later as *one option among many* (headless CMS, not *the* CMS) ### Why Now 1. Content Editor already exists and works well 2. SatelliteService already has local-first logic 3. MCP Portal (mcp.host.uk.com) needs native content APIs 4. Phase 42 content generation uses native workflows 5. WordPress hosting is an unnecessary cost --- ## Objective Remove WordPress as a required dependency for satellite site content. Make the native Content Editor the primary authoring tool, with ContentItem as the source of truth. Prepare the content layer for MCP integration (AI-assisted content creation, semantic search, agent access). **"Done" looks like:** - Satellite sites (blog, help) serve content entirely from Laravel database - Content Editor is the only place to create/edit content - No WordPress API calls in normal operation - MCP tools can read/write content natively - WordPress integration exists as an optional import/export feature --- ## Acceptance Criteria ### Phase 1: Remove WordPress Dependency ✅ VERIFIED - [x] AC1: SatelliteService never calls WordPressService in normal operation - [x] AC2: ContentItem has `content_type = 'native'` (new value, distinct from 'wordpress') - [x] AC3: Blog pages (`/blog`, `/blog/{slug}`) render from native content only - [x] AC4: Help pages (`/help`, `/help/{slug}`) render from native content only - [x] AC5: WordPress sync code is moved to `app/Legacy/` (not deleted, for future import) - [x] AC6: `WORDPRESS_URL` env var is optional, not required for app boot ### Phase 2: Content Editor Enhancements - [x] AC7: Content Editor supports rich text with Flux Editor (not just textarea) - [x] AC8: Content Editor has media upload (not WordPress media library) - [x] AC9: Content Editor has category/tag management - [x] AC10: Content Editor has SEO fields (meta title, description, OG image) - [x] AC11: Content Editor has scheduling (publish_at datetime) - [x] AC12: Content Editor has revision history ### Phase 3: MCP Integration ✅ COMPLETE - [x] AC13: MCP tool `content_list` returns content items for a workspace - [x] AC14: MCP tool `content_read` returns full content by ID or slug - [x] AC15: MCP tool `content_create` creates new content (respects entitlements) - [x] AC16: MCP tool `content_update` updates existing content - [x] AC17: MCP resource `content://workspace/slug` provides content for context - [x] AC18: AI content generation uses MCP tools (not direct OpenAI/Claude calls) ### Phase 4: Optional WordPress Import ✅ COMPLETE - [x] AC19: Artisan command `content:import-wordpress` imports from WP REST API - [x] AC20: Import preserves original WordPress IDs in `wp_id` field - [x] AC21: Import handles media, categories, tags, authors - [x] AC22: Import is idempotent (re-running updates, doesn't duplicate) --- ## Implementation Checklist ### Phase 1: WordPress Removal - [x] Create new enum value `ContentType::NATIVE` in `app/Enums/ContentType.php` - [x] Update `ContentItem` model to default to `content_type = 'native'` - [x] Refactor `SatelliteService` to remove WordPress fallback: - [x] `getHomepage()` — native only - [x] `getPosts()` — native only - [x] `getPost()` — native only - [x] `getPage()` — native only - [x] Move WordPress integration files to `app/Legacy/`: - [x] `app/Services/WordPressService.php` → `app/Legacy/WordPress/WordPressService.php` - [x] `app/Services/ContentSyncService.php` → `app/Legacy/WordPress/ContentSyncService.php` - [x] `app/Http/Controllers/Api/ContentWebhookController.php` → `app/Legacy/WordPress/` - [x] `app/Jobs/ProcessContentWebhook.php` → `app/Legacy/WordPress/` - [x] Update `config/services.php` to make WordPress optional - [x] Make WordPress routes conditional in `routes/api.php` (enabled via `CONTENT_WORDPRESS_ENABLED`) - [x] Update satellite views to not expect WordPress-specific fields (added native filter option) - [x] Add feature flag `CONTENT_SOURCE=native` (default) vs `CONTENT_SOURCE=wordpress` - [x] Write migration to update existing `content_type = 'wordpress'` → `'native'` ### Phase 2: Content Editor - [x] Implement Flux Editor integration in `ContentEditor.php` - [x] Create media upload in Content Editor (WithFileUploads trait, Livewire) - [x] Add media library modal to Content Editor (featured image selection) - [x] Add taxonomy management UI in Content Editor sidebar (categories/tags) - [x] Add SEO fields component in Content Editor sidebar (meta title, description, keywords, preview) - [x] Implement `publish_at` scheduling with datetime picker in sidebar - [x] Create `ContentRevision` model for version history - [x] Add revision history panel with restore functionality - [x] Add autosave functionality (60-second interval) - [x] Add Ctrl+S keyboard shortcut for save ### Phase 3: MCP Tools - [x] Create `app/Mcp/Tools/ContentTools.php`: - [x] `content_list` — paginated, filterable by type/status/search - [x] `content_read` — by ID or slug, returns markdown - [x] `content_create` — with entitlement check and taxonomy support - [x] `content_update` — with revision creation - [x] `content_delete` — soft delete - [x] `taxonomies` — list categories and tags - [x] Create `app/Mcp/Resources/ContentResource.php`: - [x] URI format: `content://{workspace}/{slug}` - [x] Returns markdown with YAML frontmatter for AI context - [x] Includes categories, tags, SEO meta, publish_at - [x] Register tools in MCP server (`app/Mcp/Servers/HostHub.php`) - [x] Add entitlement features: `content.mcp_access`, `content.items`, `content.ai_generation` - [x] Write MCP tool tests (48 tests passing) ### Phase 4: WordPress Import ✅ COMPLETE - [x] Create `app/Console/Commands/ContentImportWordPress.php` - [x] Implement batch import with progress bar - [x] Map WordPress fields to ContentItem fields - [x] Download and store media files locally - [x] Create authors from WordPress users - [x] Handle categories and tags - [x] Add `--dry-run` flag for preview - [x] Add `--since` flag for incremental imports - [x] Add `--limit` flag for controlling import count - [x] Add `--skip-media` flag to skip file downloads - [x] Add `--types` flag to select what to import - [x] Support JWT and Basic Auth for private content - [x] Handle HTML entities and smart quotes - [x] Extract SEO metadata from Yoast/RankMath ### Testing - [x] Feature test: Satellite blog renders native content - [x] Feature test: Content Editor creates native content - [x] Feature test: Content Editor updates native content - [x] Feature test: Media upload works - [x] Feature test: MCP tools require authentication - [x] Feature test: MCP tools respect entitlements - [x] Unit test: ContentItem has correct relationships - [x] Unit test: SatelliteService returns native content - [x] Integration test: WordPress import processes all content types (21 tests) --- ## Migration Strategy ### Zero Downtime Approach 1. **Phase 1a: Add native support alongside WordPress** - Keep WordPress sync running - Add `content_type = 'native'` capability - Content Editor creates native content - SatelliteService prefers native, falls back to WordPress 2. **Phase 1b: Migrate existing content** - Run one-time migration to copy `wordpress` → `native` - Verify all satellite pages render correctly - Monitor for 1 week 3. **Phase 1c: Disable WordPress sync** - Remove webhook registration - Set `CONTENT_SOURCE=native` - Keep WordPress running (read-only backup) 4. **Phase 1d: Remove WordPress** - Move sync code to `app/Legacy/` - Remove WordPress infrastructure - Update documentation ### Rollback Plan If issues arise: 1. Set `CONTENT_SOURCE=wordpress` 2. SatelliteService reverts to WordPress fallback 3. Re-enable webhook sync 4. Content continues serving from WordPress --- ## Data Model Changes ### ContentItem Updates ```php // New enum value enum ContentType: string { case WORDPRESS = 'wordpress'; // Legacy synced content case NATIVE = 'native'; // Native Host UK content case HOSTUK = 'hostuk'; // Keep for backwards compat, alias to native case SATELLITE = 'satellite'; // Per-satellite site content } // New fields (if not present) Schema::table('content_items', function (Blueprint $table) { $table->timestamp('publish_at')->nullable()->after('status'); $table->unsignedBigInteger('revision_of')->nullable()->after('id'); $table->foreign('revision_of')->references('id')->on('content_items'); }); ``` ### New Table: content_revisions ```php Schema::create('content_revisions', function (Blueprint $table) { $table->id(); $table->foreignId('content_item_id')->constrained()->cascadeOnDelete(); $table->foreignId('user_id')->nullable()->constrained(); $table->string('title'); $table->longText('content_json'); $table->longText('content_html'); $table->text('change_summary')->nullable(); $table->timestamps(); }); ``` --- ## MCP Tool Specifications ### content_list ```json { "name": "content_list", "description": "List content items for a workspace. Use to find articles, blog posts, or help pages.", "parameters": { "workspace": "Workspace slug (main, bio, social, etc.)", "type": "Filter by type: post, page, or all", "status": "Filter by status: draft, published, scheduled", "limit": "Max items to return (default 20)", "search": "Search title and content" }, "use_when": [ "Need to find existing content", "Want to see what's published on a satellite site", "Looking for content to update or reference" ] } ``` ### content_read ```json { "name": "content_read", "description": "Read full content of an article or page. Returns markdown for easy processing.", "parameters": { "workspace": "Workspace slug", "identifier": "Content slug or ID" }, "returns": "Full content as markdown with frontmatter (title, author, date, categories)" } ``` ### content_create ```json { "name": "content_create", "description": "Create new content. Requires workspace write permission and ai.credits entitlement.", "parameters": { "workspace": "Target workspace", "type": "post or page", "title": "Content title", "content": "Markdown content", "status": "draft (default), published, or scheduled", "publish_at": "ISO datetime for scheduled publishing", "categories": "Array of category slugs", "tags": "Array of tag strings" } } ``` --- ## Dependencies - Flux Editor component (already in Flux Pro) - S3 or local storage for media - MCP server infrastructure (existing) - Entitlement system (existing) --- ## Risks and Mitigations | Risk | Impact | Mitigation | |------|--------|------------| | Content loss during migration | High | Backup before migration, keep WordPress running until verified | | SEO impact from URL changes | Medium | Keep same URL structure, 301 redirects if needed | | Editor bugs causing data loss | High | Implement autosave, revision history from day 1 | | MCP tools expose sensitive content | Medium | Workspace permission checks, entitlement gating | --- ## Verification Results ### Phase 1 Verification: 2026-01-02 by Claude Opus 4.5 (Verification Agent) | Criterion | Status | Evidence | |-----------|--------|----------| | AC1: SatelliteService never calls WordPressService | ✅ PASS | `grep WordPressService app/Services/SatelliteService.php` returns no matches. File uses only `ContentItem` queries with `->native()` scope. Lines 38-44, 76-80, 104-111, 128-134 all use `ContentItem::forWorkspace()->native()`. | | AC2: ContentItem has `content_type = 'native'` (new default) | ✅ PASS | `ContentType::NATIVE` enum exists at `app/Enums/ContentType.php:16`. Model's `booted()` method at lines 444-452 sets default via `ContentType::default()` which returns `NATIVE`. | | AC3: Blog pages render from native content only | ✅ PASS | `app/Livewire/Satellite/Blog.php` uses `SatelliteService::getPosts()` which queries `ContentItem::native()`. No WordPressService import. | | AC4: Help pages render from native content only | ✅ PASS | `app/Livewire/Satellite/Help.php` queries `ContentItem::forWorkspace()->native()->pages()` directly at lines 38-44. No WordPressService import. | | AC5: WordPress sync code moved to `app/Legacy/` | ✅ PASS | Files exist at `app/Legacy/WordPress/`: `WordPressService.php`, `ContentSyncService.php`, `ContentWebhookController.php`, `ProcessContentWebhook.php`. Original locations (`app/Services/WordPressService.php`, `app/Services/ContentSyncService.php`) return "No such file or directory". | | AC6: `WORDPRESS_URL` env var is optional | ✅ PASS | `config/services.php:48-54` shows `content.source` defaults to `native` and `content.wordpress_enabled` defaults to `false`. WordPress config at lines 67-74 only read if enabled. `routes/api.php:41` gates WordPress routes behind `config('services.content.wordpress_enabled')`. | **Additional Verification:** - Migration `2026_01_02_140456_update_wordpress_content_to_native.php` exists and converts `wordpress`/`hostuk` to `native` - Tests: `./vendor/bin/pest --filter="Satellite|Content"` — 67 passed (2 unrelated SocialProof failures) **Verdict:** ✅ PASS — Phase 1 acceptance criteria AC1-AC6 all met. Ready for Phase 2 implementation. --- ### Phase 2 Verification: 2026-01-02 by Claude Opus 4.5 (Verification Agent) | Criterion | Status | Evidence | |-----------|--------|----------| | AC7: Content Editor supports rich text with Flux Editor | ✅ PASS | ` $wpId` and `'wp_guid' => $item['guid']['rendered']`. Test: "preserves original WordPress IDs" at line 313 verifies `$post->wp_id->toBe(12345)` and `$post->wp_guid->toBe('https://example.com/?p=12345')`. | | AC21: Import handles media, categories, tags, authors | ✅ PASS | `importAuthors()` line 231, `importCategories()` line 326, `importTags()` line 381, `importMedia()` line 480. Each has dedicated import method with progress bars. Tests verify all types: "imports WordPress users as authors", "imports WordPress categories", "imports WordPress tags", "imports WordPress media items" all pass. | | AC22: Import is idempotent (re-running updates, doesn't duplicate) | ✅ PASS | Each import method checks for existing records first (e.g., `ContentItem::forWorkspace()->where('wp_id', $wpId)->first()` at line 729). If exists, updates instead of creates (line 791-793). Test: "idempotency → updates existing content instead of duplicating" verifies `ContentItem::count()->toBe(1)` after re-import and fields are updated. | **Additional Verification:** - Tests: `./vendor/bin/pest tests/Feature/Console/ContentImportWordPressTest.php` — 21 passed (90 assertions) - Dry run mode: `--dry-run` flag prevents database changes (test at line 522) - Incremental import: `--since` flag filters by modification date (test at line 569) - Limit option: `--limit` flag caps items per type (test at line 647) - SEO extraction: Yoast/RankMath metadata extracted (test at line 717) - Scheduled posts: Future status and publish_at preserved (test at line 762) - HTML entities: Smart quotes normalised to ASCII (test at line 348) **Verdict:** ✅ PASS — Phase 4 acceptance criteria AC19-AC22 all met. TASK-004 is complete. --- ## Notes ### Why Keep WordPress Import Even though we're removing WordPress as a dependency, keeping import capability: 1. Allows migration from other WordPress sites 2. Useful for clients moving from WP to Host Hub 3. Reference for future CMS integrations (Ghost, Strapi, etc.) ### MCP as the Future The content MCP tools are strategic: - Agents can create content without UI - Enables automated content pipelines - Positions Host UK as "AI-stabilised hosting" leader - Content becomes programmable infrastructure ### Content Type Clarification - `wordpress` — Legacy, synced from WordPress (to be migrated) - `native` — Created in Host Hub (new default) - `hostuk` — Alias for native (backwards compat) - `satellite` — Per-service content (e.g., BioHost-specific help) --- *This task transforms content from a synced dependency to owned infrastructure.*