diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml new file mode 100644 index 0000000..6c61dfa --- /dev/null +++ b/.forgejo/workflows/ci.yml @@ -0,0 +1,66 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + name: PHP ${{ matrix.php }} + runs-on: ubuntu-latest + container: + image: lthn/build:php-${{ matrix.php }} + + strategy: + fail-fast: true + matrix: + php: ["8.3", "8.4"] + + steps: + - uses: actions/checkout@v4 + + - name: Clone sister packages + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + for pkg in php-framework php-tenant; do + echo "Cloning $pkg into ../$pkg" + git clone --depth 1 \ + "https://x-access-token:${GITHUB_TOKEN}@forge.lthn.ai/core/${pkg}.git" \ + ../$pkg + done + ls -la ../php-framework/composer.json ../php-tenant/composer.json + + - name: Configure path repositories + run: | + composer config repositories.core path ../php-framework --no-interaction + composer config repositories.tenant path ../php-tenant --no-interaction + + - name: Install dependencies + run: composer install --prefer-dist --no-interaction --no-progress + + - name: Run Pint + run: | + if [ -f vendor/bin/pint ]; then + vendor/bin/pint --test + else + echo "Pint not installed, skipping" + fi + + - name: Run unit tests + run: | + if [ -f vendor/bin/pest ]; then + if [ -d tests/Unit ] || [ -d tests/unit ]; then + vendor/bin/pest tests/Unit --ci + elif [ -d src/Tests/Unit ]; then + vendor/bin/pest src/Tests/Unit --ci + else + echo "No unit test directory found, skipping" + fi + elif [ -f vendor/bin/phpunit ]; then + vendor/bin/phpunit --testsuite=Unit + else + echo "No test runner found, skipping" + fi diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml new file mode 100644 index 0000000..844f7a2 --- /dev/null +++ b/.forgejo/workflows/release.yml @@ -0,0 +1,38 @@ +name: Publish Composer Package + +on: + push: + tags: + - 'v*' + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Create package archive + run: | + apt-get update && apt-get install -y zip + zip -r package.zip . \ + -x ".forgejo/*" \ + -x ".git/*" \ + -x "tests/*" \ + -x "docker/*" \ + -x "*.yaml" \ + -x "infection.json5" \ + -x "phpstan.neon" \ + -x "phpunit.xml" \ + -x "psalm.xml" \ + -x "rector.php" \ + -x "TODO.md" \ + -x "ROADMAP.md" \ + -x "CONTRIBUTING.md" \ + -x "package.json" \ + -x "package-lock.json" + + - name: Publish to Forgejo Composer registry + run: | + curl --fail --user "${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_TOKEN }}" \ + --upload-file package.zip \ + "https://forge.lthn.ai/api/packages/core/composer?version=${FORGEJO_REF_NAME#v}" diff --git a/.gitignore b/.gitignore index 899ea82..d16642b 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,5 @@ yarn-error.log /.nova /.vscode /.zed +.core/ +.idea/ diff --git a/Boot.php b/Boot.php index cd2f02d..ac043e5 100644 --- a/Boot.php +++ b/Boot.php @@ -8,11 +8,11 @@ use Core\Events\ApiRoutesRegistering; use Core\Events\ConsoleBooting; use Core\Events\McpToolsRegistering; use Core\Events\WebRoutesRegistering; +use Core\Mod\Content\Services\HtmlSanitiser; use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Http\Request; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\ServiceProvider; -use Core\Mod\Content\Services\HtmlSanitiser; use RuntimeException; /** @@ -149,8 +149,8 @@ class Boot extends ServiceProvider $event->livewire('content.admin.webhook-manager', View\Modal\Admin\WebhookManager::class); $event->livewire('content.admin.content-search', View\Modal\Admin\ContentSearch::class); - if (file_exists(__DIR__.'/Routes/web.php')) { - $event->routes(fn () => require __DIR__.'/Routes/web.php'); + if (file_exists(__DIR__.'/routes/web.php')) { + $event->routes(fn () => require __DIR__.'/routes/web.php'); } } @@ -159,8 +159,8 @@ class Boot extends ServiceProvider */ public function onApiRoutes(ApiRoutesRegistering $event): void { - if (file_exists(__DIR__.'/Routes/api.php')) { - $event->routes(fn () => require __DIR__.'/Routes/api.php'); + if (file_exists(__DIR__.'/routes/api.php')) { + $event->routes(fn () => require __DIR__.'/routes/api.php'); } } diff --git a/CLAUDE.md b/CLAUDE.md index c2652e1..5151d81 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,9 +4,12 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Package Overview -This is `host-uk/core-content`, a Laravel package providing headless CMS functionality for the Core PHP Framework. It handles content management, AI generation, revisions, webhooks, and search. +This is `lthn/php-content`, a Laravel package providing headless CMS functionality for the Core PHP Framework. It handles content management, AI generation, revisions, webhooks, and search. **Namespace:** `Core\Mod\Content\` +**Entry point:** `Boot.php` (extends `ServiceProvider`, uses event-driven registration) +**Dependencies:** `lthn/php` (core framework), `ezyang/htmlpurifier` (required, security-critical) +**Optional:** `core-tenant` (workspaces/users), `core-agentic` (AI services), `core-mcp` (MCP tool registration) ## Commands @@ -19,9 +22,9 @@ composer run test # Pest tests ## Architecture -### Boot.php (Service Provider) +### Event-Driven Boot -The entry point extending `ServiceProvider` with event-driven registration: +`Boot.php` registers lazily via event listeners — routes, commands, views, and MCP tools are only loaded when their events fire: ```php public static array $listens = [ @@ -32,66 +35,73 @@ public static array $listens = [ ]; ``` -### Package Structure +### AI Generation Pipeline (Two-Stage) -``` -Boot.php # Service provider + event listeners -config.php # Package configuration -Models/ # Eloquent: ContentItem, ContentBrief, ContentRevision, etc. -Services/ # Business logic: ContentSearchService, ContentRender, etc. -Controllers/Api/ # REST API controllers -Mcp/Handlers/ # MCP tools for AI agent integration -Jobs/ # Queue jobs: GenerateContentJob, ProcessContentWebhook -View/Modal/ # Livewire components (Web/ and Admin/) -View/Blade/ # Blade templates -Migrations/ # Database migrations -routes/ # web.php, api.php, console.php -``` +`AIGatewayService` orchestrates a two-stage content generation pipeline: +1. **Stage 1 (Gemini):** Fast, cost-effective draft generation +2. **Stage 2 (Claude):** Quality refinement and brand voice alignment -### Key Models +Brief workflow: `pending` → `queued` → `generating` → `review` → `published` -| Model | Purpose | -|-------|---------| -| `ContentItem` | Published content with revisions | -| `ContentBrief` | Content generation requests/queue | -| `ContentRevision` | Version history for content items | -| `ContentMedia` | Attached media files | -| `ContentTaxonomy` | Categories and tags | -| `ContentWebhookEndpoint` | External webhook configurations | +### Dual API Authentication -### API Routes (routes/api.php) +All `/api/content/*` endpoints are registered twice in `routes/api.php`: +- Session auth (`middleware('auth')`) +- API key auth (`middleware(['api.auth', 'api.scope.enforce'])`) — uses `Authorization: Bearer hk_xxx` -- `/api/content/briefs` - Content brief CRUD -- `/api/content/generate/*` - AI generation (rate limited) -- `/api/content/media` - Media management -- `/api/content/search` - Full-text search -- `/api/content/webhooks/{endpoint}` - External webhooks (no auth, signature verified) +Webhook endpoints are public (no auth, signature-verified via HMAC). + +### Livewire Component Paths + +Livewire components live in `View/Modal/` (not the typical `Livewire/` directory): +- `View/Modal/Web/` — public-facing (Blog, Post, Help, HelpArticle, Preview) +- `View/Modal/Admin/` — admin (WebhookManager, ContentSearch) + +Blade templates are in `View/Blade/`, registered with the `content` view namespace. + +### Search Backends + +`ContentSearchService` supports three backends via `CONTENT_SEARCH_BACKEND`: +- `database` (default) — LIKE queries with relevance scoring (title > slug > excerpt > content) +- `scout_database` — Laravel Scout with database driver +- `meilisearch` — Laravel Scout with Meilisearch ## Conventions -- UK English (colour, organisation, centre) +- UK English (colour, organisation, centre, behaviour) - `declare(strict_types=1);` in all PHP files - Type hints on all parameters and return types +- Final classes by default unless inheritance is intended - Pest for testing (not PHPUnit syntax) -- Livewire + Flux Pro for UI components +- Livewire + Flux Pro for UI components (not vanilla Alpine) - Font Awesome Pro for icons (not Heroicons) -## Rate Limiters +### Naming -Defined in `Boot::configureRateLimiting()`: -- `content-generate` - AI generation (10/min authenticated) -- `content-briefs` - Brief creation (30/min) -- `content-webhooks` - Incoming webhooks (60/min per endpoint) -- `content-search` - Search queries (configurable, default 60/min) +| Type | Convention | Example | +|------|------------|---------| +| Model | Singular PascalCase | `ContentItem` | +| Table | Plural snake_case | `content_items` | +| Controller | `{Model}Controller` | `ContentBriefController` | +| Livewire Page | `{Feature}Page` | `ProductListPage` | +| Livewire Modal | `{Feature}Modal` | `EditProductModal` | + +### Don'ts + +- Don't create controllers for Livewire pages +- Don't use Heroicons (use Font Awesome Pro) +- Don't use vanilla Alpine components (use Flux Pro) +- Don't use American English spellings ## Configuration (config.php) -Key settings exposed via environment: -- `CONTENT_GENERATION_TIMEOUT` - AI generation timeout -- `CONTENT_MAX_REVISIONS` - Revision limit per item -- `CONTENT_SEARCH_BACKEND` - Search driver (database, scout_database, meilisearch) -- `CONTENT_CACHE_TTL` - Content cache duration +Key environment variables: +- `CONTENT_GENERATION_TIMEOUT` — AI generation timeout (default 300s) +- `CONTENT_MAX_REVISIONS` — Revision limit per item (default 50) +- `CONTENT_SEARCH_BACKEND` — Search driver (database, scout_database, meilisearch) +- `CONTENT_CACHE_TTL` — Content cache duration (default 3600s) +- `CONTENT_SEARCH_RATE_LIMIT` — Search rate limit per minute (default 60) ## License -EUPL-1.2 (European Union Public Licence) \ No newline at end of file +EUPL-1.2 (European Union Public Licence) diff --git a/Console/Commands/ContentImportWordPress.php b/Console/Commands/ContentImportWordPress.php index 0f4643c..48e7ea4 100644 --- a/Console/Commands/ContentImportWordPress.php +++ b/Console/Commands/ContentImportWordPress.php @@ -4,13 +4,13 @@ declare(strict_types=1); namespace Core\Mod\Content\Console\Commands; +use Carbon\Carbon; use Core\Mod\Content\Enums\ContentType; use Core\Mod\Content\Models\ContentAuthor; use Core\Mod\Content\Models\ContentItem; use Core\Mod\Content\Models\ContentMedia; use Core\Mod\Content\Models\ContentTaxonomy; use Core\Tenant\Models\Workspace; -use Carbon\Carbon; use Illuminate\Console\Command; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Storage; diff --git a/Console/Commands/ProcessPendingWebhooks.php b/Console/Commands/ProcessPendingWebhooks.php index 67be26e..65ac4f3 100644 --- a/Console/Commands/ProcessPendingWebhooks.php +++ b/Console/Commands/ProcessPendingWebhooks.php @@ -4,9 +4,9 @@ declare(strict_types=1); namespace Core\Mod\Content\Console\Commands; +use Core\Mod\Content\Services\WebhookRetryService; use Illuminate\Console\Command; use Illuminate\Support\Facades\Log; -use Core\Mod\Content\Services\WebhookRetryService; /** * ProcessPendingWebhooks diff --git a/Console/Commands/PruneContentRevisions.php b/Console/Commands/PruneContentRevisions.php index 966df45..ab2197d 100644 --- a/Console/Commands/PruneContentRevisions.php +++ b/Console/Commands/PruneContentRevisions.php @@ -4,8 +4,8 @@ declare(strict_types=1); namespace Core\Mod\Content\Console\Commands; -use Illuminate\Console\Command; use Core\Mod\Content\Models\ContentRevision; +use Illuminate\Console\Command; /** * Prune old content revisions based on retention policy. diff --git a/Console/Commands/PublishScheduledContent.php b/Console/Commands/PublishScheduledContent.php index b6a0998..49dd1bc 100644 --- a/Console/Commands/PublishScheduledContent.php +++ b/Console/Commands/PublishScheduledContent.php @@ -4,9 +4,9 @@ declare(strict_types=1); namespace Core\Mod\Content\Console\Commands; +use Core\Mod\Content\Models\ContentItem; use Illuminate\Console\Command; use Illuminate\Support\Facades\Log; -use Core\Mod\Content\Models\ContentItem; /** * PublishScheduledContent diff --git a/Controllers/Api/ContentBriefController.php b/Controllers/Api/ContentBriefController.php index db7fdef..43c765a 100644 --- a/Controllers/Api/ContentBriefController.php +++ b/Controllers/Api/ContentBriefController.php @@ -4,13 +4,13 @@ declare(strict_types=1); namespace Core\Mod\Content\Controllers\Api; -use Core\Front\Controller; -use Illuminate\Http\JsonResponse; -use Illuminate\Http\Request; use Core\Api\Concerns\HasApiResponses; use Core\Api\Concerns\ResolvesWorkspace; +use Core\Front\Controller; use Core\Mod\Content\Models\ContentBrief; use Core\Mod\Content\Resources\ContentBriefResource; +use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; /** * Content Brief API Controller diff --git a/Controllers/Api/ContentMediaController.php b/Controllers/Api/ContentMediaController.php index 3f15d6c..083b904 100644 --- a/Controllers/Api/ContentMediaController.php +++ b/Controllers/Api/ContentMediaController.php @@ -4,14 +4,14 @@ declare(strict_types=1); namespace Core\Mod\Content\Controllers\Api; -use Core\Front\Controller; use Core\Api\Concerns\HasApiResponses; use Core\Api\Concerns\ResolvesWorkspace; +use Core\Front\Controller; +use Core\Mod\Content\Models\ContentMedia; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; -use Core\Mod\Content\Models\ContentMedia; /** * Content Media API Controller diff --git a/Controllers/Api/ContentRevisionController.php b/Controllers/Api/ContentRevisionController.php index 989d0a7..ef1edc9 100644 --- a/Controllers/Api/ContentRevisionController.php +++ b/Controllers/Api/ContentRevisionController.php @@ -4,14 +4,14 @@ declare(strict_types=1); namespace Core\Mod\Content\Controllers\Api; -use Core\Front\Controller; use Core\Api\Concerns\HasApiResponses; use Core\Api\Concerns\ResolvesWorkspace; -use Illuminate\Http\JsonResponse; -use Illuminate\Http\Request; +use Core\Front\Controller; use Core\Mod\Content\Models\ContentItem; use Core\Mod\Content\Models\ContentRevision; use Core\Mod\Content\Resources\ContentRevisionResource; +use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; /** * Content Revision API Controller diff --git a/Controllers/Api/ContentSearchController.php b/Controllers/Api/ContentSearchController.php index cce16f2..659e7a2 100644 --- a/Controllers/Api/ContentSearchController.php +++ b/Controllers/Api/ContentSearchController.php @@ -4,12 +4,12 @@ declare(strict_types=1); namespace Core\Mod\Content\Controllers\Api; -use Core\Front\Controller; use Core\Api\Concerns\HasApiResponses; use Core\Api\Concerns\ResolvesWorkspace; +use Core\Front\Controller; +use Core\Mod\Content\Services\ContentSearchService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; -use Core\Mod\Content\Services\ContentSearchService; /** * Content Search API Controller diff --git a/Controllers/Api/ContentWebhookController.php b/Controllers/Api/ContentWebhookController.php index f6e20d1..c510517 100644 --- a/Controllers/Api/ContentWebhookController.php +++ b/Controllers/Api/ContentWebhookController.php @@ -4,14 +4,13 @@ declare(strict_types=1); namespace Core\Mod\Content\Controllers\Api; +use Core\Mod\Content\Jobs\ProcessContentWebhook; +use Core\Mod\Content\Models\ContentWebhookEndpoint; +use Core\Mod\Content\Services\WebhookDeliveryLogger; use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\Routing\Controller; use Illuminate\Support\Facades\Log; -use Core\Mod\Content\Jobs\ProcessContentWebhook; -use Core\Mod\Content\Models\ContentWebhookEndpoint; -use Core\Mod\Content\Models\ContentWebhookLog; -use Core\Mod\Content\Services\WebhookDeliveryLogger; /** * Controller for receiving external content webhooks. @@ -29,8 +28,7 @@ class ContentWebhookController extends Controller { public function __construct( protected WebhookDeliveryLogger $deliveryLogger - ) { - } + ) {} /** * Receive a webhook from an external source. @@ -295,5 +293,4 @@ class ContentWebhookController extends Controller return 'wordpress.post_updated'; } - } diff --git a/Controllers/Api/GenerationController.php b/Controllers/Api/GenerationController.php index 869ca65..a4bc0f2 100644 --- a/Controllers/Api/GenerationController.php +++ b/Controllers/Api/GenerationController.php @@ -4,16 +4,16 @@ declare(strict_types=1); namespace Core\Mod\Content\Controllers\Api; -use Core\Front\Controller; -use Illuminate\Http\JsonResponse; -use Illuminate\Http\Request; use Core\Api\Concerns\HasApiResponses; use Core\Api\Concerns\ResolvesWorkspace; +use Core\Front\Controller; use Core\Mod\Content\Jobs\GenerateContentJob; use Core\Mod\Content\Models\AIUsage; use Core\Mod\Content\Models\ContentBrief; use Core\Mod\Content\Resources\ContentBriefResource; use Core\Mod\Content\Services\AIGatewayService; +use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; /** * Content Generation API Controller diff --git a/Controllers/ContentPreviewController.php b/Controllers/ContentPreviewController.php index ac69f6f..1c30b22 100644 --- a/Controllers/ContentPreviewController.php +++ b/Controllers/ContentPreviewController.php @@ -4,11 +4,11 @@ declare(strict_types=1); namespace Core\Mod\Content\Controllers; +use Core\Mod\Content\Models\ContentItem; use Core\Tenant\Models\Workspace; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Routing\Controller; -use Core\Mod\Content\Models\ContentItem; /** * ContentPreviewController - Preview draft content before publishing. diff --git a/Database/Factories/ContentBriefFactory.php b/Database/Factories/ContentBriefFactory.php new file mode 100644 index 0000000..3459f8c --- /dev/null +++ b/Database/Factories/ContentBriefFactory.php @@ -0,0 +1,24 @@ + Workspace::factory(), + 'title' => $this->faker->sentence(), + 'description' => $this->faker->paragraph(), + 'status' => ContentBrief::STATUS_PENDING, + ]; + } +} diff --git a/Database/Factories/ContentItemFactory.php b/Database/Factories/ContentItemFactory.php new file mode 100644 index 0000000..0a31355 --- /dev/null +++ b/Database/Factories/ContentItemFactory.php @@ -0,0 +1,29 @@ + Workspace::factory(), + 'content_type' => ContentType::NATIVE->value, + 'type' => 'post', + 'status' => 'publish', + 'slug' => $this->faker->slug(), + 'title' => $this->faker->sentence(), + 'excerpt' => $this->faker->paragraph(), + 'content_html' => '
'.$this->faker->paragraphs(3, true).'
', + ]; + } +} diff --git a/Database/Factories/ContentTaxonomyFactory.php b/Database/Factories/ContentTaxonomyFactory.php new file mode 100644 index 0000000..d8f5d79 --- /dev/null +++ b/Database/Factories/ContentTaxonomyFactory.php @@ -0,0 +1,25 @@ + Workspace::factory(), + 'type' => $this->faker->randomElement(['category', 'tag']), + 'name' => $this->faker->word(), + 'slug' => $this->faker->slug(), + 'count' => $this->faker->numberBetween(0, 100), + ]; + } +} diff --git a/Database/Factories/ContentWebhookEndpointFactory.php b/Database/Factories/ContentWebhookEndpointFactory.php new file mode 100644 index 0000000..c3e1582 --- /dev/null +++ b/Database/Factories/ContentWebhookEndpointFactory.php @@ -0,0 +1,42 @@ + Workspace::factory(), + 'name' => $this->faker->company(), + 'secret' => $this->faker->sha256(), + 'require_signature' => true, + 'allowed_types' => [], + 'is_enabled' => true, + 'failure_count' => 0, + ]; + } + + public function circuitBroken(): static + { + return $this->state([ + 'failure_count' => ContentWebhookEndpoint::MAX_FAILURES, + 'is_enabled' => false, + ]); + } + + public function disabled(): static + { + return $this->state([ + 'is_enabled' => false, + ]); + } +} diff --git a/Database/Factories/ContentWebhookLogFactory.php b/Database/Factories/ContentWebhookLogFactory.php new file mode 100644 index 0000000..30d2dc7 --- /dev/null +++ b/Database/Factories/ContentWebhookLogFactory.php @@ -0,0 +1,31 @@ + Workspace::factory(), + 'endpoint_id' => ContentWebhookEndpoint::factory(), + 'event_type' => $this->faker->randomElement([ + 'wordpress.post_created', + 'wordpress.post_updated', + 'cms.content_created', + ]), + 'payload' => ['title' => $this->faker->sentence()], + 'status' => 'pending', + 'source_ip' => $this->faker->ipv4(), + ]; + } +} diff --git a/Jobs/GenerateContentJob.php b/Jobs/GenerateContentJob.php index be24fe0..e2e6449 100644 --- a/Jobs/GenerateContentJob.php +++ b/Jobs/GenerateContentJob.php @@ -4,14 +4,14 @@ declare(strict_types=1); namespace Core\Mod\Content\Jobs; +use Core\Mod\Content\Models\ContentBrief; +use Core\Mod\Content\Services\AIGatewayService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Log; -use Core\Mod\Content\Models\ContentBrief; -use Core\Mod\Content\Services\AIGatewayService; /** * GenerateContentJob diff --git a/Jobs/ProcessContentWebhook.php b/Jobs/ProcessContentWebhook.php index 248b144..db9f52e 100644 --- a/Jobs/ProcessContentWebhook.php +++ b/Jobs/ProcessContentWebhook.php @@ -4,18 +4,18 @@ declare(strict_types=1); namespace Core\Mod\Content\Jobs; -use Illuminate\Bus\Queueable; -use Illuminate\Contracts\Queue\ShouldQueue; -use Illuminate\Foundation\Bus\Dispatchable; -use Illuminate\Queue\InteractsWithQueue; -use Illuminate\Queue\SerializesModels; -use Illuminate\Support\Facades\Log; use Core\Mod\Content\Enums\ContentType; use Core\Mod\Content\Models\ContentItem; use Core\Mod\Content\Models\ContentMedia; use Core\Mod\Content\Models\ContentTaxonomy; use Core\Mod\Content\Models\ContentWebhookEndpoint; use Core\Mod\Content\Models\ContentWebhookLog; +use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldQueue; +use Illuminate\Foundation\Bus\Dispatchable; +use Illuminate\Queue\InteractsWithQueue; +use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Facades\Log; /** * Process incoming content webhooks. diff --git a/Mcp/Handlers/ContentCreateHandler.php b/Mcp/Handlers/ContentCreateHandler.php index 1dd61d5..226ff15 100644 --- a/Mcp/Handlers/ContentCreateHandler.php +++ b/Mcp/Handlers/ContentCreateHandler.php @@ -7,14 +7,14 @@ namespace Core\Mod\Content\Mcp\Handlers; use Carbon\Carbon; use Core\Front\Mcp\Contracts\McpToolHandler; use Core\Front\Mcp\McpContext; -use Core\Tenant\Models\Workspace; -use Core\Tenant\Services\EntitlementService; -use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Str; use Core\Mod\Content\Enums\ContentType; use Core\Mod\Content\Models\ContentItem; use Core\Mod\Content\Models\ContentRevision; use Core\Mod\Content\Models\ContentTaxonomy; +use Core\Tenant\Models\Workspace; +use Core\Tenant\Services\EntitlementService; +use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Str; /** * MCP tool handler for creating content items. diff --git a/Mcp/Handlers/ContentDeleteHandler.php b/Mcp/Handlers/ContentDeleteHandler.php index 2ac4b9c..151357f 100644 --- a/Mcp/Handlers/ContentDeleteHandler.php +++ b/Mcp/Handlers/ContentDeleteHandler.php @@ -6,10 +6,10 @@ namespace Core\Mod\Content\Mcp\Handlers; use Core\Front\Mcp\Contracts\McpToolHandler; use Core\Front\Mcp\McpContext; -use Core\Tenant\Models\Workspace; -use Illuminate\Support\Facades\Auth; use Core\Mod\Content\Models\ContentItem; use Core\Mod\Content\Models\ContentRevision; +use Core\Tenant\Models\Workspace; +use Illuminate\Support\Facades\Auth; /** * MCP tool handler for deleting content items. diff --git a/Mcp/Handlers/ContentListHandler.php b/Mcp/Handlers/ContentListHandler.php index 72172f6..7274c73 100644 --- a/Mcp/Handlers/ContentListHandler.php +++ b/Mcp/Handlers/ContentListHandler.php @@ -6,9 +6,9 @@ namespace Core\Mod\Content\Mcp\Handlers; use Core\Front\Mcp\Contracts\McpToolHandler; use Core\Front\Mcp\McpContext; +use Core\Mod\Content\Models\ContentItem; use Core\Tenant\Models\Workspace; use Illuminate\Support\Str; -use Core\Mod\Content\Models\ContentItem; /** * MCP tool handler for listing content items. diff --git a/Mcp/Handlers/ContentReadHandler.php b/Mcp/Handlers/ContentReadHandler.php index 3c53139..3978ba2 100644 --- a/Mcp/Handlers/ContentReadHandler.php +++ b/Mcp/Handlers/ContentReadHandler.php @@ -6,8 +6,8 @@ namespace Core\Mod\Content\Mcp\Handlers; use Core\Front\Mcp\Contracts\McpToolHandler; use Core\Front\Mcp\McpContext; -use Core\Tenant\Models\Workspace; use Core\Mod\Content\Models\ContentItem; +use Core\Tenant\Models\Workspace; /** * MCP tool handler for reading content items. diff --git a/Mcp/Handlers/ContentSearchHandler.php b/Mcp/Handlers/ContentSearchHandler.php index 9b499fe..014a415 100644 --- a/Mcp/Handlers/ContentSearchHandler.php +++ b/Mcp/Handlers/ContentSearchHandler.php @@ -6,9 +6,9 @@ namespace Core\Mod\Content\Mcp\Handlers; use Core\Front\Mcp\Contracts\McpToolHandler; use Core\Front\Mcp\McpContext; +use Core\Mod\Content\Services\ContentSearchService; use Core\Tenant\Models\Workspace; use Illuminate\Support\Str; -use Core\Mod\Content\Services\ContentSearchService; /** * MCP tool handler for searching content. diff --git a/Mcp/Handlers/ContentTaxonomiesHandler.php b/Mcp/Handlers/ContentTaxonomiesHandler.php index 71d4528..b076f53 100644 --- a/Mcp/Handlers/ContentTaxonomiesHandler.php +++ b/Mcp/Handlers/ContentTaxonomiesHandler.php @@ -6,8 +6,8 @@ namespace Core\Mod\Content\Mcp\Handlers; use Core\Front\Mcp\Contracts\McpToolHandler; use Core\Front\Mcp\McpContext; -use Core\Tenant\Models\Workspace; use Core\Mod\Content\Models\ContentTaxonomy; +use Core\Tenant\Models\Workspace; /** * MCP tool handler for listing content taxonomies. diff --git a/Mcp/Handlers/ContentUpdateHandler.php b/Mcp/Handlers/ContentUpdateHandler.php index 792673b..895c7cf 100644 --- a/Mcp/Handlers/ContentUpdateHandler.php +++ b/Mcp/Handlers/ContentUpdateHandler.php @@ -7,12 +7,12 @@ namespace Core\Mod\Content\Mcp\Handlers; use Carbon\Carbon; use Core\Front\Mcp\Contracts\McpToolHandler; use Core\Front\Mcp\McpContext; -use Core\Tenant\Models\Workspace; -use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Str; use Core\Mod\Content\Models\ContentItem; use Core\Mod\Content\Models\ContentRevision; use Core\Mod\Content\Models\ContentTaxonomy; +use Core\Tenant\Models\Workspace; +use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Str; /** * MCP tool handler for updating content items. diff --git a/Middleware/WorkspaceRouter.php b/Middleware/WorkspaceRouter.php index 1844360..316a84d 100644 --- a/Middleware/WorkspaceRouter.php +++ b/Middleware/WorkspaceRouter.php @@ -4,9 +4,9 @@ declare(strict_types=1); namespace Core\Mod\Content\Middleware; +use Closure; use Core\Mod\Content\Services\ContentRender; use Core\Tenant\Models\Workspace; -use Closure; use Illuminate\Http\Request; use Symfony\Component\HttpFoundation\Response; diff --git a/Models/AIUsage.php b/Models/AIUsage.php index 8f70572..7a8fffd 100644 --- a/Models/AIUsage.php +++ b/Models/AIUsage.php @@ -4,11 +4,11 @@ declare(strict_types=1); namespace Core\Mod\Content\Models; +use Core\Tenant\Models\Workspace; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\MorphTo; -use Core\Tenant\Models\Workspace; /** * AIUsage Model diff --git a/Models/ContentBrief.php b/Models/ContentBrief.php index 185c00d..549e4eb 100644 --- a/Models/ContentBrief.php +++ b/Models/ContentBrief.php @@ -4,12 +4,12 @@ declare(strict_types=1); namespace Core\Mod\Content\Models; +use Core\Mod\Content\Enums\BriefContentType; +use Core\Tenant\Models\Workspace; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; -use Core\Mod\Content\Enums\BriefContentType; -use Core\Tenant\Models\Workspace; /** * ContentBrief Model diff --git a/Models/ContentItem.php b/Models/ContentItem.php index 9effd29..1cf4fa4 100644 --- a/Models/ContentItem.php +++ b/Models/ContentItem.php @@ -4,9 +4,12 @@ declare(strict_types=1); namespace Core\Mod\Content\Models; +use Core\Mod\Content\Enums\ContentType; +use Core\Mod\Content\Observers\ContentItemObserver; +use Core\Mod\Content\Services\HtmlSanitiser; +use Core\Seo\HasSeoMetadata; use Core\Tenant\Models\User; use Core\Tenant\Models\Workspace; -use Core\Seo\HasSeoMetadata; use Illuminate\Database\Eloquent\Attributes\ObservedBy; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -14,9 +17,6 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; -use Core\Mod\Content\Enums\ContentType; -use Core\Mod\Content\Observers\ContentItemObserver; -use Core\Mod\Content\Services\HtmlSanitiser; #[ObservedBy([ContentItemObserver::class])] class ContentItem extends Model diff --git a/Models/ContentWebhookEndpoint.php b/Models/ContentWebhookEndpoint.php index 3333acc..669416d 100644 --- a/Models/ContentWebhookEndpoint.php +++ b/Models/ContentWebhookEndpoint.php @@ -377,14 +377,14 @@ class ContentWebhookEndpoint extends Model */ public function getStatusColorAttribute(): string { - if (! $this->is_enabled) { - return 'zinc'; - } - if ($this->isCircuitBroken()) { return 'red'; } + if (! $this->is_enabled) { + return 'zinc'; + } + if ($this->failure_count > 0) { return 'yellow'; } @@ -397,14 +397,14 @@ class ContentWebhookEndpoint extends Model */ public function getStatusLabelAttribute(): string { - if (! $this->is_enabled) { - return 'Disabled'; - } - if ($this->isCircuitBroken()) { return 'Circuit Open'; } + if (! $this->is_enabled) { + return 'Disabled'; + } + if ($this->failure_count > 0) { return "Active ({$this->failure_count} failures)"; } diff --git a/Observers/ContentItemObserver.php b/Observers/ContentItemObserver.php index 223f255..ddfd053 100644 --- a/Observers/ContentItemObserver.php +++ b/Observers/ContentItemObserver.php @@ -4,9 +4,9 @@ declare(strict_types=1); namespace Core\Mod\Content\Observers; -use Illuminate\Support\Facades\Log; use Core\Mod\Content\Models\ContentItem; use Core\Mod\Content\Services\CdnPurgeService; +use Illuminate\Support\Facades\Log; /** * Content Item Observer - handles CDN cache purging on content changes. diff --git a/Services/CdnPurgeService.php b/Services/CdnPurgeService.php index 7ae107c..0785aa6 100644 --- a/Services/CdnPurgeService.php +++ b/Services/CdnPurgeService.php @@ -4,8 +4,8 @@ declare(strict_types=1); namespace Core\Mod\Content\Services; -use Illuminate\Support\Facades\Log; use Core\Mod\Content\Models\ContentItem; +use Illuminate\Support\Facades\Log; use Plug\Cdn\CdnManager; use Plug\Response; diff --git a/Services/ContentRender.php b/Services/ContentRender.php index 56617aa..44c110a 100644 --- a/Services/ContentRender.php +++ b/Services/ContentRender.php @@ -5,12 +5,12 @@ declare(strict_types=1); namespace Core\Mod\Content\Services; use Core\Front\Controller; +use Core\Mod\Content\Models\ContentItem; +use Core\Tenant\Models\Workspace; use Illuminate\Http\Request; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; use Illuminate\View\View; -use Core\Mod\Content\Models\ContentItem; -use Core\Tenant\Models\Workspace; /** * ContentRender - Public workspace frontend renderer. diff --git a/Services/ContentSearchService.php b/Services/ContentSearchService.php index 241d0a9..a7f72e0 100644 --- a/Services/ContentSearchService.php +++ b/Services/ContentSearchService.php @@ -5,12 +5,12 @@ declare(strict_types=1); namespace Core\Mod\Content\Services; use Carbon\Carbon; +use Core\Mod\Content\Models\ContentItem; use Core\Tenant\Models\Workspace; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Collection; use Illuminate\Support\Str; -use Core\Mod\Content\Models\ContentItem; /** * Content Search Service diff --git a/Services/HtmlSanitiser.php b/Services/HtmlSanitiser.php index 301e225..8a8bdfe 100644 --- a/Services/HtmlSanitiser.php +++ b/Services/HtmlSanitiser.php @@ -97,13 +97,8 @@ class HtmlSanitiser $config->set('HTML.Nofollow', true); $config->set('HTML.TargetNoopener', true); - // Disable cache in development, enable via config in production - $cacheDir = config('content.purifier_cache_dir'); - if ($cacheDir && is_dir($cacheDir) && is_writable($cacheDir)) { - $config->set('Cache.SerializerPath', $cacheDir); - } else { - $config->set('Cache.DefinitionImpl', null); - } + // Allow id attributes (disabled by default in HTMLPurifier) + $config->set('Attr.EnableID', true); // Safe URI schemes only $config->set('URI.AllowedSchemes', [ @@ -117,6 +112,22 @@ class HtmlSanitiser $config->set('URI.DisableExternalResources', false); $config->set('URI.DisableResources', false); + // Disable cache and set definition ID for custom HTML definitions + $config->set('Cache.DefinitionImpl', null); + $config->set('HTML.DefinitionID', 'core-content-html5'); + $config->set('HTML.DefinitionRev', 1); + + // Register HTML5 elements that HTMLPurifier doesn't know about + // NOTE: maybeGetRawHTMLDefinition() finalizes the config — all + // $config->set() calls must come before this point. + if ($def = $config->maybeGetRawHTMLDefinition()) { + $def->addElement('section', 'Block', 'Flow', 'Common'); + $def->addElement('article', 'Block', 'Flow', 'Common'); + $def->addElement('figure', 'Block', 'Flow', 'Common'); + $def->addElement('figcaption', 'Inline', 'Flow', 'Common'); + $def->addElement('mark', 'Inline', 'Inline', 'Common'); + } + $this->purifier = new HTMLPurifier($config); } diff --git a/Services/WebhookDeliveryLogger.php b/Services/WebhookDeliveryLogger.php index d3db5c6..413a7eb 100644 --- a/Services/WebhookDeliveryLogger.php +++ b/Services/WebhookDeliveryLogger.php @@ -4,10 +4,10 @@ declare(strict_types=1); namespace Core\Mod\Content\Services; -use Illuminate\Http\Request; -use Illuminate\Support\Facades\Log; use Core\Mod\Content\Models\ContentWebhookEndpoint; use Core\Mod\Content\Models\ContentWebhookLog; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\Log; /** * WebhookDeliveryLogger @@ -43,7 +43,6 @@ class WebhookDeliveryLogger * @param int $durationMs Processing duration in milliseconds * @param int|null $responseCode HTTP response code if applicable * @param string|null $responseBody Response body if applicable - * @return void */ public function logSuccess( ContentWebhookLog $webhookLog, @@ -74,7 +73,6 @@ class WebhookDeliveryLogger * @param int $durationMs Processing duration in milliseconds * @param int|null $responseCode HTTP response code if applicable * @param string|null $responseBody Response body if applicable - * @return void */ public function logFailure( ContentWebhookLog $webhookLog, @@ -128,7 +126,7 @@ class WebhookDeliveryLogger 'source_ip' => $request->ip(), 'signature_verified' => false, 'signature_failure_reason' => $failureReason, - 'error_message' => 'Signature verification failed: ' . $failureReason, + 'error_message' => 'Signature verification failed: '.$failureReason, 'processed_at' => now(), ]); @@ -149,7 +147,6 @@ class WebhookDeliveryLogger * * @param ContentWebhookLog $webhookLog The webhook log entry * @param string $verificationMethod How the signature was verified (e.g., 'current_secret', 'grace_period') - * @return void */ public function logSignatureSuccess( ContentWebhookLog $webhookLog, @@ -172,7 +169,6 @@ class WebhookDeliveryLogger * * @param Request $request The incoming request * @param ContentWebhookEndpoint $endpoint The webhook endpoint - * @return void */ public function logSignatureNotRequired( Request $request, @@ -195,7 +191,6 @@ class WebhookDeliveryLogger * @param array $payload The parsed payload * @param string $eventType The determined event type * @param array $verificationResult The signature verification result - * @return ContentWebhookLog */ public function createDeliveryLog( Request $request, @@ -225,7 +220,6 @@ class WebhookDeliveryLogger * @param ContentWebhookLog $webhookLog The webhook log entry * @param int $durationMs Processing duration in milliseconds * @param array|null $result Processing result details - * @return void */ public function recordProcessingMetrics( ContentWebhookLog $webhookLog, @@ -341,7 +335,6 @@ class WebhookDeliveryLogger * Extract content ID from payload. * * @param array $data The webhook payload - * @return int|null */ protected function extractContentId(array $data): ?int { @@ -364,7 +357,6 @@ class WebhookDeliveryLogger * Extract content type from payload. * * @param array $data The webhook payload - * @return string|null */ protected function extractContentType(array $data): ?string { diff --git a/Services/WebhookRetryService.php b/Services/WebhookRetryService.php index 886c507..8c8aba8 100644 --- a/Services/WebhookRetryService.php +++ b/Services/WebhookRetryService.php @@ -4,9 +4,9 @@ declare(strict_types=1); namespace Core\Mod\Content\Services; +use Core\Mod\Content\Models\ContentWebhookLog; use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Facades\Log; -use Core\Mod\Content\Models\ContentWebhookLog; /** * WebhookRetryService diff --git a/View/Modal/Admin/ContentSearch.php b/View/Modal/Admin/ContentSearch.php index 9c6a5fb..dc32a0a 100644 --- a/View/Modal/Admin/ContentSearch.php +++ b/View/Modal/Admin/ContentSearch.php @@ -4,14 +4,14 @@ declare(strict_types=1); namespace Core\Mod\Content\View\Modal\Admin; +use Core\Mod\Content\Models\ContentItem; +use Core\Mod\Content\Models\ContentTaxonomy; +use Core\Mod\Content\Services\ContentSearchService; use Livewire\Attributes\Computed; use Livewire\Attributes\Layout; use Livewire\Attributes\Url; use Livewire\Component; use Livewire\WithPagination; -use Core\Mod\Content\Models\ContentItem; -use Core\Mod\Content\Models\ContentTaxonomy; -use Core\Mod\Content\Services\ContentSearchService; /** * Content Search Livewire Component diff --git a/View/Modal/Admin/WebhookManager.php b/View/Modal/Admin/WebhookManager.php index 93987a8..a6e0a3a 100644 --- a/View/Modal/Admin/WebhookManager.php +++ b/View/Modal/Admin/WebhookManager.php @@ -4,13 +4,13 @@ declare(strict_types=1); namespace Core\Mod\Content\View\Modal\Admin; +use Core\Mod\Content\Models\ContentWebhookEndpoint; +use Core\Mod\Content\Models\ContentWebhookLog; use Livewire\Attributes\Computed; use Livewire\Attributes\Layout; use Livewire\Attributes\Url; use Livewire\Component; use Livewire\WithPagination; -use Core\Mod\Content\Models\ContentWebhookEndpoint; -use Core\Mod\Content\Models\ContentWebhookLog; /** * Livewire component for managing content webhook endpoints. diff --git a/View/Modal/Web/Blog.php b/View/Modal/Web/Blog.php index 2011612..a9a8d70 100644 --- a/View/Modal/Web/Blog.php +++ b/View/Modal/Web/Blog.php @@ -4,10 +4,10 @@ declare(strict_types=1); namespace Core\Mod\Content\View\Modal\Web; -use Livewire\Component; use Core\Mod\Content\Services\ContentRender; use Core\Tenant\Models\Workspace; use Core\Tenant\Services\WorkspaceService; +use Livewire\Component; class Blog extends Component { diff --git a/View/Modal/Web/Help.php b/View/Modal/Web/Help.php index 9242a74..d978dd4 100644 --- a/View/Modal/Web/Help.php +++ b/View/Modal/Web/Help.php @@ -4,10 +4,10 @@ declare(strict_types=1); namespace Core\Mod\Content\View\Modal\Web; -use Livewire\Component; use Core\Mod\Content\Models\ContentItem; use Core\Tenant\Models\Workspace; use Core\Tenant\Services\WorkspaceService; +use Livewire\Component; class Help extends Component { diff --git a/View/Modal/Web/HelpArticle.php b/View/Modal/Web/HelpArticle.php index 76020cc..593f67c 100644 --- a/View/Modal/Web/HelpArticle.php +++ b/View/Modal/Web/HelpArticle.php @@ -4,10 +4,10 @@ declare(strict_types=1); namespace Core\Mod\Content\View\Modal\Web; -use Livewire\Component; use Core\Mod\Content\Services\ContentRender; use Core\Tenant\Models\Workspace; use Core\Tenant\Services\WorkspaceService; +use Livewire\Component; class HelpArticle extends Component { diff --git a/View/Modal/Web/Post.php b/View/Modal/Web/Post.php index f32dfc9..48f6868 100644 --- a/View/Modal/Web/Post.php +++ b/View/Modal/Web/Post.php @@ -4,10 +4,10 @@ declare(strict_types=1); namespace Core\Mod\Content\View\Modal\Web; -use Livewire\Component; use Core\Mod\Content\Services\ContentRender; use Core\Tenant\Models\Workspace; use Core\Tenant\Services\WorkspaceService; +use Livewire\Component; class Post extends Component { diff --git a/View/Modal/Web/Preview.php b/View/Modal/Web/Preview.php index 3e335ab..5143a08 100644 --- a/View/Modal/Web/Preview.php +++ b/View/Modal/Web/Preview.php @@ -4,9 +4,9 @@ declare(strict_types=1); namespace Core\Mod\Content\View\Modal\Web; +use Core\Mod\Content\Models\ContentItem; use Core\Tenant\Models\Workspace; use Livewire\Component; -use Core\Mod\Content\Models\ContentItem; /** * Preview - Render draft/unpublished content with preview token. diff --git a/composer.json b/composer.json index fe81244..8530ebf 100644 --- a/composer.json +++ b/composer.json @@ -1,17 +1,23 @@ { - "name": "host-uk/core-content", + "name": "lthn/php-content", "description": "Content management and headless CMS for Laravel", - "keywords": ["laravel", "content", "cms", "headless"], + "keywords": [ + "laravel", + "content", + "cms", + "headless" + ], "license": "EUPL-1.2", "require": { "php": "^8.2", - "host-uk/core": "dev-main", + "lthn/php": "*", "ezyang/htmlpurifier": "^4.17" }, "require-dev": { "laravel/pint": "^1.18", "orchestra/testbench": "^9.0|^10.0", - "pestphp/pest": "^3.0" + "pestphp/pest": "^3.0", + "core/php-tenant": "@dev" }, "autoload": { "psr-4": { @@ -20,7 +26,8 @@ }, "autoload-dev": { "psr-4": { - "Core\\Mod\\Content\\Tests\\": "Tests/" + "Core\\Mod\\Content\\Tests\\": "Tests/", + "Tests\\": "tests/" } }, "extra": { @@ -41,5 +48,15 @@ } }, "minimum-stability": "dev", - "prefer-stable": true + "prefer-stable": true, + "repositories": [ + { + "name": "core", + "type": "path", + "url": "../php-framework" + } + ], + "replace": { + "core/php-content": "self.version" + } } diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..cb23fe0 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,39 @@ + +