diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 00000000..9c84c0a2 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,71 @@ +name: Docs + +on: + push: + branches: [main] + paths: + - 'src/**' + - 'docs/**' + - 'bin/**' + - 'phpdoc.dist.xml' + - 'package.json' + workflow_dispatch: + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + permissions: + contents: write + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-php@v4 + with: + php-version: 8.2 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'npm' + + - name: Install phpDocumentor + run: | + wget -q https://github.com/phpDocumentor/phpDocumentor/releases/download/v3.10.0/phpDocumentor.phar + chmod +x phpDocumentor.phar + + - name: Install PHP dependencies + run: composer install --no-interaction --no-progress + + - name: Generate API docs + run: php phpDocumentor.phar -c phpdoc.dist.xml + + - name: Generate MCP docs + run: composer docs:mcp + + - name: Install Node dependencies + run: npm ci + + - name: Build VitePress site + run: npm run docs:build + + - name: Setup Pages + uses: actions/configure-pages@v4 + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: docs/.vitepress/dist + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index bfcb0c89..77d79b16 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,11 @@ # dev vendor/ +node_modules/ + +# docs +docs/public/api/ +docs/.vitepress/dist/ +build/phpdoc-cache/ # editor/OS files .DS_Store diff --git a/README.md b/README.md index 34010bb7..dee060b6 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ Saltus Framework helps you develop WordPress plugins that are based on Custom Po We built it to make things easier and faster for developers with different skills. Add metaboxes, settings pages and other enhancements with just a few lines of code. -Visit saltus.dev for more information. +Visit [saltus.dev](https://saltus.dev) for more information. Full documentation is available at [docs.saltus.dev](https://docs.saltus.dev). ## Version @@ -42,7 +42,7 @@ composer require saltus/framework ### Demo -Refer to the [Framework Demo](https://github.com/SaltusDev/framework-demo) for a complete plugin example and to the [Wiki](https://github.com/SaltusDev/saltus-framework/wiki) for complete documentation. +Refer to the [Framework Demo](https://github.com/SaltusDev/framework-demo) for a complete plugin example and to the [documentation site](https://docs.saltus.dev) for complete documentation. Once the framework is installed and Composer's autoloader is loaded by your plugin, you can initialize it the following way: @@ -187,7 +187,7 @@ Includes support for [github-updater](https://github.com/afragen/github-updater) Saltus Framework exposes its AI-facing tool surface through the WordPress-native MCP/Abilities API. Native WordPress MCP clients can discover and call the `saltus/*` abilities directly from the active plugin. -For full documentation, see [docs/MCP.md](docs/MCP.md). For client integration guidance, see [docs/MCP-CLIENTS.md](docs/MCP-CLIENTS.md). These pages are source material for the future Saltus MCP documentation site. +For full documentation, see [docs/MCP.md](docs/MCP.md) or the [MCP section](https://docs.saltus.dev/mcp/) of the documentation site. For client integration guidance, see [docs/MCP-CLIENTS.md](docs/MCP-CLIENTS.md) or the [client guide](https://docs.saltus.dev/mcp/clients). ### Quick Start diff --git a/bin/generate-mcp-docs.php b/bin/generate-mcp-docs.php index d83591cf..a193d485 100644 --- a/bin/generate-mcp-docs.php +++ b/bin/generate-mcp-docs.php @@ -16,6 +16,12 @@ $root = dirname( __DIR__ ); require_once $root . '/vendor/autoload.php'; +if ( ! function_exists( 'apply_filters' ) ) { + function apply_filters( string $hook_name, $value, ...$args ) { + return $value; + } +} + if ( ! class_exists( 'WP_REST_Request' ) ) { class WP_REST_Request { private string $method; diff --git a/bin/generate-wpcli-docs.php b/bin/generate-wpcli-docs.php new file mode 100644 index 00000000..eb6b1e21 --- /dev/null +++ b/bin/generate-wpcli-docs.php @@ -0,0 +1,16 @@ +#!/usr/bin/env php + -Saltus Framework exposes 17 WordPress-native MCP/Abilities tools. +Saltus Framework exposes 18 WordPress-native MCP/Abilities tools. | Tool | Ability | REST request | Description | |------|---------|--------------|-------------| @@ -21,6 +21,7 @@ Saltus Framework exposes 17 WordPress-native MCP/Abilities tools. | `list_posts` | `saltus/list-posts` | `GET /wp/v2/posts` | Query posts from a Custom Post Type with optional filters | | `list_terms` | `saltus/list-terms` | `GET /wp/v2/{taxonomy_rest_base}` | List terms from a taxonomy (categories, tags, or custom taxonomies) | | `reorder_posts` | `saltus/reorder-posts` | `POST /saltus-framework/v1/reorder` | Reorder multiple posts by updating their menu_order values in a single batch operation | +| `update_meta_fields` | `saltus/update-meta-fields` | `PUT /saltus-framework/v1/meta/{post_type}/123` | Update meta fields for a specific post of a registered Saltus post type | | `update_post` | `saltus/update-post` | `PUT /wp/v2/posts/123` | Update an existing post's fields and meta data | | `update_settings` | `saltus/update-settings` | `PUT /saltus-framework/v1/settings/{post_type}` | Update the Saltus Framework settings for a specific post type | @@ -284,6 +285,24 @@ Reorder multiple posts by updating their menu_order values in a single batch ope |-----------|------|----------|---------|-------------| | `items` | `array` | yes | | Array of objects with "id" (post ID) and "menu_order" (integer position) | +## `update_meta_fields` + +Update meta fields for a specific post of a registered Saltus post type + +- Ability: `saltus/update-meta-fields` +- REST request: `PUT /saltus-framework/v1/meta/{post_type}/123` +- REST capability: `meta (post_type)` +- Cacheable: `no` +- Cache TTL: `n/a` + +### Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `post_id` | `number` | yes | | The post ID to update meta fields for | +| `post_type` | `string` | yes | | The post type slug | +| `meta` | `object` | yes | | Meta fields to update as key-value pairs | + ## `update_post` Update an existing post's fields and meta data diff --git a/docs/MCP.md b/docs/MCP.md index 3abc6e2e..92ef6b13 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -11,7 +11,7 @@ For client implementation guidance, see [MCP-CLIENTS.md](MCP-CLIENTS.md). For th - Supported path: WordPress-native MCP/Abilities - Standalone stdio server: removed - SSE transport: out of scope -- Current ability count: 17 +- Current ability count: 18 - REST namespace: `saltus-framework/v1` - Ability namespace: `saltus/*` @@ -109,9 +109,38 @@ Saltus reuses WordPress capability checks such as: | Settings updates | `manage_options` | | Term creation | taxonomy edit/manage capability | -REST routes are also gated by model configuration. For model-scoped Saltus REST/MCP features, set `saltus_rest` in the model options. +REST routes and MCP tools are gated by model configuration. -Enable all Saltus REST-backed capabilities for a model: +### Master Options (Model Level) + +At the model level, two master options in the `options` array control access: +- **`show_in_rest`**: Controls whether model-scoped REST routes are registered. If explicitly set to `false`, all model-scoped REST capabilities for the model are disabled. It does not control whether the model's MCP tools are generated/shown (which is managed by `mcp_tools` and `show_in_mcp`), although calling those MCP tools will fail if the underlying REST route is disabled. Defaults to `true` (if omitted or not `false`). +- **`mcp_tools`**: Must be set and truthy (e.g., `true`) in model options to enable any MCP tools for that model. + +The framework-scoped health capability (`health` ability / REST route) is independent of per-model opt-in and is always available. The `models` capability is always enabled for a model as long as its `show_in_rest` is not `false` (or always, for MCP, if `mcp_tools` is enabled). + +### Feature-Level Gating + +Each individual framework capability can be gated in the model's `config` array. They map to specific configuration sections: +- **Meta (`meta`):** `'meta'` key (root level of `config`) +- **Settings (`settings`):** `'settings'` key (root level of `config`) +- **Duplicate (`duplicate`):** `'duplicate'` key (nested under `config.features.duplicate`) +- **Export (`export`):** `'single_export'` key (nested under `config.features.single_export`) +- **Reorder (`reorder`):** `'drag_and_drop'` key (nested under `config.features.drag_and_drop`) + +### Resolution Rules + +For each feature/capability configuration section: +1. **Omitted (Null):** If a capability config section is omitted from the model configuration, the feature's availability defaults to the master model option: + - For **REST API**: falls back to `show_in_rest` (which itself defaults to `true`). + - For **MCP Tools**: falls back to `mcp_tools` (which itself defaults to `false` if omitted). + - If the respective master option is omitted/false, the feature is **disabled**. If the master option is `true`, the feature is **enabled**. +2. **Boolean Value:** If defined as a simple boolean (e.g., `'meta' => false` or `'features' => ['duplicate' => false]`), it acts as a joint gate. A value of `false` disables both REST and MCP for that capability; a value of `true` enables both. +3. **Array Value:** If defined as an array, REST and MCP gating can be configured independently: + - **REST Route Gating:** Governed by the `show_in_rest` key in the section array. If present, it resolves to its boolean value. If omitted, it falls back to matching the master model `show_in_rest` option. + - **MCP Tool Gating:** Governed by the `show_in_mcp` key in the section array. If present, it resolves to its boolean value. If omitted, it falls back to matching the master model `mcp_tools` option. + +Enable all Saltus REST-backed and MCP capabilities for a model: ```php return [ @@ -119,12 +148,12 @@ return [ 'name' => 'book', 'options' => [ 'show_in_rest' => true, - 'saltus_rest' => true, + 'mcp_tools' => true, ], ]; ``` -Enable only selected capabilities: +Example showing various feature-level configurations: ```php return [ @@ -132,16 +161,30 @@ return [ 'name' => 'book', 'options' => [ 'show_in_rest' => true, - 'saltus_rest' => [ - 'models' => true, - 'meta' => true, - 'settings' => true, + 'mcp_tools' => true, + ], + 'config' => [ + // 1. Array style: enabled for REST but disabled for MCP + 'meta' => [ + 'show_in_rest' => true, + 'show_in_mcp' => false, + ], + // 2. Boolean style: disabled for both REST and MCP + 'settings' => false, + + 'features' => [ + // 3. Array style: enabled for REST, and defaults to matching master options (enabled) for MCP + 'duplicate' => [ + 'show_in_rest' => true, + ], + // 4. Omitted config for single_export and drag_and_drop: + // both default to matching the master options (enabled here because show_in_rest & mcp_tools are true) ], ], ]; ``` -If `show_in_rest` is explicitly `false`, Saltus does not expose model-scoped REST/MCP routes for that model. The health ability is framework-scoped and remains independent of per-model `saltus_rest` opt-in. +If `show_in_rest` is explicitly `false`, Saltus does not register the model-scoped REST routes. The `mcp_tools` option controls whether MCP tools are exposed. The health ability is framework-scoped and remains independent of per-model opt-in. ## Available Abilities @@ -165,6 +208,7 @@ If `show_in_rest` is explicitly `false`, Saltus does not expose model-scoped RES | `list_posts` | `saltus/list-posts` | `GET /wp/v2/posts` | Query posts from a Custom Post Type with optional filters | | `list_terms` | `saltus/list-terms` | `GET /wp/v2/{taxonomy_rest_base}` | List terms from a taxonomy (categories, tags, or custom taxonomies) | | `reorder_posts` | `saltus/reorder-posts` | `POST /saltus-framework/v1/reorder` | Reorder multiple posts by updating their menu_order values in a single batch operation | +| `update_meta_fields` | `saltus/update-meta-fields` | `PUT /saltus-framework/v1/meta/{post_type}/123` | Update meta fields for a specific post of a registered Saltus post type | | `update_post` | `saltus/update-post` | `PUT /wp/v2/posts/123` | Update an existing post's fields and meta data | | `update_settings` | `saltus/update-settings` | `PUT /saltus-framework/v1/settings/{post_type}` | Update the Saltus Framework settings for a specific post type | @@ -204,7 +248,7 @@ The `get_health` ability calls `GET /saltus-framework/v1/health`. It reports: - cache enabled state - rate limit enabled state -The health route requires `edit_posts` by default. It is not tied to a specific CPT model and does not require `saltus_rest` model opt-in. +The health route requires `edit_posts` by default. It is not tied to a specific CPT model and does not require model opt-in. ## Runtime Controls @@ -276,8 +320,8 @@ Audit retention cleanup runs through the daily `saltus_framework_mcp_audit_clean |-------------|----------| | WordPress with Abilities API | Saltus registers `saltus/*` abilities | | WordPress without Abilities API | Saltus skips native ability registration | -| REST disabled for a model | Model-scoped Saltus MCP routes are unavailable for that model | -| `show_in_rest` set to `false` | Model-scoped Saltus REST/MCP routes are unavailable | +| `mcp_tools` not set or `false` | No MCP tools are generated for that model | +| `show_in_rest` set to `false` | Model-scoped Saltus REST routes are disabled (calling any corresponding MCP tools will fail) | | No WordPress-native MCP client | Saltus abilities are registered, but no client consumes them | ## Troubleshooting @@ -285,7 +329,7 @@ Audit retention cleanup runs through the daily `saltus_framework_mcp_audit_clean | Symptom | Check | |---------|-------| | No `saltus/*` abilities appear | Confirm the WordPress build provides the Abilities API and the plugin is active | -| A model is missing from MCP results | Confirm the model has `show_in_rest` enabled and `saltus_rest` configured | +| A model is missing from MCP results | Confirm the model has `mcp_tools` enabled, and required feature-level `show_in_mcp` flags | | A write operation fails | Confirm the current WordPress user has the needed post, taxonomy, or settings capability | | Calls are throttled | Check `saltus/framework/mcp/rate_limit/*` filters | | Results look stale | Clear transients or disable MCP cache while testing | diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 214c9092..e17038d6 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -8,9 +8,11 @@ - Phase 3 hardening complete: caching, rate limiting, audit trail, structured error codes, health monitoring - PHPStan Level 7 clean across the configured analysis set as of 2026-07-02, including the asset loading helper path - MCP v1 refactoring complete: per-tool REST dispatch, RestBackedToolInterface, ToolContributor, @phpstan-type AbilityDefinition +- MCP namespace/category/prefix now filterable via MCPConfig utility class (saltus/framework/mcp/namespace, saltus/framework/mcp/ability_category, saltus/framework/mcp/ability_prefix) +- MCP/REST capability gating refactored: McpPolicy class with mcp_tools/show_in_mcp gating; ModelRestPolicy switched from saltus_rest array to per-feature config-section model (using show_in_rest and show_in_mcp gates) - Legacy refactoring: inline REST controller logic extracted into shared service classes (SaltusSingleExport, MetaFieldProvider, ReorderPostsService, SettingsManager) wired into both REST controllers and MCP tools — resolved 2026-07-03 - Conditional registration fix: `is_needed()` gate bypass for RestRouteProvider/ToolContributor registries via two-pass approach in `Core`, ensuring REST routes always appear in WP-REST index even before `REST_REQUEST` is defined — resolved 2026-07-06 -- 226 PHPUnit tests passing (639 assertions), PHPStan Level 7 clean across the configured analysis set +- 250 PHPUnit tests passing (669 assertions), PHPStan Level 7 clean across the configured analysis set - **v2.0.0 released 2026-06-30** — MCP, REST API, and Phase 3 shipped ## Top Priority: WordPress 7.0 MCP/Abilities Integration @@ -150,7 +152,7 @@ Expose Saltus Framework capabilities through WordPress-native MCP/Abilities. Sal | **Docker image** | Skipped with standalone server path | | **GitHub Action** | Skipped with standalone server path | | **VS Code extension** | Future WordPress-native MCP client integration | -| **Documentation site** | Source pages added at `docs/MCP.md`, `docs/MCP-CLIENTS.md`, and generated `docs/MCP-ABILITIES.md` for future `docs.saltus.dev/mcp` | +| **Documentation site** | VitePress site live at `docs.saltus.dev`, phpDocumentor API docs, GitHub Actions auto-deploy, MCP docs integrated | | **MCP Registry listing** | Reassess for WordPress-native abilities | | **Support & SLA model** | Paid support contracts, custom tool development | @@ -356,8 +358,16 @@ frontend: **Auto-generation:** `composer docs:wpcli` script in `bin/generate-wpcli-docs.php` to generate WP-CLI command tables (parallel to `bin/generate-mcp-docs.php`). +**Docs site infrastructure (new):** VitePress static site at `docs.saltus.dev` + phpDocumentor API docs + GitHub Actions auto-deploy. + | Item | Status | |------|--------| +| VitePress site config + landing page | ✓ Done | +| phpDocumentor config (phpdoc.dist.xml) | ✓ Done | +| @api annotations on 84 public classes/interfaces | ✓ Done | +| GitHub Actions workflow (build + deploy to Pages) | ✓ Done | +| Docs content: getting-started, architecture, build, features, MCP | ✓ Done | +| `composer docs:all` script (mcp + api) | ✓ Done | | README features table | ○ Pending | | README labels reference | ○ Pending | | README meta structure | ○ Pending | @@ -370,11 +380,11 @@ frontend: | docs/FEATURES.md | ○ Pending | | bin/generate-wpcli-docs.php | ○ Pending | -**Exit criteria:** Zero `(More Info Soon)` or `(Soon)` placeholders in README. All four new features have dedicated doc files. Feature reference is extracted to `docs/FEATURES.md`. WP-CLI docs are auto-generated. +**Exit criteria:** `(More Info Soon)` placeholders filled, docs.saltus.dev live with VitePress + API docs, auto-deploy via GitHub Actions. All four feature doc files created. WP-CLI docs auto-generated. --- -*Plugin Generator moved to its own repository — see [docs/PLUGIN_GENERATOR_ROADMAP.md](./PLUGIN_GENERATOR_ROADMAP.md).* +*Plugin Generator moved to its own repository — see the [framework-demo repository](https://github.com/SaltusDev/framework-demo).* ## Framework Core Roadmap @@ -395,3 +405,105 @@ frontend: ## Tracking - Check GitHub Issues for active sprint items. - Active development on `feature/mcp-v1` branch. + + + +### Phase 6: AI Governance & Editorial Review (v2.2+) + +**Theme:** Add a first-class AI governance layer — context control, editorial review queues, and inside-admin AI assistants — on top of the existing MCP/Abilities foundation. + +Saltus already has model-defined CPTs, REST routes, MCP/Abilities tools, capability checks, audit logging, rate limits, health checks, and per-model `mcp_tools`/`show_in_mcp` gates. Phase 6 adds the higher-level product layer. + +--- + +#### 6A — Context Control Center + +**Goal:** A Saltus model config area where a plugin defines AI governance rules that MCP tools receive before executing. + +**Config shape:** +```yaml +config: + ai_context: + brand_voice: 'Clear, practical, expert, no hype.' + audiences: ['developers', 'site editors'] + field_rules: + post_content: + - 'Maintain technical accuracy' + - 'Never include affiliate links' + allowed_statuses: ['draft', 'pending'] + forbidden_actions: ['delete', 'publish'] + require_human_review: true +``` + +| Item | Status | +|------|--------| +| `ai_context` config schema definition and validation | ○ Pending | +| `AiContextProvider` service — parses and serves ai_context per model | ○ Pending | +| MCP tool `get_context` — exposes ai_context to external agents | ○ Pending | +| Context injection into mutating MCP tools (create/update/delete) | ○ Pending | +| Filter: `saltus/framework/ai_context/defaults` | ○ Pending | +| PHPUnit tests for context validation and injection | ○ Pending | + +**Exit criteria:** Models with `config.ai_context` expose a `saltus/get-context` MCP tool. Mutating MCP tools receive context rules and can reject operations that violate them. + +--- + +#### 6B — Editorial Review Queue + +**Theme:** Agent-proposed changes go through a human approval workflow instead of publishing directly. + +**Flow:** +``` +AI write -> draft/pending/revision -> human approval -> publish +``` + +| Item | Status | +|------|--------| +| `AiChangeProposal` service — stores agent writes as pending change records | ○ Pending | +| `EditorialReviewController` — REST endpoints for listing/reviewing/approving/rejecting proposals | ○ Pending | +| Review dashboard UI (admin screen with diff view) | ○ Pending | +| Audit log integration — full chain from proposal to approval/rejection | ○ Pending | +| Default all mutating MCP tools to draft/pending (configurable) | ○ Pending | +| PHPUnit tests for proposal lifecycle | ○ Pending | + +**Exit criteria:** Mutating MCP tools create pending change records by default. A review admin screen lists proposals with diff view. Approved proposals are published; rejected ones are discarded. Audit log records the full chain. + +--- + +#### 6C — Inside-Admin AI Assistants + +**Theme:** AI operates from inside WordPress admin — buttons beside metabox fields, inline suggestions, and validation. + +| Item | Status | +|------|--------| +| `AiAssistantProvider` service — registers meta box assistants per model | ○ Pending | +| Admin JS entry point (`assets/Feature/AiAssistant/editor.js`) | ○ Pending | +| Assistant actions: improve title, summarize, generate excerpt, suggest terms | ○ Pending | +| Brand rule validation button for post content | ○ Pending | +| REST endpoints for assistant actions (reuse existing permission checks) | ○ Pending | +| Filter: `saltus/framework/ai/assistant_actions` | ○ Pending | +| PHPUnit tests for assistant REST endpoints | ○ Pending | + +**Exit criteria:** Models with `config.ai_context` show AI assistant buttons in the admin. Clicking "Improve title" or "Summarize" calls a REST endpoint and updates the field. Brand rule validation highlights content that violates configured rules. + +--- + +**Exit criteria (Phase 6 overall):** AI governance is configurable per model via `ai_context`. Mutating MCP tools respect context rules and default to review-queue creation. Inside-admin assistants are operational for configured models. All features are tested. + +--- + +### Phase 7: Advanced Dependency Injection & Container Hardening (v2.3+) + +**Theme:** Upgrade the framework's dependency injection container to support reflection-based parameter resolution (autowiring) for third-party services, avoiding standard constructor mapping errors. + +| Item | Status | +|------|--------| +| `ReflectionInstantiator` class implementing `Instantiator` | ○ Pending | +| Positional constructor parameter resolution and dependency matching | ○ Pending | +| Constructor parameter default value fallbacks | ○ Pending | +| Clean validation and exception flow for unresolved parameters | ○ Pending | +| Remove requirement for `Assembly::make` boilerplate on custom services | ○ Pending | +| Container autowiring unit tests (`tests/Unit/Infrastructure/Container/`) | ○ Pending | +| Developer documentation update for custom service constructors | ○ Pending | + +**Exit criteria:** Developers can register custom services in the container with standard typed/positional constructor arguments. The container uses PHP Reflection to map parameter names to container keys, falling back to default arguments or throwing descriptive runtime exceptions when dependencies cannot be resolved. diff --git a/docs/api/index.md b/docs/api/index.md new file mode 100644 index 00000000..c2b719f9 --- /dev/null +++ b/docs/api/index.md @@ -0,0 +1,33 @@ +--- +title: API Reference +--- + +# API Reference + +This section contains auto-generated API documentation for the Saltus Framework's public interfaces and classes. + +## Overview + +The API reference is generated from PHPDoc annotations using phpDocumentor. It covers the public API surface — interfaces, classes, and methods tagged with `@api`. + +## Key Namespaces + +| Namespace | Description | +|-----------|-------------| +| `Saltus\WP\Framework` | Core entry point and modeler | +| `Saltus\WP\Framework\Models` | Model interface, PostType, Taxonomy | +| `Saltus\WP\Framework\Rest` | REST controllers, route providers, policies | +| `Saltus\WP\Framework\MCP` | MCP/Abilities infrastructure | +| `Saltus\WP\Framework\MCP\Tools` | MCP tool interfaces and implementations | +| `Saltus\WP\Framework\Infrastructure\Container` | DI container interfaces | +| `Saltus\WP\Framework\Infrastructure\Plugin` | Plugin lifecycle interfaces | +| `Saltus\WP\Framework\Infrastructure\Service` | Service framework interfaces | +| `Saltus\WP\Framework\Features` | Feature service implementations | + +## Generating the API Docs + +```bash +composer docs:api +``` + +This requires phpDocumentor to be installed. See the [Build Guide](/guides/build) for setup instructions. diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 00000000..f07e8793 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,63 @@ +# Getting Started + +## Installation + +Install the framework in your plugin with Composer: + +```bash +composer require saltus/framework +``` + +## Quick Start + +Once the framework is installed and Composer's autoloader is loaded by your plugin, initialize it: + +```php +$autoload = __DIR__ . '/vendor/autoload.php'; +if ( is_readable( $autoload ) ) { + require_once $autoload; +} + +if ( class_exists( \Saltus\WP\Framework\Core::class ) ) { + $framework = new \Saltus\WP\Framework\Core( dirname( __FILE__ ), __FILE__ ); + $framework->register(); +} +``` + +The framework searches for model files in `src/models/` by default (configurable via the `saltus/framework/models/path` filter). + +## Model Files + +A model file returns an array (or multidimensional array) of model definitions: + +```php + 'cpt', + 'name' => 'movie', +]; +``` + +Multiple models in one file: + +```php + 'cpt', + 'name' => 'movie', + ], + [ + 'type' => 'category', + 'name' => 'genre', + 'associations' => ['movie'], + ], +]; +``` + +## Next Steps + +- Explore the [Features Guide](/guides/features) for all configuration options +- Read the [Architecture Guide](/guides/architecture) for framework internals +- Check the [MCP/Abilities](/mcp/index) docs for AI integration +- Browse the [API Reference](/api/index) for class and interface details diff --git a/docs/guides/architecture.md b/docs/guides/architecture.md new file mode 100644 index 00000000..61869e7d --- /dev/null +++ b/docs/guides/architecture.md @@ -0,0 +1,57 @@ +# Architecture + +## Overview + +Saltus Framework follows a service-oriented architecture with a dependency injection container at its core. The framework bootstraps through the `Core` class, which registers all services, routes, and tool providers. + +``` +Core (Plugin interface) + | + |- ServiceContainer (DI container) + | |- Registers 10 feature services + | |- Two-pass registration: unconditional for routes/tools, + | | gated via is_needed() for hooks/assets + | + |- Modeler (RestRouteProvider + ToolContributor) + | |- Scans {project}/src/models/ for config files + | |- Creates PostType or Taxonomy instances via ModelFactory + | |- Exposes get_models(), REST routes, and MCP tools + | + |- RestServer + | |- Registers 9 REST routes in saltus-framework/v1/ + | |- Uses ModelRestPolicy + CapabilityPolicy for gating + | + |- MCP/Abilities + |- 18 MCP tools registered as saltus/* abilities + |- Middleware pipeline (permission, validation, audit, cache, rate-limit) + |- Backed by REST controllers +``` + +## Key Components + +### Core (`src/Core.php`) +The main entry point. Initializes the service container, registers all services, and hooks into WordPress's `init` action. + +### Modeler (`src/Modeler.php`) +Loads model configuration files from the project's `src/models/` directory and registers them as WordPress post types and taxonomies. + +### Service Container (`src/Infrastructure/Container/`) +A PSR-11-compatible dependency injection container with support for: +- Service registration and resolution +- Conditional service loading via `is_needed()` +- Factory-based instantiation +- Assembly-based wiring + +### REST API (`src/Rest/`) +Nine REST controllers registered under the `saltus-framework/v1/` namespace, covering models, posts, settings, meta, and more. + +### MCP/Abilities (`src/MCP/`) +WordPress-native MCP/Abilities integration exposing 18 tools through a middleware pipeline with caching, rate limiting, audit logging, and permission gating. + +## Design Decisions + +### Two-Pass Service Registration +REST route providers and MCP tool contributors are registered unconditionally during plugin boot to ensure endpoints appear in the WordPress REST index. All other services (admin screens, scripts, frontend hooks) are gated behind `is_needed()` to avoid loading unnecessary code. + +### Middleware Pipeline +Permission, validation, error formatting, audit, cache, and rate-limit logic each exist in exactly one place — a composable middleware pipeline shared by both REST and MCP paths. diff --git a/docs/guides/build.md b/docs/guides/build.md new file mode 100644 index 00000000..d8f6b49c --- /dev/null +++ b/docs/guides/build.md @@ -0,0 +1,57 @@ +# Build & Setup + +## Requirements + +- PHP 7.4 or higher +- Composer + +## Installation + +```bash +composer install +``` + +## Quality Checks + +```bash +# Run tests +composer test + +# Static analysis +composer phpstan + +# Coding standards +composer phpcs + +# Validate composer +composer validate --strict +``` + +## Documentation + +```bash +# Generate MCP ability docs +composer docs:mcp + +# Build API docs (requires phpDocumentor) +composer docs:api + +# Build full docs site +npm run docs:build +``` + +## Patching Codestar Framework + +If the Codestar Framework is updated, re-apply custom patches: + +```bash +for f in lib/codestar-framework/patches/*; do git apply "$f"; done +``` + +## Autoloading + +If you add new classes to `lib/codestar-framework/`, regenerate the classmap: + +```bash +composer dump-autoload +``` diff --git a/docs/guides/features.md b/docs/guides/features.md new file mode 100644 index 00000000..12cae032 --- /dev/null +++ b/docs/guides/features.md @@ -0,0 +1,146 @@ +# Features Reference + +Saltus Framework provides several built-in features that can be enabled per model via the model configuration. + +## Admin Columns + +Customize the admin post list table with additional columns. + +```yaml +features: + admin_cols: + enabled: true + columns: + genre: + label: 'Genre' + callback: 'my_plugin_genre_column' + sortable: true +``` + +## Admin Filters + +Add filter dropdowns to the admin post list. + +```yaml +features: + admin_filters: + genre: + label: 'Filter by Genre' + taxonomy: 'genre' +``` + +## Drag & Drop Reordering + +Enable drag-and-drop reordering of posts. + +```yaml +features: + draganddrop: + enabled: true +``` + +## Duplicate Post + +Allow one-click duplication of posts. + +```yaml +features: + duplicate: + enabled: true +``` + +## Quick Edit + +Add custom fields to the quick edit screen. + +```yaml +features: + quick_edit: + enabled: true +``` + +## Remember Tabs + +Persist active tab state in admin settings pages. + +```yaml +features: + remember_tabs: + enabled: true +``` + +## Single Export + +Export individual posts as WXR. + +```yaml +features: + single_export: + enabled: true +``` + +## Labels + +Customize all labels and messages for your Custom Post Type: + +```yaml +labels: + has_one: 'Movie' + has_many: 'Movies' + text_domain: 'my-plugin' + overrides: + ui: + add_new: 'Add New Movie' + edit_item: 'Edit Movie' + messages: + updated: 'Movie updated.' + created: 'Movie created.' + labels: + name: 'Movies' + singular_name: 'Movie' +``` + +## Meta Boxes + +Define meta boxes with Codestar Framework fields: + +```yaml +meta: + movie_details: + title: 'Movie Details' + sections: + - name: 'general' + title: 'General' + fields: + - id: 'release_date' + type: 'date' + title: 'Release Date' + - id: 'rating' + type: 'select' + title: 'Rating' + options: + G: 'G' + PG: 'PG' + PG-13: 'PG-13' + R: 'R' +``` + +## Settings Pages + +Define settings pages for your CPT: + +```yaml +settings: + page_args: + menu_title: 'Movie Settings' + menu_slug: 'movie-settings' + parent_slug: 'edit.php?post_type=movie' + option_name: 'movie_settings' + sections: + - name: 'general' + title: 'General Settings' + fields: + - id: 'items_per_page' + type: 'number' + title: 'Items Per Page' +``` diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..2c3a08ce --- /dev/null +++ b/docs/index.md @@ -0,0 +1,33 @@ +--- +# https://vitepress.dev/reference/default-theme-home-page +layout: home + +hero: + name: "Saltus Framework" + text: "WordPress plugin development, accelerated" + tagline: Build WordPress plugins with Custom Post Types, taxonomies, meta boxes, settings pages, and admin tooling — using simple configuration files. + image: + src: /logo.png + alt: Saltus Framework + actions: + - theme: brand + text: Get Started + link: /getting-started + - theme: alt + text: View on GitHub + link: https://github.com/SaltusDev/saltus-framework + +features: + - title: Model-driven Architecture + details: Define Custom Post Types and Taxonomies with simple PHP, JSON, or YAML config files. No boilerplate code. + - title: Admin Tooling + details: Add admin columns, filters, drag-and-drop reordering, cloning, single-export, and quick-edit fields in minutes. + - title: REST API + details: 9 REST routes registered in saltus-framework/v1/ covering models, posts, terms, settings, meta, duplicate, export, and reorder. + - title: MCP/Abilities + details: WordPress-native MCP/Abilities surface with 18 tools — models, posts, terms, settings, meta, health, and reorder. + - title: Meta Boxes & Settings + details: Powered by Codestar Framework — build complex meta boxes and settings pages with 40+ field types. + - title: WordPress-native AI + details: Rate limiting, audit logging, transient caching, and capability-gated execution for every MCP tool. +--- diff --git a/docs/mcp/abilities.md b/docs/mcp/abilities.md new file mode 100644 index 00000000..41dab324 --- /dev/null +++ b/docs/mcp/abilities.md @@ -0,0 +1,344 @@ +# MCP Ability Reference + + + +Saltus Framework exposes 18 WordPress-native MCP/Abilities tools. + +| Tool | Ability | REST request | Description | +|------|---------|--------------|-------------| +| `create_post` | `saltus/create-post` | `POST /wp/v2/posts` | Create a new post in any registered Custom Post Type | +| `create_term` | `saltus/create-term` | `POST /wp/v2/{taxonomy_rest_base}` | Create a new term in a taxonomy | +| `delete_post` | `saltus/delete-post` | `DELETE /wp/v2/posts/123` | Delete (trash or force delete) a post by ID | +| `duplicate_post` | `saltus/duplicate-post` | `POST /saltus-framework/v1/duplicate/123` | Duplicate a WordPress post, creating a copy with "(Copy)" appended to the title | +| `export_post` | `saltus/export-post` | `GET /saltus-framework/v1/export/123` | Export a WordPress post as WXR (WordPress eXtended RSS) for import into another site | +| `get_health` | `saltus/get-health` | `GET /saltus-framework/v1/health` | Get Saltus Framework health, version, audit error rate, latency, cache, and rate limit status | +| `get_meta_fields` | `saltus/get-meta-fields` | `GET /saltus-framework/v1/meta/{post_type}` | Get the meta field definitions for a post type as configured in the Saltus Framework model | +| `get_model` | `saltus/get-model` | `GET /saltus-framework/v1/models/{slug}` | Get details of a specific Custom Post Type or Taxonomy by slug | +| `get_post` | `saltus/get-post` | `GET /wp/v2/posts/123` | Get a single post by ID with all fields and meta data | +| `get_settings` | `saltus/get-settings` | `GET /saltus-framework/v1/settings/{post_type}` | Get the Saltus Framework settings for a specific post type | +| `list_meta_fields` | `saltus/list-meta-fields` | `GET /saltus-framework/v1/meta` | List model-defined meta field definitions for all registered Saltus post types | +| `list_models` | `saltus/list-models` | `GET /saltus-framework/v1/models` | List all registered Custom Post Types and Taxonomies on the WordPress site | +| `list_posts` | `saltus/list-posts` | `GET /wp/v2/posts` | Query posts from a Custom Post Type with optional filters | +| `list_terms` | `saltus/list-terms` | `GET /wp/v2/{taxonomy_rest_base}` | List terms from a taxonomy (categories, tags, or custom taxonomies) | +| `reorder_posts` | `saltus/reorder-posts` | `POST /saltus-framework/v1/reorder` | Reorder multiple posts by updating their menu_order values in a single batch operation | +| `update_meta_fields` | `saltus/update-meta-fields` | `PUT /saltus-framework/v1/meta/{post_type}/123` | Update meta fields for a specific post of a registered Saltus post type | +| `update_post` | `saltus/update-post` | `PUT /wp/v2/posts/123` | Update an existing post's fields and meta data | +| `update_settings` | `saltus/update-settings` | `PUT /saltus-framework/v1/settings/{post_type}` | Update the Saltus Framework settings for a specific post type | + +## `create_post` + +Create a new post in any registered Custom Post Type + +- Ability: `saltus/create-post` +- REST request: `POST /wp/v2/posts` +- REST capability: `none` +- Cacheable: `no` +- Cache TTL: `n/a` + +### Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `post_type` | `string` | no | `posts` | The post type slug (e.g., "posts", "page", "product") | +| `title` | `string` | yes | | The post title | +| `content` | `string` | no | | The post content (HTML or raw text) | +| `excerpt` | `string` | no | | The post excerpt | +| `slug` | `string` | no | | URL slug | +| `status` | `string` | no | `draft` | Post status | +| `meta` | `object` | no | | Meta fields as key-value pairs | +| `terms` | `object` | no | | Taxonomy terms as {taxonomy: [term_id, ...]} | + +## `create_term` + +Create a new term in a taxonomy + +- Ability: `saltus/create-term` +- REST request: `POST /wp/v2/{taxonomy_rest_base}` +- REST capability: `none` +- Cacheable: `no` +- Cache TTL: `n/a` + +### Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `taxonomy` | `string` | yes | | The taxonomy slug (e.g., "categories", "tags") | +| `name` | `string` | yes | | The term name | +| `slug` | `string` | no | | URL slug (auto-generated if not provided) | +| `description` | `string` | no | | Term description | +| `parent` | `number` | no | | Parent term ID (for hierarchical taxonomies) | + +## `delete_post` + +Delete (trash or force delete) a post by ID + +- Ability: `saltus/delete-post` +- REST request: `DELETE /wp/v2/posts/123` +- REST capability: `none` +- Cacheable: `no` +- Cache TTL: `n/a` + +### Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `post_id` | `number` | yes | | The post ID to delete | +| `post_type` | `string` | no | `posts` | The post type slug | +| `force` | `boolean` | no | `false` | Whether to force delete (skip trash) | + +## `duplicate_post` + +Duplicate a WordPress post, creating a copy with "(Copy)" appended to the title + +- Ability: `saltus/duplicate-post` +- REST request: `POST /saltus-framework/v1/duplicate/123` +- REST capability: `duplicate (post_type)` +- Cacheable: `no` +- Cache TTL: `n/a` + +### Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `post_id` | `number` | yes | | The ID of the post to duplicate | + +## `export_post` + +Export a WordPress post as WXR (WordPress eXtended RSS) for import into another site + +- Ability: `saltus/export-post` +- REST request: `GET /saltus-framework/v1/export/123` +- REST capability: `export (post_type)` +- Cacheable: `no` +- Cache TTL: `n/a` + +### Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `post_id` | `number` | yes | | The ID of the post to export | + +## `get_health` + +Get Saltus Framework health, version, audit error rate, latency, cache, and rate limit status + +- Ability: `saltus/get-health` +- REST request: `GET /saltus-framework/v1/health` +- REST capability: `health` +- Cacheable: `yes` +- Cache TTL: `60s` + +### Parameters + +This tool does not accept parameters. + +## `get_meta_fields` + +Get the meta field definitions for a post type as configured in the Saltus Framework model + +- Ability: `saltus/get-meta-fields` +- REST request: `GET /saltus-framework/v1/meta/{post_type}` +- REST capability: `meta (post_type)` +- Cacheable: `yes` +- Cache TTL: `600s` + +### Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `post_type` | `string` | yes | | The post type slug to get meta fields for | + +## `get_model` + +Get details of a specific Custom Post Type or Taxonomy by slug + +- Ability: `saltus/get-model` +- REST request: `GET /saltus-framework/v1/models/{slug}` +- REST capability: `models` +- Cacheable: `yes` +- Cache TTL: `600s` + +### Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `slug` | `string` | yes | | The slug of the post type or taxonomy (e.g., "post", "page", "product") | + +## `get_post` + +Get a single post by ID with all fields and meta data + +- Ability: `saltus/get-post` +- REST request: `GET /wp/v2/posts/123` +- REST capability: `none` +- Cacheable: `yes` +- Cache TTL: `300s` + +### Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `post_id` | `number` | yes | | The post ID | +| `post_type` | `string` | no | `posts` | The post type slug (defaults to "posts") | + +## `get_settings` + +Get the Saltus Framework settings for a specific post type + +- Ability: `saltus/get-settings` +- REST request: `GET /saltus-framework/v1/settings/{post_type}` +- REST capability: `settings (post_type)` +- Cacheable: `yes` +- Cache TTL: `300s` + +### Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `post_type` | `string` | yes | | The post type slug to get settings for | + +## `list_meta_fields` + +List model-defined meta field definitions for all registered Saltus post types + +- Ability: `saltus/list-meta-fields` +- REST request: `GET /saltus-framework/v1/meta` +- REST capability: `meta (post_type)` +- Cacheable: `yes` +- Cache TTL: `600s` + +### Parameters + +This tool does not accept parameters. + +## `list_models` + +List all registered Custom Post Types and Taxonomies on the WordPress site + +- Ability: `saltus/list-models` +- REST request: `GET /saltus-framework/v1/models` +- REST capability: `models` +- Cacheable: `yes` +- Cache TTL: `600s` + +### Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `type` | `string` | no | `all` | Filter by type: post_types, taxonomies, or all (default) | + +## `list_posts` + +Query posts from a Custom Post Type with optional filters + +- Ability: `saltus/list-posts` +- REST request: `GET /wp/v2/posts` +- REST capability: `none` +- Cacheable: `yes` +- Cache TTL: `300s` + +### Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `post_type` | `string` | no | `posts` | The post type slug (e.g., "posts", "page", "product") | +| `status` | `string` | no | `publish` | Post status filter (publish, draft, pending, private, trash, any) | +| `search` | `string` | no | | Search term | +| `per_page` | `number` | no | `20` | Number of posts per page (max 100) | +| `page` | `number` | no | `1` | Page number | +| `orderby` | `string` | no | `date` | Sort field (date, title, id, modified, menu_order) | +| `order` | `string` | no | `desc` | Sort order | +| `terms` | `object` | no | | Taxonomy term filters as {taxonomy_rest_base: [term_id, ...]} | + +## `list_terms` + +List terms from a taxonomy (categories, tags, or custom taxonomies) + +- Ability: `saltus/list-terms` +- REST request: `GET /wp/v2/{taxonomy_rest_base}` +- REST capability: `none` +- Cacheable: `yes` +- Cache TTL: `300s` + +### Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `taxonomy` | `string` | yes | | The taxonomy slug (e.g., "categories", "tags", or custom) | +| `per_page` | `number` | no | `50` | Number of terms per page (max 100) | +| `search` | `string` | no | | Search term | +| `hide_empty` | `boolean` | no | `false` | Whether to hide terms with no posts | + +## `reorder_posts` + +Reorder multiple posts by updating their menu_order values in a single batch operation + +- Ability: `saltus/reorder-posts` +- REST request: `POST /saltus-framework/v1/reorder` +- REST capability: `reorder (post_type)` +- Cacheable: `no` +- Cache TTL: `n/a` + +### Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `items` | `array` | yes | | Array of objects with "id" (post ID) and "menu_order" (integer position) | + +## `update_meta_fields` + +Update meta fields for a specific post of a registered Saltus post type + +- Ability: `saltus/update-meta-fields` +- REST request: `PUT /saltus-framework/v1/meta/{post_type}/123` +- REST capability: `meta (post_type)` +- Cacheable: `no` +- Cache TTL: `n/a` + +### Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `post_id` | `number` | yes | | The post ID to update meta fields for | +| `post_type` | `string` | yes | | The post type slug | +| `meta` | `object` | yes | | Meta fields to update as key-value pairs | + +## `update_post` + +Update an existing post's fields and meta data + +- Ability: `saltus/update-post` +- REST request: `PUT /wp/v2/posts/123` +- REST capability: `none` +- Cacheable: `no` +- Cache TTL: `n/a` + +### Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `post_id` | `number` | yes | | The post ID to update | +| `post_type` | `string` | no | `posts` | The post type slug | +| `title` | `string` | no | | New post title | +| `content` | `string` | no | | New post content (HTML or raw text) | +| `excerpt` | `string` | no | | New post excerpt | +| `slug` | `string` | no | | New URL slug | +| `status` | `string` | no | | New post status | +| `meta` | `object` | no | | Meta fields to update as key-value pairs | + +## `update_settings` + +Update the Saltus Framework settings for a specific post type + +- Ability: `saltus/update-settings` +- REST request: `PUT /saltus-framework/v1/settings/{post_type}` +- REST capability: `settings (post_type)` +- Cacheable: `no` +- Cache TTL: `n/a` + +### Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `post_type` | `string` | yes | | The post type slug to update settings for | +| `settings` | `object` | yes | | The settings data to update (key-value pairs) | diff --git a/docs/mcp/clients.md b/docs/mcp/clients.md new file mode 100644 index 00000000..13eab69a --- /dev/null +++ b/docs/mcp/clients.md @@ -0,0 +1,289 @@ +# MCP Client Integration Guide + +This guide is for WordPress-native MCP/Abilities clients that consume Saltus Framework abilities from an active WordPress site. + +Saltus registers `saltus/*` abilities inside WordPress. Clients should treat the ability definitions as the source of truth for available tools, input schemas, permissions, and transport metadata. + +## Client Goals + +A good Saltus MCP client should: + +- discover available `saltus/*` abilities before planning actions +- check site health before making a workflow plan +- inspect models and meta fields before reading or writing content +- use normalized metadata paths when reasoning about nested custom fields +- handle WordPress capability failures without retry loops +- respect rate limits and cacheable read operations +- ask for explicit user confirmation before destructive or broad writes + +## Recommended Call Flow + +Use this sequence for most client sessions: + +1. Call `saltus/get-health`. +2. Call `saltus/list-models`. +3. Call `saltus/list-meta-fields`. +4. Choose the relevant model. +5. Call `saltus/get-model` or `saltus/get-meta-fields` for model-specific detail. +6. Read content with `saltus/list-posts`, `saltus/get-post`, `saltus/list-terms`, or settings tools. +7. Propose changes to the user. +8. Execute writes only after the target model, fields, and permissions are clear. + +For narrow workflows where the client already knows the post type, it can skip the aggregate metadata call and use `saltus/get-meta-fields` directly. + +## Discovery + +Clients should discover abilities from WordPress and filter for the `saltus/` prefix. + +Every Saltus ability includes metadata similar to: + +```json +{ + "meta": { + "mcp_tool": "list_models", + "namespace": "saltus-framework/v1", + "transport": "wordpress-rest", + "show_in_rest": true + } +} +``` + +Use `meta.mcp_tool` for user-facing tool names and logs. Use the ability name, such as `saltus/list-models`, for native client execution. + +## Health First + +Call `saltus/get-health` at the start of a session. A healthy response tells the client that Saltus' runtime controls are available and gives recent audit-derived error and latency information. + +Suggested handling: + +| Health signal | Client behavior | +|---------------|-----------------| +| `status: ok` | Continue normally | +| `status: degraded` | Prefer read-only planning and explain the degraded state before writes | +| High `error_rate` | Avoid repeated retries; inspect permission and input errors | +| High latency | Reduce broad listing calls and keep page sizes modest | +| Rate limit enabled | Respect rate-limit errors and retry-after data | + +Health is framework-scoped. It does not prove that a specific model or write operation is available. + +## Model Discovery + +Use `saltus/list-models` to discover post types and taxonomies exposed by Saltus. Clients should not assume a model exists based only on a user phrase. + +Recommended behavior: + +- Map user language to model names after reading model labels and slugs. +- Prefer exact model slugs when calling tools. +- If multiple models are plausible, ask the user to choose. +- Treat missing models as configuration or permission issues, not as empty content. + +## Metadata Discovery + +Use `saltus/list-meta-fields` for site-wide field discovery. Use `saltus/get-meta-fields` for one post type. + +Saltus returns both raw and normalized metadata: + +- `meta`: raw Saltus/Codestar model configuration +- `normalized.fields`: flattened field paths for client reasoning +- `normalized.rest_meta_keys`: REST-writable roots and type information + +Clients should prefer `normalized.fields` when explaining or mapping field-level work. + +Example normalized paths: + +```text +points_info.coordinates.latitude +points_info.coordinates.longitude +points_info.tooltipContent +``` + +When writing post meta, clients should map the desired field path back to its writable REST meta root. If a field is nested in serialized meta, update the containing structure carefully rather than sending only the leaf path. + +## Safe Read Patterns + +Use list operations before single-item operations: + +1. `saltus/list-posts` with `post_type`, `search`, `status`, and pagination. +2. Ask the user to confirm the target when the search result is ambiguous. +3. `saltus/get-post` with the confirmed `post_id`. + +Keep list queries small. Use `per_page` values that fit the user's task instead of pulling large collections by default. + +For terms: + +1. Discover taxonomy models with `saltus/list-models`. +2. Call `saltus/list-terms` with the taxonomy slug or REST base expected by the tool. +3. Use returned term IDs for post create/update calls when needed. + +## Safe Write Patterns + +Before creating or updating content, clients should know: + +- target post type +- target post ID for updates/deletes +- writable meta roots +- expected field shape +- current user intent +- relevant capability outcome + +Recommended write flow: + +1. Read current model and metadata. +2. Read the target post or settings. +3. Build a minimal patch. +4. Summarize the planned mutation to the user. +5. Execute the mutation. +6. Read the object again to confirm the result. + +Avoid broad writes such as updating many posts from one instruction unless the client can show the exact target list and the user confirms it. + +## Destructive Actions + +`saltus/delete-post`, `saltus/reorder-posts`, and settings updates can materially change the site. Clients should require explicit confirmation for: + +- force deletion +- bulk deletion +- reordering more than a small visible set +- settings updates +- writes to serialized or nested meta + +Prefer trashing over force deletion unless the user explicitly asks for permanent deletion. + +## Permission Failures + +Saltus permissions are WordPress permissions. Clients should not try to bypass them. + +Common responses: + +| Failure | Likely cause | Client response | +|---------|--------------|-----------------| +| `rest_forbidden` | Current user lacks capability | Explain the required access and stop | +| `invalid_params` | Missing or malformed arguments | Fix arguments once, then retry | +| model not found | Model is not registered, not REST-enabled, or not visible to this user | Ask for a different model or admin configuration | +| write denied | User can read but not mutate the target | Offer a read-only summary instead | + +Do not retry permission failures repeatedly. They are usually stable until the user's role or model configuration changes. + +## Rate Limits + +Saltus rate limiting protects WordPress from excessive ability calls. When a call returns a rate-limit error, clients should respect the error data and wait before retrying. + +Recommended behavior: + +- Stop parallel calls after the first rate-limit error. +- Use the returned `retry_after` value when available. +- Prefer cached or already-read context while waiting. +- Avoid broad discovery loops that repeatedly call the same list tools. + +## Caching + +Saltus may cache read-only ability responses in WordPress transients. Clients can still call read tools normally, but should understand that repeated calls may return cached data. + +For workflows that must confirm a mutation: + +1. Execute the write. +2. Let Saltus clear its MCP cache. +3. Read the changed object again. + +Mutating Saltus tools clear the MCP cache after execution. + +## Client Planning Heuristics + +Use these heuristics when planning autonomous workflows: + +| User intent | First tools | +|-------------|-------------| +| "What content types are available?" | `saltus/get-health`, `saltus/list-models` | +| "Show me entries for X" | `saltus/list-models`, `saltus/list-posts` | +| "Edit field Y on item Z" | `saltus/list-models`, `saltus/get-meta-fields`, `saltus/list-posts`, `saltus/get-post` | +| "Create a new X" | `saltus/list-models`, `saltus/get-meta-fields`, `saltus/create-post` | +| "Change plugin settings" | `saltus/list-models`, `saltus/get-settings`, `saltus/update-settings` | +| "Export this item" | `saltus/list-posts`, `saltus/get-post`, `saltus/export-post` | +| "Reorder items" | `saltus/list-posts`, user confirmation, `saltus/reorder-posts` | + +## Example Workflows + +### Inspect Available Saltus Content + +1. Call `saltus/get-health`. +2. Call `saltus/list-models` with `type: "all"`. +3. Summarize exposed post types and taxonomies. +4. Mention if no models are visible or if health is degraded. + +### Update a Custom Meta Field + +1. Call `saltus/list-models`. +2. Identify the post type. +3. Call `saltus/get-meta-fields`. +4. Find the normalized field path. +5. Call `saltus/list-posts` to locate the item. +6. Call `saltus/get-post`. +7. Build a minimal meta update. +8. Ask for confirmation. +9. Call `saltus/update-post`. +10. Call `saltus/get-post` again and report the confirmed value. + +### Create a New CPT Entry + +1. Call `saltus/list-models`. +2. Call `saltus/get-meta-fields` for the chosen post type. +3. Collect required title/content/meta/term data from the user. +4. Call `saltus/create-post`. +5. Read the new post and summarize its ID, status, title, and important meta. + +### Diagnose Client Errors + +1. Call `saltus/get-health`. +2. Check recent error rate and latency. +3. Confirm the requested ability exists. +4. Confirm the target model is visible through `saltus/list-models`. +5. Confirm required parameters match `docs/MCP-ABILITIES.md` or the discovered input schema. +6. Stop if the error is permission-related. + +## Editor And VS Code Guidance + +For editor agents and future VS Code integrations: + +- Show discovered Saltus models before offering write operations. +- Show the exact post IDs or setting keys affected by a mutation. +- Surface normalized meta paths in pickers/autocomplete. +- Keep ability call logs visible enough for debugging. +- Prefer staged edits or previews before `update_post`, `update_settings`, `delete_post`, or `reorder_posts`. +- Use `get_health` as a connection/status check in the extension UI. + +## Prompt Guidance For Clients + +Clients can improve reliability by grounding tool use in a short internal plan: + +```text +First check Saltus health. Then list models. Then discover metadata for the target post type. Do not write until the target post ID, field path, and new value are confirmed. +``` + +For destructive work: + +```text +Before deletion or force deletion, show the exact post ID, title, post type, and deletion mode. Require explicit confirmation. +``` + +For nested meta: + +```text +Use normalized field paths for reasoning, but write through the REST meta root reported by Saltus. Preserve sibling fields in serialized structures. +``` + +## Anti-Patterns + +Avoid these client behaviors: + +- guessing post type slugs without `list_models` +- writing meta before checking normalized metadata +- retrying permission failures +- using large list queries as a discovery shortcut +- force deleting without explicit confirmation +- treating health `ok` as proof that all model-scoped capabilities are enabled +- assuming every Saltus model allows every REST-backed capability + +## Reference +- Main MCP docs: [MCP Overview](../mcp/index.md) + +- Generated ability reference: [Abilities Reference](../mcp/abilities.md) diff --git a/docs/mcp/index.md b/docs/mcp/index.md new file mode 100644 index 00000000..acc91d97 --- /dev/null +++ b/docs/mcp/index.md @@ -0,0 +1,45 @@ +--- +title: MCP/Abilities Overview +--- + +# MCP/Abilities Overview + +Saltus Framework exposes its AI-facing tool surface through WordPress-native MCP/Abilities. Native WordPress MCP clients can discover and call the `saltus/*` abilities directly from the active plugin. + +## Quick Start + +Install and activate the plugin that uses Saltus Framework on a WordPress version with the Abilities API. Saltus registers its abilities during `wp_abilities_api_init`; clients that understand WordPress-native MCP/Abilities can discover the `saltus/*` tools from WordPress. + +## Available Tools + +| Tool | Description | +|------|-------------| +| `get_health` | Framework health, version, error rate, latency, cache, and rate-limit status | +| `list_models` | List all registered CPTs and taxonomies | +| `get_model` | Get details of a specific post type or taxonomy | +| `list_posts` | Query posts with filters (status, search, pagination) | +| `get_post` | Get a single post with all fields and meta | +| `create_post` | Create a new post in any CPT | +| `update_post` | Update an existing post's fields and meta | +| `delete_post` | Trash or force delete a post | +| `list_terms` | List terms from a taxonomy | +| `create_term` | Create a new term in a taxonomy | +| `duplicate_post` | Duplicate a WordPress post | +| `export_post` | Export a post as WXR | +| `get_settings` | Get Saltus settings for a post type | +| `update_settings` | Update Saltus settings for a post type | +| `reorder_posts` | Batch update post menu order | +| `list_meta_fields` | Discover Saltus meta field definitions across all registered CPTs | +| `get_meta_fields` | Get Saltus meta field definitions for a post type | +| `update_meta_fields` | Update meta field values for a post | + +## Requirements + +- WordPress 7.0+ with the Abilities API +- A WordPress-native MCP/Abilities client +- The plugin using Saltus Framework must be active + +## Further Reading + +- [Abilities Reference](/mcp/abilities) — full parameter details for every tool +- [Client Integration](/mcp/clients) — integration guide, workflow, and anti-patterns diff --git a/docs/public/CNAME b/docs/public/CNAME new file mode 100644 index 00000000..8ce979c0 --- /dev/null +++ b/docs/public/CNAME @@ -0,0 +1 @@ +docs.saltus.dev diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..fb79a59b --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2559 @@ +{ + "name": "saltus-framework-git", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "saltus-framework-git", + "version": "1.0.0", + "license": "ISC", + "devDependencies": { + "vitepress": "^1.6.4" + } + }, + "node_modules/@algolia/abtesting": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.22.0.tgz", + "integrity": "sha512-BFR6zNowNKcY7Ou7TaJc9QWexES4YKPbmf/OTFofpdsdhz4x6q0lbxp3duO0EHnyrN7rE4ba/TSXuY+BDGu4+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/autocomplete-core": { + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-core/-/autocomplete-core-1.17.7.tgz", + "integrity": "sha512-BjiPOW6ks90UKl7TwMv7oNQMnzU+t/wk9mgIDi6b1tXpUek7MW0lbNOUHpvam9pe3lVCf4xPFT+lK7s+e+fs7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-plugin-algolia-insights": "1.17.7", + "@algolia/autocomplete-shared": "1.17.7" + } + }, + "node_modules/@algolia/autocomplete-plugin-algolia-insights": { + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-plugin-algolia-insights/-/autocomplete-plugin-algolia-insights-1.17.7.tgz", + "integrity": "sha512-Jca5Ude6yUOuyzjnz57og7Et3aXjbwCSDf/8onLHSQgw1qW3ALl9mrMWaXb5FmPVkV3EtkD2F/+NkT6VHyPu9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-shared": "1.17.7" + }, + "peerDependencies": { + "search-insights": ">= 1 < 3" + } + }, + "node_modules/@algolia/autocomplete-preset-algolia": { + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-preset-algolia/-/autocomplete-preset-algolia-1.17.7.tgz", + "integrity": "sha512-ggOQ950+nwbWROq2MOCIL71RE0DdQZsceqrg32UqnhDz8FlO9rL8ONHNsI2R1MH0tkgVIDKI/D0sMiUchsFdWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-shared": "1.17.7" + }, + "peerDependencies": { + "@algolia/client-search": ">= 4.9.1 < 6", + "algoliasearch": ">= 4.9.1 < 6" + } + }, + "node_modules/@algolia/autocomplete-shared": { + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-shared/-/autocomplete-shared-1.17.7.tgz", + "integrity": "sha512-o/1Vurr42U/qskRSuhBH+VKxMvkkUVTLU6WZQr+L5lGZZLYWyhdzWjW0iGXY7EkwRTjBqvN2EsR81yCTGV/kmg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@algolia/client-search": ">= 4.9.1 < 6", + "algoliasearch": ">= 4.9.1 < 6" + } + }, + "node_modules/@algolia/client-abtesting": { + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.56.0.tgz", + "integrity": "sha512-7r4Z3NC7yU1oAQVWJNA2HX7tX481F3pJvCGyLIXiTdBcthz4Q/o21jwcMYDFkuI92UWTNBQQmHYgwHo1zS5dzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-analytics": { + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.56.0.tgz", + "integrity": "sha512-avmjXQSq+jadFO8Xl2em05/uQdQnEmHsJyOAdVbZkmVgpMfxL12aJwVVfGNwYr9nulcpuJN1X0lTaQ5wxuNGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-common": { + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.56.0.tgz", + "integrity": "sha512-v2TPStUhY//ripPjIVclZ8AWc7DEGooXULZGFlFu37zNatgHjw34oZZ+OSbbc/YHO+xZwPl62I1k8xH1m4S2eg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-insights": { + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.56.0.tgz", + "integrity": "sha512-P0ehROpM4Sem3Sqo5x2cKPgj67D3G3jy0rh1Amwkcvsfr6tkvIcdCmerieanqTF7NxUMPNFLkpIFeMO8Rpa50w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-personalization": { + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.56.0.tgz", + "integrity": "sha512-SXK3Vn3WVxyzbm31oePZBJkp1wpOyuWdd4B/Pv7n0aXDxmeSWhC1R1FC1517mMrFAIaPH4Rt0x6RUe7ZNjz8FA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-query-suggestions": { + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.56.0.tgz", + "integrity": "sha512-5+ZdX8garFnmycnZgKhtXHePEaLj5zqDxI/0lkhhluzCcvTn0/PvvTirTg8hHYetQHvn7GDyeAiqTAieMvMW4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-search": { + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.56.0.tgz", + "integrity": "sha512-+mKUdYvqOi0BcvpAEyCEw49vSBptufIcfibtHz2bdr1pI789M46Yt0uQEk/sxtK3teh71OQvVFHaTDzShUWewQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/ingestion": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.56.0.tgz", + "integrity": "sha512-9g/zj+AZx5moFcdFIrYQoVrueXivjUcc3MQHtCYT8WhIuk1lUh1AyEhvJCS0XBZld09cLvd1AZ3BvDBpVpX2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/monitoring": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.56.0.tgz", + "integrity": "sha512-Qf3Sr6f9A9uxCZUf3MXS0d2b877uYzEB5yxqpVGXAhcJnBCQjrRRon0KvefpGkxy+BshrIJs96OUoMtGqXTFDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/recommend": { + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.56.0.tgz", + "integrity": "sha512-GXWG1rWc5wu8hY4N33Y3b6ernY6sAdAvmKWN/zHAiACOx40WnpG0TVX5YazCAr/9gOYGInSiM2A0y2jy2xbiDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-browser-xhr": { + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.56.0.tgz", + "integrity": "sha512-7t24cBxaInS3mZb7ddEaZT/tp6q+/aR4YttsQVyP1/i+LmwPR34atO35KjaLFCcRVrlP7sYOAqkCfg6lIRB+ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-fetch": { + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.56.0.tgz", + "integrity": "sha512-R7ePHgVYmDFjZpvrsVAfbDz/d4RxKAYZ5/vgLfIsCVRZRryjWl/3INOxpOICzitehQ5FjNtNjcLQTrmHPTcHBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-node-http": { + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.56.0.tgz", + "integrity": "sha512-PIOUXlSnrqM0S+WOgDRb4RzotydJH7ZoT6tOyL7tAO7qJOfvX5wsEW8Pe+PMKMwvuI4/gIyK9cg2H7lJXqnc4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@docsearch/css": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-3.8.2.tgz", + "integrity": "sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@docsearch/js": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/@docsearch/js/-/js-3.8.2.tgz", + "integrity": "sha512-Q5wY66qHn0SwA7Taa0aDbHiJvaFJLOJyHmooQ7y8hlwwQLQ/5WwCcoX0g7ii04Qi2DJlHsd0XXzJ8Ypw9+9YmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@docsearch/react": "3.8.2", + "preact": "^10.0.0" + } + }, + "node_modules/@docsearch/react": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/@docsearch/react/-/react-3.8.2.tgz", + "integrity": "sha512-xCRrJQlTt8N9GU0DG4ptwHRkfnSnD/YpdeaXe02iKfqs97TkZJv60yE+1eq/tjPcVnTW8dP5qLP7itifFVV5eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-core": "1.17.7", + "@algolia/autocomplete-preset-algolia": "1.17.7", + "@docsearch/css": "3.8.2", + "algoliasearch": "^5.14.2" + }, + "peerDependencies": { + "@types/react": ">= 16.8.0 < 19.0.0", + "react": ">= 16.8.0 < 19.0.0", + "react-dom": ">= 16.8.0 < 19.0.0", + "search-insights": ">= 1 < 3" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "search-insights": { + "optional": true + } + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@iconify-json/simple-icons": { + "version": "1.2.91", + "resolved": "https://registry.npmjs.org/@iconify-json/simple-icons/-/simple-icons-1.2.91.tgz", + "integrity": "sha512-plQJHZxzpkIXaeYjJF0Sc9nkzYF32EDX9olx7j1hcKYFaiTrQXbcngZmvzuV0pG6uZPU3ZuA/GIsgxSHOm6jrQ==", + "dev": true, + "license": "CC0-1.0", + "dependencies": { + "@iconify/types": "*" + } + }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@shikijs/core": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-2.5.0.tgz", + "integrity": "sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/engine-javascript": "2.5.0", + "@shikijs/engine-oniguruma": "2.5.0", + "@shikijs/types": "2.5.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.4" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-2.5.0.tgz", + "integrity": "sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "2.5.0", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^3.1.0" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-2.5.0.tgz", + "integrity": "sha512-pGd1wRATzbo/uatrCIILlAdFVKdxImWJGQ5rFiB5VZi2ve5xj3Ax9jny8QvkaV93btQEwR/rSz5ERFpC5mKNIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "2.5.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "node_modules/@shikijs/langs": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-2.5.0.tgz", + "integrity": "sha512-Qfrrt5OsNH5R+5tJ/3uYBBZv3SuGmnRPejV9IlIbFH3HTGLDlkqgHymAlzklVmKBjAaVmkPkyikAV/sQ1wSL+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "2.5.0" + } + }, + "node_modules/@shikijs/themes": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-2.5.0.tgz", + "integrity": "sha512-wGrk+R8tJnO0VMzmUExHR+QdSaPUl/NKs+a4cQQRWyoc3YFbUzuLEi/KWK1hj+8BfHRKm2jNhhJck1dfstJpiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "2.5.0" + } + }, + "node_modules/@shikijs/transformers": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/transformers/-/transformers-2.5.0.tgz", + "integrity": "sha512-SI494W5X60CaUwgi8u4q4m4s3YAFSxln3tzNjOSYqq54wlVgz0/NbbXEb3mdLbqMBztcmS7bVTaEd2w0qMmfeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/core": "2.5.0", + "@shikijs/types": "2.5.0" + } + }, + "node_modules/@shikijs/types": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-2.5.0.tgz", + "integrity": "sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/hast": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/markdown-it": { + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", + "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", + "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==", + "dev": true, + "license": "ISC" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.40.tgz", + "integrity": "sha512-39E8IgOhTbVDnoJFMKc2DvYnypcZwUqgUhQkccva/0m6FUwtIKSGV7n1hpVmYcFaoRAwf9pBcwnKlCEsN63ZEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/shared": "3.5.40", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.40.tgz", + "integrity": "sha512-pwkx4vqlqOspFstrcmzwkKLePVMD3PT65imRzLhanU2V1Fj4K13g6OXjanOyzw3aTAuRk84BOmY8f3rEHqPaVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.40", + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.40.tgz", + "integrity": "sha512-gIf497P4kpuALcvs5n3AEg1Vdn0pSY4XbjASIfHNYF1/MP3T2Mf2STERTubysBxCRxzJGJYtF/O7vwJrxFB3Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/compiler-core": "3.5.40", + "@vue/compiler-dom": "3.5.40", + "@vue/compiler-ssr": "3.5.40", + "@vue/shared": "3.5.40", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.19", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.40.tgz", + "integrity": "sha512-rrE5xiXG663+vHCHa3J9p2z5OcBRjXmoqenprJxAFQxg5pSshzeBiCE6pu46axapRJ2Adk0YDA2BRZVjiHXnhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.40", + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/devtools-api": { + "version": "7.7.10", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.10.tgz", + "integrity": "sha512-KxtEpUOOpFz/qOGRrAwA36QF7DqIA+FXgCYit9mk9wjbaZt0sXOFz81ElOZtKA4HbWHUdwNjZHBFsFFyp5BZiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^7.7.10" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "7.7.10", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.10.tgz", + "integrity": "sha512-3WNi2Kq4tbpVbmhml7RiphmAt0279oh3fKNeWMQIrltfX8Q91b4i5PL8DtyNKdwmcsGrV4fg+erwWOmD05CLIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^7.7.10", + "birpc": "^2.3.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^1.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "7.7.10", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.10.tgz", + "integrity": "sha512-wOPslzB8vTvpxwdaOcR2qAbwmuSP0L+rhpoC6Cf56V3Jip+HWb7PQQXOUPgBNQARpXsbQX/+mvi8kKucmBGRwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "rfdc": "^1.4.1" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.40.tgz", + "integrity": "sha512-B7ot9UlUZOi1zbq61/LvE88ZLTV8IlajTdiZTAEiDQgrnIMIZoPr9kGw0Zw46ObW62O9+H/Be3kMbfb7kYPQZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.40.tgz", + "integrity": "sha512-KAZLweuZ6uUJPK1PMSQPgBU5gCjgrrfjUhSglmU9NhH+Zjepa8cnwSydPWDWHDwOgY4g3VcZ+PljbiHlURNCbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.40", + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.40.tgz", + "integrity": "sha512-ZfrX8ssZQds900L9pr8AuK05ddnMsR4MPMZr8cPN9GoqoPWcXLhjvvbIA2SMv+7a97sJ1vv9pj/zxK0Cq/eEFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.40", + "@vue/runtime-core": "3.5.40", + "@vue/shared": "3.5.40", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.40.tgz", + "integrity": "sha512-XNJym9WpevhTVt1HuwOrCRJ5Q+9z4BjTMrDtjTrvx74SmUll8spNTw6whWJa9mEkO4PKn5TihI/bm/8ds2QVJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.40", + "@vue/runtime-dom": "3.5.40", + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.40.tgz", + "integrity": "sha512-WxnBtruIqOoV3rA4jeKDWzrYI5h7Cp4+pjwDi8kWGHz+IslhiN+wguLVVhtv2l8VoU02rzDCVfDjgCl1lNpZVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-12.8.2.tgz", + "integrity": "sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "12.8.2", + "@vueuse/shared": "12.8.2", + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/integrations": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/integrations/-/integrations-12.8.2.tgz", + "integrity": "sha512-fbGYivgK5uBTRt7p5F3zy6VrETlV9RtZjBqd1/HxGdjdckBgBM4ugP8LHpjolqTj14TXTxSK1ZfgPbHYyGuH7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vueuse/core": "12.8.2", + "@vueuse/shared": "12.8.2", + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "async-validator": "^4", + "axios": "^1", + "change-case": "^5", + "drauu": "^0.4", + "focus-trap": "^7", + "fuse.js": "^7", + "idb-keyval": "^6", + "jwt-decode": "^4", + "nprogress": "^0.2", + "qrcode": "^1.5", + "sortablejs": "^1", + "universal-cookie": "^7" + }, + "peerDependenciesMeta": { + "async-validator": { + "optional": true + }, + "axios": { + "optional": true + }, + "change-case": { + "optional": true + }, + "drauu": { + "optional": true + }, + "focus-trap": { + "optional": true + }, + "fuse.js": { + "optional": true + }, + "idb-keyval": { + "optional": true + }, + "jwt-decode": { + "optional": true + }, + "nprogress": { + "optional": true + }, + "qrcode": { + "optional": true + }, + "sortablejs": { + "optional": true + }, + "universal-cookie": { + "optional": true + } + } + }, + "node_modules/@vueuse/metadata": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-12.8.2.tgz", + "integrity": "sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-12.8.2.tgz", + "integrity": "sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/algoliasearch": { + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.56.0.tgz", + "integrity": "sha512-PrqppUmhT4ENdas2pH9caE7efUcxy6EcSFhWzosiVuQBzu2tQ5yLTI6jwomT/1cuBnivzGfxiJCqDNN9FRRh+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/abtesting": "1.22.0", + "@algolia/client-abtesting": "5.56.0", + "@algolia/client-analytics": "5.56.0", + "@algolia/client-common": "5.56.0", + "@algolia/client-insights": "5.56.0", + "@algolia/client-personalization": "5.56.0", + "@algolia/client-query-suggestions": "5.56.0", + "@algolia/client-search": "5.56.0", + "@algolia/ingestion": "1.56.0", + "@algolia/monitoring": "1.56.0", + "@algolia/recommend": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/copy-anything": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", + "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-what": "^5.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/emoji-regex-xs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz", + "integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/focus-trap": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.8.0.tgz", + "integrity": "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tabbable": "^6.4.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-what": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", + "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mark.js": { + "version": "8.11.1", + "resolved": "https://registry.npmjs.org/mark.js/-/mark.js-8.11.1.tgz", + "integrity": "sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/minisearch": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/minisearch/-/minisearch-7.2.0.tgz", + "integrity": "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==", + "dev": true, + "license": "MIT" + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/oniguruma-to-es": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-3.1.1.tgz", + "integrity": "sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex-xs": "^1.0.0", + "regex": "^6.0.1", + "regex-recursion": "^6.0.2" + } + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.20", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.20.tgz", + "integrity": "sha512-lW616l85ucIQL+FocMmL7pQFPqBmwejrCMg+iPxyImlrANNJG9NHq/RkyCZopDhd8C3LA03PHRJDjkbGu8vvug==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/preact": { + "version": "10.29.7", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.7.tgz", + "integrity": "sha512-DCHYrK/B10yUD3ZjLfhZ3WIE/9Vf9VFUODcRE2dRomTYDpJk6z6L9wecSfhfE6M9ZTHUdyQkoC46arIDhEV84Q==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + }, + "peerDependencies": { + "preact-render-to-string": ">=5" + }, + "peerDependenciesMeta": { + "preact-render-to-string": { + "optional": true + } + } + }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "dev": true, + "license": "MIT" + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/search-insights": { + "version": "2.17.3", + "resolved": "https://registry.npmjs.org/search-insights/-/search-insights-2.17.3.tgz", + "integrity": "sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/shiki": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-2.5.0.tgz", + "integrity": "sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/core": "2.5.0", + "@shikijs/engine-javascript": "2.5.0", + "@shikijs/engine-oniguruma": "2.5.0", + "@shikijs/langs": "2.5.0", + "@shikijs/themes": "2.5.0", + "@shikijs/types": "2.5.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/speakingurl": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", + "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "dev": true, + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/superjson": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", + "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "copy-anything": "^4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tabbable": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.5.0.tgz", + "integrity": "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vitepress": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/vitepress/-/vitepress-1.6.4.tgz", + "integrity": "sha512-+2ym1/+0VVrbhNyRoFFesVvBvHAVMZMK0rw60E3X/5349M1GuVdKeazuksqopEdvkKwKGs21Q729jX81/bkBJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@docsearch/css": "3.8.2", + "@docsearch/js": "3.8.2", + "@iconify-json/simple-icons": "^1.2.21", + "@shikijs/core": "^2.1.0", + "@shikijs/transformers": "^2.1.0", + "@shikijs/types": "^2.1.0", + "@types/markdown-it": "^14.1.2", + "@vitejs/plugin-vue": "^5.2.1", + "@vue/devtools-api": "^7.7.0", + "@vue/shared": "^3.5.13", + "@vueuse/core": "^12.4.0", + "@vueuse/integrations": "^12.4.0", + "focus-trap": "^7.6.4", + "mark.js": "8.11.1", + "minisearch": "^7.1.1", + "shiki": "^2.1.0", + "vite": "^5.4.14", + "vue": "^3.5.13" + }, + "bin": { + "vitepress": "bin/vitepress.js" + }, + "peerDependencies": { + "markdown-it-mathjax3": "^4", + "postcss": "^8" + }, + "peerDependenciesMeta": { + "markdown-it-mathjax3": { + "optional": true + }, + "postcss": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.40.tgz", + "integrity": "sha512-+8PJ4SJXdn/cHGImF4CKdxlWHIN5Dkt7DoufRREM6h6uVCx2m7QxgcEQmmzyOK8A9mcafg7sFbJFYsdFVubTig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.40", + "@vue/compiler-sfc": "3.5.40", + "@vue/runtime-dom": "3.5.40", + "@vue/server-renderer": "3.5.40", + "@vue/shared": "3.5.40" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..1f3e9f15 --- /dev/null +++ b/package.json @@ -0,0 +1,31 @@ +{ + "name": "saltus-framework-git", + "version": "1.0.0", + "description": "Saltus Framework helps you develop WordPress plugins that are based on Custom Post Types.", + "main": "index.js", + "directories": { + "doc": "docs", + "lib": "lib", + "test": "tests" + }, + "scripts": { + "docs:dev": "vitepress dev docs", + "docs:build": "vitepress build docs", + "docs:preview": "vitepress preview docs" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/SaltusDev/saltus-framework.git" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "commonjs", + "bugs": { + "url": "https://github.com/SaltusDev/saltus-framework/issues" + }, + "homepage": "https://github.com/SaltusDev/saltus-framework#readme", + "devDependencies": { + "vitepress": "^1.6.4" + } +} diff --git a/phpdoc.dist.xml b/phpdoc.dist.xml new file mode 100644 index 00000000..6ac34779 --- /dev/null +++ b/phpdoc.dist.xml @@ -0,0 +1,26 @@ + + + Saltus Framework + + docs/public/api + build/phpdoc-cache + + + + + src + + + TODO + FIXME + + Saltus Framework + true + public + + + diff --git a/phpunit.xml b/phpunit.xml index 0370eadc..4e715f93 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -9,6 +9,11 @@ beStrictAboutTestsThatDoNotTestAnything="true" failOnRisky="true" failOnWarning="true"> + + + src + + tests/Unit/ diff --git a/src/Core.php b/src/Core.php index b164a957..4d3b779d 100644 --- a/src/Core.php +++ b/src/Core.php @@ -3,6 +3,7 @@ * Saltus Framework * * @version 2.0.0 + * @api */ namespace Saltus\WP\Framework; diff --git a/src/Features/AdminCols/AdminCols.php b/src/Features/AdminCols/AdminCols.php index c48e4740..d389439e 100644 --- a/src/Features/AdminCols/AdminCols.php +++ b/src/Features/AdminCols/AdminCols.php @@ -10,6 +10,7 @@ /** * Adds custom admin columns in the post type archive + * @api */ class AdminCols implements Service, Conditional, Assembly { diff --git a/src/Features/AdminFilters/AdminFilters.php b/src/Features/AdminFilters/AdminFilters.php index d378f990..730fe28d 100644 --- a/src/Features/AdminFilters/AdminFilters.php +++ b/src/Features/AdminFilters/AdminFilters.php @@ -10,6 +10,7 @@ /** * Adds admin filters in the post type admin archive + * @api */ class AdminFilters implements Service, Conditional, Assembly { diff --git a/src/Features/DragAndDrop/DragAndDrop.php b/src/Features/DragAndDrop/DragAndDrop.php index 40bd0950..9ff61dcb 100644 --- a/src/Features/DragAndDrop/DragAndDrop.php +++ b/src/Features/DragAndDrop/DragAndDrop.php @@ -21,6 +21,7 @@ * Class DragAndDrop * * Enable an option to manage drag and drop functionality in the admin area. + * @api */ class DragAndDrop implements Service, Conditional, Actionable, Assembly, RestRouteProvider, ToolContributor { diff --git a/src/Features/Duplicate/Duplicate.php b/src/Features/Duplicate/Duplicate.php index 25f349fe..57cb776d 100644 --- a/src/Features/Duplicate/Duplicate.php +++ b/src/Features/Duplicate/Duplicate.php @@ -17,6 +17,7 @@ /** + * @api */ class Duplicate implements Service, Conditional, Assembly, RestRouteProvider, ToolContributor { diff --git a/src/Features/MCP/MCP.php b/src/Features/MCP/MCP.php index 085242f3..80530ff6 100644 --- a/src/Features/MCP/MCP.php +++ b/src/Features/MCP/MCP.php @@ -9,6 +9,7 @@ use Saltus\WP\Framework\MCP\Abilities\AbilityRegistrar; use Saltus\WP\Framework\MCP\Audit\AuditLogger; use Saltus\WP\Framework\MCP\Cache\TransientCache; +use Saltus\WP\Framework\MCP\McpPolicy; use Saltus\WP\Framework\MCP\Tools\ToolContributor; use Saltus\WP\Framework\MCP\Tools\ToolProvider; use Saltus\WP\Framework\Rest\ModelRestPolicy; @@ -19,6 +20,7 @@ * WordPress-native abilities are registered when the host WordPress version * exposes the Abilities API. Older WordPress versions skip native ability * registration. + * @api */ class MCP implements Service, Registerable, Activateable, Deactivateable { @@ -31,6 +33,7 @@ class MCP implements Service, Registerable, Activateable, Deactivateable { /** @var callable|null */ private $modeler_resolver; private ?ModelRestPolicy $policy; + private ?McpPolicy $mcp_policy; /** * @param array $dependencies Framework dependencies injected by the service container. @@ -42,6 +45,7 @@ public function __construct( array $dependencies = [], ?AbilityRegistrar $abilit $this->modeler = $modeler instanceof Modeler ? $modeler : null; $this->modeler_resolver = is_callable( $dependencies['modeler_resolver'] ?? null ) ? $dependencies['modeler_resolver'] : null; $this->policy = null; + $this->mcp_policy = null; } public function register(): void { @@ -106,7 +110,7 @@ private function ability_registrar(): AbilityRegistrar { return $this->ability_registrar; } - $this->ability_registrar = new AbilityRegistrar( $this->tool_provider(), null, $this->policy() ); + $this->ability_registrar = new AbilityRegistrar( $this->tool_provider(), null, $this->mcp_policy() ); return $this->ability_registrar; } @@ -195,4 +199,17 @@ private function policy(): ?ModelRestPolicy { return $this->policy; } + + private function mcp_policy(): ?McpPolicy { + $modeler = $this->modeler(); + if ( ! $modeler instanceof Modeler ) { + return null; + } + + if ( ! $this->mcp_policy instanceof McpPolicy ) { + $this->mcp_policy = new McpPolicy( $modeler ); + } + + return $this->mcp_policy; + } } diff --git a/src/Features/Meta/CodestarMeta.php b/src/Features/Meta/CodestarMeta.php index b1309b3a..f56985b5 100644 --- a/src/Features/Meta/CodestarMeta.php +++ b/src/Features/Meta/CodestarMeta.php @@ -9,6 +9,7 @@ /** * @phpstan-type MetaConfig array + * @api */ final class CodestarMeta implements Processable { diff --git a/src/Features/Meta/Meta.php b/src/Features/Meta/Meta.php index c591d665..8489a4ba 100644 --- a/src/Features/Meta/Meta.php +++ b/src/Features/Meta/Meta.php @@ -21,6 +21,7 @@ * Class Meta * * Enable an option to manage meta fields + * @api */ final class Meta implements Service, Conditional, Assembly, RestRouteProvider, ToolContributor { diff --git a/src/Features/Meta/MetaFieldProvider.php b/src/Features/Meta/MetaFieldProvider.php index 9a729051..978f9f18 100644 --- a/src/Features/Meta/MetaFieldProvider.php +++ b/src/Features/Meta/MetaFieldProvider.php @@ -7,6 +7,7 @@ /** * Provides model-defined meta field payloads and normalized field metadata. + * @api */ class MetaFieldProvider { @@ -65,7 +66,7 @@ public function post_type_meta( Modeler $modeler, ?ModelRestPolicy $policy, stri 'status' => 404, 'hint' => \sprintf( /* translators: %s: post type slug */ - __( "Model '%s' is not registered or the post type is not enabled. Check the model slug and ensure it has 'saltus_rest' => [ 'capabilities' => [ 'meta' => true ] ] in src/models/.", 'saltus-framework' ), + __( "Model '%s' is not registered or the post type is not enabled. Check the model slug and add 'show_in_rest' => true under the 'meta' section in its config.", 'saltus-framework' ), $post_type ), ] diff --git a/src/Features/QuickEdit/QuickEdit.php b/src/Features/QuickEdit/QuickEdit.php index ef392554..c73bef7c 100644 --- a/src/Features/QuickEdit/QuickEdit.php +++ b/src/Features/QuickEdit/QuickEdit.php @@ -10,6 +10,7 @@ /** * Adds custom admin columns in the post type archive + * @api */ class QuickEdit implements Service, Conditional, Assembly { diff --git a/src/Features/RememberTabs/RememberTabs.php b/src/Features/RememberTabs/RememberTabs.php index 8f826ee7..5594ad9f 100644 --- a/src/Features/RememberTabs/RememberTabs.php +++ b/src/Features/RememberTabs/RememberTabs.php @@ -11,6 +11,7 @@ * Class RememberTabs * * Enable an option to remember the last active tab in the admin area. + * @api */ class RememberTabs implements Service, Conditional, Assembly { diff --git a/src/Features/Settings/CodestarSettings.php b/src/Features/Settings/CodestarSettings.php index a150a512..a84af618 100644 --- a/src/Features/Settings/CodestarSettings.php +++ b/src/Features/Settings/CodestarSettings.php @@ -8,8 +8,9 @@ final class CodestarSettings implements Processable { /** - * @var string $name The name of the custom post type (CPT) - */ + * @var string $name The name of the custom post type (CPT) + * @api + */ private $name; /** diff --git a/src/Features/Settings/Settings.php b/src/Features/Settings/Settings.php index 699da751..7ab3c7c0 100644 --- a/src/Features/Settings/Settings.php +++ b/src/Features/Settings/Settings.php @@ -20,6 +20,7 @@ * Class Settings * * Enable an option to create Settings page + * @api */ final class Settings implements Service, Conditional, Assembly, RestRouteProvider, ToolContributor { diff --git a/src/Features/Settings/SettingsManager.php b/src/Features/Settings/SettingsManager.php index dc3b0c69..ab7be900 100644 --- a/src/Features/Settings/SettingsManager.php +++ b/src/Features/Settings/SettingsManager.php @@ -3,6 +3,7 @@ /** * Shared feature-layer access to Saltus per-post-type settings. + * @api */ class SettingsManager { diff --git a/src/Features/SingleExport/SingleExport.php b/src/Features/SingleExport/SingleExport.php index d72f562c..2cac70bf 100644 --- a/src/Features/SingleExport/SingleExport.php +++ b/src/Features/SingleExport/SingleExport.php @@ -19,6 +19,7 @@ * Class SingleExport * * Enable an option to export single entry + * @api */ class SingleExport implements Service, Conditional, Assembly, RestRouteProvider, ToolContributor { diff --git a/src/Infrastructure/Container/CanRegister.php b/src/Infrastructure/Container/CanRegister.php index f903a1cf..8f4f10b7 100644 --- a/src/Infrastructure/Container/CanRegister.php +++ b/src/Infrastructure/Container/CanRegister.php @@ -4,6 +4,7 @@ /** * Something that triggers class instantiation * + * @api */ interface CanRegister { diff --git a/src/Infrastructure/Container/Container.php b/src/Infrastructure/Container/Container.php index 3f033cf9..86da41e0 100644 --- a/src/Infrastructure/Container/Container.php +++ b/src/Infrastructure/Container/Container.php @@ -16,6 +16,7 @@ * * @extends Traversable * @extends ArrayAccess + * @api */ interface Container extends Traversable, Countable, ArrayAccess { diff --git a/src/Infrastructure/Container/ContainerAssembler.php b/src/Infrastructure/Container/ContainerAssembler.php index b2d84c74..9a8b8170 100644 --- a/src/Infrastructure/Container/ContainerAssembler.php +++ b/src/Infrastructure/Container/ContainerAssembler.php @@ -4,6 +4,7 @@ /** * A simplified implementation of a container Assembler. * + * @api */ class ContainerAssembler { diff --git a/src/Infrastructure/Container/Instantiator.php b/src/Infrastructure/Container/Instantiator.php index 146fea60..25421e84 100644 --- a/src/Infrastructure/Container/Instantiator.php +++ b/src/Infrastructure/Container/Instantiator.php @@ -6,6 +6,7 @@ * * This way, a more elaborate mechanism can be plugged in, like using * ProxyManager to instantiate proxies instead of actual objects. + * @api */ interface Instantiator { diff --git a/src/Infrastructure/Container/ServiceContainer.php b/src/Infrastructure/Container/ServiceContainer.php index ecab168d..0685b26c 100644 --- a/src/Infrastructure/Container/ServiceContainer.php +++ b/src/Infrastructure/Container/ServiceContainer.php @@ -28,6 +28,7 @@ * Can trigger service registration proccess with CanRegister. * * @extends ArrayObject + * @api */ class ServiceContainer extends ArrayObject diff --git a/src/Infrastructure/Container/SimpleContainer.php b/src/Infrastructure/Container/SimpleContainer.php index 2824aa0d..1d088838 100644 --- a/src/Infrastructure/Container/SimpleContainer.php +++ b/src/Infrastructure/Container/SimpleContainer.php @@ -16,6 +16,7 @@ * array access. * * @extends ArrayObject + * @api */ class SimpleContainer extends ArrayObject implements Container { diff --git a/src/Infrastructure/Plugin/Activateable.php b/src/Infrastructure/Plugin/Activateable.php index 81b3c33a..3ab58f21 100644 --- a/src/Infrastructure/Plugin/Activateable.php +++ b/src/Infrastructure/Plugin/Activateable.php @@ -9,6 +9,7 @@ * * This way, we can just add the simple interface marker and not worry about how * to wire up the code to reach that part during the static activation hook. + * @api */ interface Activateable { diff --git a/src/Infrastructure/Plugin/Deactivateable.php b/src/Infrastructure/Plugin/Deactivateable.php index ad14e942..4f67bc2d 100644 --- a/src/Infrastructure/Plugin/Deactivateable.php +++ b/src/Infrastructure/Plugin/Deactivateable.php @@ -9,6 +9,7 @@ * * This way, we can just add the simple interface marker and not worry about how * to wire up the code to reach that part during the static deactivation hook. + * @api */ interface Deactivateable { diff --git a/src/Infrastructure/Plugin/Plugin.php b/src/Infrastructure/Plugin/Plugin.php index 9a0dd177..cc55f1b3 100644 --- a/src/Infrastructure/Plugin/Plugin.php +++ b/src/Infrastructure/Plugin/Plugin.php @@ -16,6 +16,7 @@ * Additionally, we provide a means to get access to the plugin's container that * collects all the features it is made up of. This allows direct access to the * features to outside code if needed. + * @api */ interface Plugin extends Activateable, Deactivateable, Registerable { diff --git a/src/Infrastructure/Plugin/Project.php b/src/Infrastructure/Plugin/Project.php index 6615de91..4eb098c0 100644 --- a/src/Infrastructure/Plugin/Project.php +++ b/src/Infrastructure/Plugin/Project.php @@ -3,6 +3,7 @@ /** * The Project class, where data is defined. + * @api */ class Project { diff --git a/src/Infrastructure/Plugin/Registerable.php b/src/Infrastructure/Plugin/Registerable.php index 5c2512ca..a51f4f5c 100644 --- a/src/Infrastructure/Plugin/Registerable.php +++ b/src/Infrastructure/Plugin/Registerable.php @@ -12,6 +12,7 @@ * * Registering such an object is the explicit act of making it known to the * overarching system. + * @api */ interface Registerable { diff --git a/src/Infrastructure/Service/Actionable.php b/src/Infrastructure/Service/Actionable.php index 955de3e8..059af018 100644 --- a/src/Infrastructure/Service/Actionable.php +++ b/src/Infrastructure/Service/Actionable.php @@ -10,6 +10,7 @@ * * This allows for a more systematic and automated optimization of how the * different parts of the plugin are enabled or disabled. + * @api */ interface Actionable { diff --git a/src/Infrastructure/Service/App.php b/src/Infrastructure/Service/App.php index 4eb51a8c..dc5cf41f 100644 --- a/src/Infrastructure/Service/App.php +++ b/src/Infrastructure/Service/App.php @@ -15,6 +15,7 @@ * array access. * * @deprecated 0.1.1 Use Infrastructure/Container + * @api */ final class App extends ServiceContainer diff --git a/src/Infrastructure/Service/Assembly.php b/src/Infrastructure/Service/Assembly.php index bbf68cd3..c0bb156a 100644 --- a/src/Infrastructure/Service/Assembly.php +++ b/src/Infrastructure/Service/Assembly.php @@ -7,6 +7,7 @@ * Splitting your logic up into independent services makes the approach of * assembling a plugin more systematic and scalable and lowers the cognitive * load when the code base increases in size. + * @api */ interface Assembly { diff --git a/src/Infrastructure/Service/Conditional.php b/src/Infrastructure/Service/Conditional.php index fbf6276f..d7f62e88 100644 --- a/src/Infrastructure/Service/Conditional.php +++ b/src/Infrastructure/Service/Conditional.php @@ -10,6 +10,7 @@ * * This allows for a more systematic and automated optimization of how the * different parts of the plugin are enabled or disabled. + * @api */ interface Conditional { diff --git a/src/Infrastructure/Service/Factory.php b/src/Infrastructure/Service/Factory.php index 7af2da48..d384da13 100644 --- a/src/Infrastructure/Service/Factory.php +++ b/src/Infrastructure/Service/Factory.php @@ -1,6 +1,9 @@ * } + * @api */ class AbilityDefinitionFactory { @@ -60,7 +62,7 @@ public function from_tool( ToolInterface $tool ): array { 'name' => $this->ability_name( $tool->get_name() ), 'label' => $this->label_from_tool_name( $tool->get_name() ), 'description' => $tool->get_description(), - 'category' => 'saltus-framework', + 'category' => MCPConfig::get_ability_category()['id'], 'input_schema' => $schema, 'inputSchema' => $schema, 'execute_callback' => function ( array $args = [] ) use ( $tool ) { @@ -74,7 +76,7 @@ public function from_tool( ToolInterface $tool ): array { }, 'meta' => [ 'mcp_tool' => $tool->get_name(), - 'namespace' => 'saltus-framework/v1', + 'namespace' => MCPConfig::get_namespace(), 'transport' => 'wordpress-rest', 'show_in_rest' => true, ], @@ -122,7 +124,8 @@ private function normalize_args( $args ): array { * @return lowercase-string&non-falsy-string */ private function ability_name( string $tool_name ): string { - return strtolower( 'saltus/' . str_replace( '_', '-', $tool_name ) ); + /** @var lowercase-string&non-falsy-string */ + return strtolower( MCPConfig::get_ability_prefix() . str_replace( '_', '-', $tool_name ) ); } /** diff --git a/src/MCP/Abilities/AbilityRegistrar.php b/src/MCP/Abilities/AbilityRegistrar.php index 0d1f03f6..7cb6b5a4 100644 --- a/src/MCP/Abilities/AbilityRegistrar.php +++ b/src/MCP/Abilities/AbilityRegistrar.php @@ -1,31 +1,33 @@ tool_provider = $tool_provider ?? new ToolProvider(); $this->definition_factory = $definition_factory ?? new AbilityDefinitionFactory(); - $this->policy = $policy; + $this->mcp_policy = $mcp_policy; } /** @@ -45,11 +47,13 @@ public function register_category(): void { return; } + $category = MCPConfig::get_ability_category(); + \wp_register_ability_category( - 'saltus-framework', + $category['id'], [ - 'label' => 'Saltus Framework', - 'description' => 'Saltus Framework content modeling and administration abilities.', + 'label' => $category['label'], + 'description' => $category['description'], ] ); } @@ -84,13 +88,13 @@ public function register(): array { } /** - * Check whether a tool is enabled based on the model REST policy. + * Check whether a tool is enabled based on the MCP policy. * * @param ToolInterface $tool The tool to check. * @return bool */ private function is_enabled_tool( ToolInterface $tool ): bool { - if ( ! $this->policy ) { + if ( ! $this->mcp_policy ) { return true; } @@ -103,6 +107,6 @@ private function is_enabled_tool( ToolInterface $tool ): bool { return true; } - return $this->policy->has_capability( $requirement->get_capability(), $requirement->get_model_type() ); + return $this->mcp_policy->has_capability( $requirement->get_capability(), $requirement->get_model_type() ); } } diff --git a/src/MCP/Abilities/AbilityRuntime.php b/src/MCP/Abilities/AbilityRuntime.php index 836e881b..4482326d 100644 --- a/src/MCP/Abilities/AbilityRuntime.php +++ b/src/MCP/Abilities/AbilityRuntime.php @@ -4,6 +4,8 @@ use Saltus\WP\Framework\MCP\Audit\AuditEntry; use Saltus\WP\Framework\MCP\Audit\AuditLogger; use Saltus\WP\Framework\MCP\Cache\TransientCache; +use Saltus\WP\Framework\MCP\Middleware\MiddlewarePipeline; +use Saltus\WP\Framework\MCP\Middleware\RequestContext; use Saltus\WP\Framework\MCP\RateLimiter\RateLimiter; use Saltus\WP\Framework\MCP\Tools\RestBackedToolInterface; use Saltus\WP\Framework\MCP\Tools\ToolInterface; @@ -11,6 +13,10 @@ /** * Coordinates validation, rate limiting, REST dispatch, caching, and audit logging for MCP tool execution. + * + * Now delegates to the middleware pipeline when available; falls back to + * the original inline orchestration for backward compatibility. + * @api */ class AbilityRuntime { use \Saltus\WP\Framework\Infrastructure\Services\FilterAwareTrait; @@ -18,31 +24,140 @@ class AbilityRuntime { private AuditLogger $audit_logger; private RateLimiter $rate_limiter; private TransientCache $cache; + private ?MiddlewarePipeline $pipeline; /** * @param AuditLogger|null $audit_logger Optional audit logger. * @param RateLimiter|null $rate_limiter Optional rate limiter. * @param TransientCache|null $cache Optional cache backend. + * @param MiddlewarePipeline|null $pipeline Optional middleware pipeline. */ public function __construct( ?AuditLogger $audit_logger = null, ?RateLimiter $rate_limiter = null, - ?TransientCache $cache = null + ?TransientCache $cache = null, + ?MiddlewarePipeline $pipeline = null ) { $this->audit_logger = $audit_logger ?? new AuditLogger(); $this->rate_limiter = $rate_limiter ?? new RateLimiter(); $this->cache = $cache ?? new TransientCache(); + $this->pipeline = $pipeline; } /** * Validate, rate-limit, dispatch, cache, and audit an MCP tool execution. * + * When a middleware pipeline is configured, delegates to it. Otherwise + * falls back to the original sequential orchestration. + * * @param ToolInterface $tool The tool to execute. * @param array $args Arguments to pass to the tool. * @return array|\WP_Error Tool result or error. */ - // phpcs:ignore Generic.Metrics.CyclomaticComplexity.TooHigh -- Runtime coordinates validation, rate limiting, dispatch, cache, and audit. + // phpcs:ignore Generic.Metrics.CyclomaticComplexity.TooHigh public function execute( ToolInterface $tool, array $args ) { + if ( $this->pipeline !== null ) { + return $this->execute_via_pipeline( $tool, $args ); + } + + return $this->execute_legacy( $tool, $args ); + } + + /** + * Execute the tool via the middleware pipeline. + * + * @param ToolInterface $tool + * @param array $args + * @return array|\WP_Error + */ + // phpcs:ignore Generic.Metrics.CyclomaticComplexity.TooHigh -- Pipeline setup, dispatch, and result handling. + private function execute_via_pipeline( ToolInterface $tool, array $args ) { + $context = new RequestContext(); + $context->set_args( $args ); + $context->set_tool_metadata( [ + 'name' => $tool->get_name(), + 'parameters' => $tool->get_parameters(), + 'has_permission' => [ $tool, 'has_permission' ], + 'capability' => $tool instanceof RestBackedToolInterface && $tool->get_rest_capability() !== null + ? $tool->get_rest_capability()->get_capability() + : 'edit_posts', + ] ); + + $cache_ttl = $tool instanceof RestBackedToolInterface ? $tool->cache_ttl() : 300; + $context->set_attribute( 'cache_ttl', $cache_ttl ); + + $result = $this->pipeline->execute( + $context, + function ( RequestContext $ctx ) use ( $tool, $args ) { + if ( ! $tool instanceof RestBackedToolInterface ) { + return \Saltus\WP\Framework\MCP\Error\ErrorResponse::internal_error( + \__( 'This tool does not support REST dispatch.', 'saltus-framework' ) + ); + } + + $request = $tool->build_rest_request( $args ); + if ( $request === null ) { + return \Saltus\WP\Framework\MCP\Error\ErrorResponse::internal_error( + \__( 'This Saltus ability is registered for discovery only until a native dispatcher is available.', 'saltus-framework' ) + ); + } + + if ( ! \function_exists( 'rest_do_request' ) ) { + return \Saltus\WP\Framework\MCP\Error\ErrorResponse::internal_error( + \__( 'WordPress REST dispatch is not available.', 'saltus-framework' ) + ); + } + + $ctx->set_rest_request( $request ); + + try { + $response = \rest_do_request( $request ); + + /** @phpstan-ignore-next-line rest_do_request can return WP_Error (WordPress stubs may not include it in the return type) */ + if ( \is_wp_error( $response ) ) { + return \Saltus\WP\Framework\MCP\Error\ErrorResponse::dispatch_error( $response ); + } + + $status = (int) $response->get_status(); + $data = $response->get_data(); + $result = \is_array( $data ) ? $data : [ 'result' => $data ]; + + if ( $status >= 400 ) { + $error_code = \is_array( $data ) ? (string) ( $data['code'] ?? 'rest_error' ) : 'rest_error'; + $error_msg = \is_array( $data ) ? (string) ( $data['message'] ?? 'REST error' ) : 'REST error'; + + return new \WP_Error( $error_code, $error_msg, [ 'status' => $status ] ); + } + + $ctx->set_response( $response ); + + return $result; + } catch ( \Throwable $e ) { + return \Saltus\WP\Framework\MCP\Error\ErrorResponse::internal_error( + $e->getMessage(), + \__( 'An unexpected error occurred during tool execution.', 'saltus-framework' ) + ); + } + } + ); + + if ( $result instanceof \WP_REST_Response ) { + $data = $result->get_data(); + return \is_array( $data ) ? $data : [ 'result' => $data ]; + } + + return $result; + } + + /** + * Original sequential execution (backward compatibility path). + * + * @param ToolInterface $tool + * @param array $args + * @return array|\WP_Error + */ + // phpcs:ignore Generic.Metrics.CyclomaticComplexity.TooHigh + private function execute_legacy( ToolInterface $tool, array $args ) { $entry = new AuditEntry( $tool->get_name(), $args, $this->identifier() ); $valid = Validator::validate( $args, $tool->get_parameters() ); diff --git a/src/MCP/Audit/AuditDatabase.php b/src/MCP/Audit/AuditDatabase.php index a1db780f..716a0200 100644 --- a/src/MCP/Audit/AuditDatabase.php +++ b/src/MCP/Audit/AuditDatabase.php @@ -1,6 +1,9 @@ 403 ]; + if ( $hint !== '' ) { + $data['hint'] = $hint; + } + + return new \WP_Error( 'rest_forbidden', __( 'You do not have permission to perform this action.', 'saltus-framework' ), $data ); + } + + /** + * Resource not found. + * + * @param string $resource_name The resource identifier. + * @param string $hint User-facing resolution hint. + * @return \WP_Error + */ + public static function not_found( string $resource_name = '', string $hint = '' ): \WP_Error { + $data = [ 'status' => 404 ]; + if ( $hint !== '' ) { + $data['hint'] = $hint; + } + + return new \WP_Error( + $resource_name !== '' ? $resource_name . '_not_found' : 'not_found', + __( 'Resource not found.', 'saltus-framework' ), + $data + ); + } + + /** + * Invalid request parameters. + * + * @param string $field The invalid field name. + * @param string $reason Why the field is invalid. + * @param string $hint User-facing resolution hint. + * @return \WP_Error + */ + public static function invalid( string $field = '', string $reason = '', string $hint = '' ): \WP_Error { + $data = [ 'status' => 400 ]; + if ( $field !== '' ) { + $data['field'] = $field; + } + if ( $reason !== '' ) { + $data['reason'] = $reason; + } + if ( $hint !== '' ) { + $data['hint'] = $hint; + } + + return new \WP_Error( 'rest_invalid_param', __( 'Invalid parameter.', 'saltus-framework' ), $data ); + } + + /** + * Rate limited. + * + * @param int $retry_after Seconds to wait before retrying. + * @return \WP_Error + */ + public static function rate_limited( int $retry_after = 60 ): \WP_Error { + return new \WP_Error( + 'rate_limited', + __( 'Rate limit exceeded.', 'saltus-framework' ), + [ + 'status' => 429, + 'retry_after' => $retry_after, + ] + ); + } + + /** + * Internal server error. + * + * @param string $message Error message. + * @param string $hint User-facing resolution hint. + * @return \WP_Error + */ + public static function internal_error( string $message = '', string $hint = '' ): \WP_Error { + $data = [ 'status' => 500 ]; + if ( $hint !== '' ) { + $data['hint'] = $hint; + } + + return new \WP_Error( 'rest_internal_error', $message ?: __( 'Internal server error.', 'saltus-framework' ), $data ); + } + + /** + * REST dispatch error wrapping an upstream WP_Error. + * + * @param \WP_Error $error The upstream error. + * @return \WP_Error + */ + public static function dispatch_error( \WP_Error $error ): \WP_Error { + $data = $error->get_error_data(); + $status = is_array( $data ) && isset( $data['status'] ) ? (int) $data['status'] : 500; + $error_data = is_array( $data ) ? $data : [ 'status' => $status ]; + $error_data['hint'] = $error_data['hint'] ?? __( 'The upstream REST endpoint returned an error. Check the error code and message for details.', 'saltus-framework' ); + + return new \WP_Error( + $error->get_error_code() ?: 'rest_dispatch_error', + $error->get_error_message() ?: __( 'REST dispatch error.', 'saltus-framework' ), + $error_data + ); + } +} diff --git a/src/MCP/MCPConfig.php b/src/MCP/MCPConfig.php new file mode 100644 index 00000000..2f77d417 --- /dev/null +++ b/src/MCP/MCPConfig.php @@ -0,0 +1,88 @@ + 'saltus-framework', + * 'label' => 'Saltus Framework', + * 'description' => 'Saltus Framework content modeling and administration abilities.', + * ] + * + * @return array{id: string, label: string, description: string} + */ + public static function get_ability_category(): array { + $default = [ + 'id' => 'saltus-framework', + 'label' => 'Saltus Framework', + 'description' => 'Saltus Framework content modeling and administration abilities.', + ]; + + $filtered = \apply_filters( + 'saltus/framework/mcp/ability_category', + $default + ); + + if ( ! is_array( $filtered ) ) { + return $default; + } + + $sanitized = []; + foreach ( [ 'id', 'label', 'description' ] as $key ) { + $val = $filtered[ $key ] ?? null; + $sanitized[ $key ] = is_string( $val ) ? $val : $default[ $key ]; + } + + /** @var array{id: string, label: string, description: string} */ + return $sanitized; + } + + /** + * Get the prefix used when generating ability names from tool names. + * + * Default: 'saltus/' + * + * @return string + */ + public static function get_ability_prefix(): string { + $prefix = \apply_filters( + 'saltus/framework/mcp/ability_prefix', + 'saltus/' + ); + if ( ! is_string( $prefix ) ) { + return 'saltus/'; + } + return $prefix; + } +} diff --git a/src/MCP/McpPolicy.php b/src/MCP/McpPolicy.php new file mode 100644 index 00000000..bbdca964 --- /dev/null +++ b/src/MCP/McpPolicy.php @@ -0,0 +1,153 @@ +modeler = $modeler; + } + + /** + * Check if a capability is enabled. + * + * @param string $capability The capability. + * @param string|null $model_type The model type. + * + * @return bool + */ + public function has_capability( string $capability, ?string $model_type = null ): bool { + if ( $capability === ModelRestPolicy::CAPABILITY_HEALTH ) { + return true; + } + + foreach ( $this->modeler->get_models() as $model ) { + if ( $model_type !== null && $model->get_type() !== $model_type ) { + continue; + } + + if ( $this->is_enabled( $model, $capability ) ) { + return true; + } + } + + return false; + } + + /** + * Check if a capability is enabled for a model. + * + * @param Model $model The model. + * @param string $capability The capability. + * + * @return bool + */ + public function is_enabled( Model $model, string $capability ): bool { + $options = $model->get_options(); + $global_val = null; + if ( array_key_exists( 'mcp_tools', $options ) ) { + $global_val = (bool) $options['mcp_tools']; + } + + if ( $capability === ModelRestPolicy::CAPABILITY_HEALTH ) { + return true; + } + + if ( $capability === ModelRestPolicy::CAPABILITY_MODELS ) { + return $global_val === true; + } + + $config = $model->get_config(); + $feature_val = $this->resolve_feature_value( $config, $capability ); + + if ( $feature_val !== null ) { + return $feature_val; + } + + return $global_val === true; + } + + /** + * Get the configuration section for a specific capability. + * + * @param array $config The model configuration. + * @param string $capability The capability. + * @return mixed + */ + private function get_capability_config( array $config, string $capability ) { + if ( $capability === ModelRestPolicy::CAPABILITY_META || $capability === ModelRestPolicy::CAPABILITY_SETTINGS ) { + return $config[ $capability ] ?? null; + } + + $features = $config['features'] ?? []; + if ( ! is_array( $features ) ) { + return null; + } + + $feature_keys = [ + ModelRestPolicy::CAPABILITY_DUPLICATE => 'duplicate', + ModelRestPolicy::CAPABILITY_EXPORT => 'single_export', + ModelRestPolicy::CAPABILITY_REORDER => 'drag_and_drop', + ]; + + $key = $feature_keys[ $capability ] ?? null; + if ( $key === null ) { + return null; + } + + return $features[ $key ] ?? null; + } + + /** + * Resolve whether a capability should be shown in MCP. + * + * @param array $config + * @param string $capability + * @return bool|null + */ + private function resolve_feature_value( array $config, string $capability ): ?bool { + $section = $this->get_capability_config( $config, $capability ); + if ( $section === null ) { + return null; + } + + if ( ! is_array( $section ) ) { + return (bool) $section; + } + + if ( ! array_key_exists( 'show_in_mcp', $section ) ) { + return true; + } + + return (bool) $section['show_in_mcp']; + } + + /** + * Get a model by name. + * + * @param string $name The model name. + * @return Model|null + */ + public function get_model( string $name ): ?Model { + $models = $this->modeler->get_models(); + + return $models[ $name ] ?? null; + } +} diff --git a/src/MCP/Middleware/AuditMiddleware.php b/src/MCP/Middleware/AuditMiddleware.php new file mode 100644 index 00000000..0f59d1d1 --- /dev/null +++ b/src/MCP/Middleware/AuditMiddleware.php @@ -0,0 +1,102 @@ +logger = $logger; + } + + /** + * @param RequestContext $context + * @param callable $next + * @return \WP_REST_Response|\WP_Error + */ + public function handle( RequestContext $context, callable $next ) { + $tool_name = $this->resolve_tool_name( $context ); + $args = $this->resolve_args( $context ); + + $entry = new AuditEntry( $tool_name, $args, $this->identifier() ); + + $result = $next( $context ); + + if ( is_wp_error( $result ) ) { + $entry->complete( + 'error', + (string) $result->get_error_code(), + (string) $result->get_error_message() + ); + } else { + $entry->complete( 'success' ); + } + + $this->logger->record( $entry ); + + return $result; + } + + /** + * Resolve the tool or route name for the audit entry. + * + * @param RequestContext $context + * @return string + */ + private function resolve_tool_name( RequestContext $context ): string { + $meta = $context->get_tool_metadata(); + if ( ! empty( $meta['name'] ) && is_string( $meta['name'] ) ) { + return $meta['name']; + } + + $route = $context->get_route(); + if ( $route !== '' ) { + return 'rest:' . $route; + } + + return 'unknown'; + } + + /** + * Resolve the arguments for the audit entry. + * + * @param RequestContext $context + * @return array + */ + private function resolve_args( RequestContext $context ): array { + $args = $context->get_args(); + if ( ! empty( $args ) ) { + return $args; + } + + $request = $context->get_rest_request(); + if ( $request !== null ) { + return $request->get_params(); + } + + return []; + } + + /** + * Resolve a unique identifier for the current user for audit. + * + * @return string + */ + private function identifier(): string { + $identifier = \function_exists( 'get_current_user_id' ) ? 'user:' . (int) \get_current_user_id() : 'user:0'; + if ( $identifier === 'user:0' && isset( $_SERVER['REMOTE_ADDR'] ) ) { + $identifier = 'ip:' . \hash( 'sha256', (string) $_SERVER['REMOTE_ADDR'] ); + } + + return $identifier; + } +} diff --git a/src/MCP/Middleware/CacheMiddleware.php b/src/MCP/Middleware/CacheMiddleware.php new file mode 100644 index 00000000..6af03420 --- /dev/null +++ b/src/MCP/Middleware/CacheMiddleware.php @@ -0,0 +1,158 @@ +cache = $cache; + } + + /** + * @param RequestContext $context + * @param callable $next + * @return \WP_REST_Response|\WP_Error + */ + public function handle( RequestContext $context, callable $next ) { + if ( ! $this->is_get_request( $context ) ) { + $result = $next( $context ); + $this->invalidate_on_mutation( $context ); + return $result; + } + + $cache_key = $this->build_cache_key( $context ); + + $cached = $this->cache->get( $cache_key ); + if ( $cached !== null ) { + return \rest_ensure_response( $cached ); + } + + $result = $next( $context ); + + if ( ! \is_wp_error( $result ) ) { + $ttl = $this->resolve_ttl( $context ); + $data = $result->get_data(); + if ( is_array( $data ) ) { + $this->cache->set( $cache_key, $data, $ttl ); + } + } + + return $result; + } + + /** + * Check whether the current request is a GET. + * + * @param RequestContext $context + * @return bool + */ + private function is_get_request( RequestContext $context ): bool { + $meta = $context->get_tool_metadata(); + if ( ! empty( $meta ) ) { + return true; + } + + $request = $context->get_rest_request(); + if ( $request === null ) { + return false; + } + + return $request->get_method() === 'GET'; + } + + /** + * Build a unique cache key from the context. + * + * @param RequestContext $context + * @return string + */ + private function build_cache_key( RequestContext $context ): string { + $tool_name = $this->resolve_name( $context ); + $args = $context->get_args(); + + $payload = [ + 'tool' => $tool_name, + 'args' => $args, + 'user' => \function_exists( 'get_current_user_id' ) ? (int) \get_current_user_id() : 0, + 'locale' => \function_exists( 'get_locale' ) ? \get_locale() : '', + ]; + + return 'saltus_mcp_' . \hash( 'sha256', $this->encode( $payload ) ); + } + + /** + * Resolve a name for cache key generation. + * + * @param RequestContext $context + * @return string + */ + private function resolve_name( RequestContext $context ): string { + $meta = $context->get_tool_metadata(); + if ( ! empty( $meta['name'] ) && is_string( $meta['name'] ) ) { + return $meta['name']; + } + + $route = $context->get_route(); + if ( $route !== '' ) { + return $route; + } + + return 'unknown'; + } + + /** + * Resolve the cache TTL from context attributes. + * + * @param RequestContext $context + * @return int + */ + private function resolve_ttl( RequestContext $context ): int { + $ttl = $context->get_attribute( 'cache_ttl' ); + if ( is_int( $ttl ) && $ttl > 0 ) { + return $ttl; + } + + return 300; + } + + /** + * Invalidate cache on mutating requests. + * + * @param RequestContext $context + */ + private function invalidate_on_mutation( RequestContext $context ): void { + if ( \function_exists( 'wp_defer_term_counting' ) && \function_exists( 'wp_defer_comment_counting' ) ) { + if ( defined( 'WP_IMPORTING' ) && WP_IMPORTING ) { + return; + } + } + + $this->cache->clear(); + } + + /** + * Encode a payload as JSON for cache key generation. + * + * @param array $payload + * @return string + */ + private function encode( array $payload ): string { + if ( \function_exists( 'wp_json_encode' ) ) { + $encoded = \wp_json_encode( $payload ); + return \is_string( $encoded ) ? $encoded : ''; + } + + // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode + $encoded = \json_encode( $payload ); + return \is_string( $encoded ) ? $encoded : ''; + } +} diff --git a/src/MCP/Middleware/MiddlewareInterface.php b/src/MCP/Middleware/MiddlewareInterface.php new file mode 100644 index 00000000..8e8d48ab --- /dev/null +++ b/src/MCP/Middleware/MiddlewareInterface.php @@ -0,0 +1,17 @@ + */ + private array $stages = []; + + /** + * Register a middleware stage. + * + * @param MiddlewareInterface $middleware + */ + public function add( MiddlewareInterface $middleware ): void { + $this->stages[] = $middleware; + } + + /** + * Execute the middleware chain with the given context and dispatch handler. + * + * @param RequestContext $context The mutable request context. + * @param callable $dispatch The innermost dispatch handler. + * @return \WP_REST_Response|\WP_Error + */ + public function execute( RequestContext $context, callable $dispatch ) { + $chain = $this->build_chain( $dispatch ); + return $chain( $context ); + } + + /** + * Build the middleware chain as nested closures. + * + * @param callable $dispatch + * @return callable + */ + private function build_chain( callable $dispatch ): callable { + $chain = $dispatch; + + foreach ( array_reverse( $this->stages ) as $stage ) { + $next = $chain; + $chain = function ( RequestContext $context ) use ( $stage, $next ) { + return $stage->handle( $context, $next ); + }; + } + + return $chain; + } +} diff --git a/src/MCP/Middleware/PermissionMiddleware.php b/src/MCP/Middleware/PermissionMiddleware.php new file mode 100644 index 00000000..1af1c516 --- /dev/null +++ b/src/MCP/Middleware/PermissionMiddleware.php @@ -0,0 +1,198 @@ +policy = $policy; + } + + /** + * @param RequestContext $context + * @param callable $next + * @return \WP_REST_Response|\WP_Error + */ + public function handle( RequestContext $context, callable $next ) { + $tool_meta = $context->get_tool_metadata(); + + if ( ! empty( $tool_meta ) ) { + return $this->handle_tool( $context, $next, $tool_meta ); + } + + return $this->handle_rest( $context, $next ); + } + + /** + * Handle permission check for MCP tool execution. + * + * @param RequestContext $context + * @param callable $next + * @param array $tool_meta + * @return \WP_REST_Response|\WP_Error + */ + private function handle_tool( RequestContext $context, callable $next, array $tool_meta ) { + $has_permission = $tool_meta['has_permission'] ?? null; + + if ( is_callable( $has_permission ) ) { + $allowed = $has_permission( $context->get_args() ); + if ( ! $allowed ) { + return \Saltus\WP\Framework\MCP\Error\ErrorResponse::forbidden( + $tool_meta['capability'] ?? 'edit_posts', + \__( 'You do not have permission to use this tool.', 'saltus-framework' ) + ); + } + } + + return $next( $context ); + } + + /** + * Handle permission check for REST API requests. + * + * @param RequestContext $context + * @param callable $next + * @return \WP_REST_Response|\WP_Error + */ + private function handle_rest( RequestContext $context, callable $next ) { + $request = $context->get_rest_request(); + if ( $request === null ) { + return $next( $context ); + } + + $route = $request->get_route(); + $capability = $this->resolve_capability( $route ); + + if ( $capability === null ) { + return $next( $context ); + } + + if ( ! \function_exists( 'current_user_can' ) ) { + return \Saltus\WP\Framework\MCP\Error\ErrorResponse::internal_error( + \__( 'WordPress capability system is unavailable.', 'saltus-framework' ) + ); + } + + if ( ! \current_user_can( 'edit_posts' ) ) { + return \Saltus\WP\Framework\MCP\Error\ErrorResponse::forbidden( + 'edit_posts', + \__( 'You must have edit_posts capability to access Saltus API.', 'saltus-framework' ) + ); + } + + $model_name = $request->get_param( 'post_type' ); + if ( is_string( $model_name ) && $model_name !== '' ) { + $cap = $this->resolve_model_capability( $model_name ); + if ( ! \current_user_can( $cap ) ) { + return \Saltus\WP\Framework\MCP\Error\ErrorResponse::forbidden( + $cap, + \sprintf( + /* translators: %s: model name */ + \__( "You do not have permission to access model '%s'.", 'saltus-framework' ), + $model_name + ) + ); + } + } + + $model_name = $request->get_param( 'id' ); + if ( is_string( $model_name ) && $model_name !== '' ) { + $post = \get_post( (int) $model_name ); + if ( $post && ! \current_user_can( 'edit_post', $post->ID ) ) { + return \Saltus\WP\Framework\MCP\Error\ErrorResponse::forbidden( + 'edit_post', + \__( 'You do not have permission to edit this post.', 'saltus-framework' ) + ); + } + } + + return $next( $context ); + } + + /** + * Resolve the capability key from a REST route. + * + * @param string $route + * @return string|null + */ + private function resolve_capability( string $route ): ?string { + $route_map = [ + 'models' => CapabilityPolicy::CAPABILITY_MODELS, + 'meta' => CapabilityPolicy::CAPABILITY_META, + 'settings' => CapabilityPolicy::CAPABILITY_SETTINGS, + 'duplicate' => CapabilityPolicy::CAPABILITY_DUPLICATE, + 'export' => CapabilityPolicy::CAPABILITY_EXPORT, + 'reorder' => CapabilityPolicy::CAPABILITY_REORDER, + 'health' => CapabilityPolicy::CAPABILITY_HEALTH, + ]; + + $parts = explode( '/', trim( $route, '/' ) ); + $path = $parts[2] ?? ''; + + return $route_map[ $path ] ?? null; + } + + /** + * Resolve the WordPress edit capability for a model (post type or taxonomy). + * + * @param string $model_name + * @return string + */ + private function resolve_model_capability( string $model_name ): string { + $model = $this->policy->get_model( $model_name ); + if ( $model === null ) { + return 'edit_posts'; + } + + if ( $model->get_type() === 'taxonomy' ) { + return $this->taxonomy_edit_capability( $model_name ); + } + + return $this->post_type_edit_capability( $model_name ); + } + + /** + * @param string $post_type + * @return string + */ + private function post_type_edit_capability( string $post_type ): string { + if ( ! \function_exists( 'get_post_type_object' ) ) { + return 'edit_posts'; + } + + $post_type_object = \get_post_type_object( $post_type ); + if ( is_object( $post_type_object ) && isset( $post_type_object->cap->edit_posts ) && is_string( $post_type_object->cap->edit_posts ) ) { + return $post_type_object->cap->edit_posts; + } + + return 'edit_posts'; + } + + /** + * @param string $taxonomy + * @return string + */ + private function taxonomy_edit_capability( string $taxonomy ): string { + if ( ! \function_exists( 'get_taxonomy' ) ) { + return 'manage_categories'; + } + + $taxonomy_object = \get_taxonomy( $taxonomy ); + if ( is_object( $taxonomy_object ) && isset( $taxonomy_object->cap->manage_terms ) && is_string( $taxonomy_object->cap->manage_terms ) ) { + return $taxonomy_object->cap->manage_terms; + } + + return 'manage_categories'; + } +} diff --git a/src/MCP/Middleware/PipelineIntegration.php b/src/MCP/Middleware/PipelineIntegration.php new file mode 100644 index 00000000..dcb3a753 --- /dev/null +++ b/src/MCP/Middleware/PipelineIntegration.php @@ -0,0 +1,93 @@ +pipeline = $pipeline; + } + + /** + * Register the rest_pre_dispatch hook to run the middleware pipeline. + * + * The pipeline wraps the actual REST dispatch, running permission, + * validation, rate-limit, cache, and audit middleware around it. + */ + public function register_rest_hooks(): void { + if ( ! \function_exists( 'add_filter' ) ) { + return; + } + + \add_filter( 'rest_pre_dispatch', [ $this, 'on_rest_pre_dispatch' ], 10, 3 ); + } + + /** + * Handle rest_pre_dispatch: run the pipeline and return the result. + * + * @param mixed $result + * @param \WP_REST_Server $server + * @param \WP_REST_Request $request + * @return \WP_REST_Response|\WP_Error|null + */ + public function on_rest_pre_dispatch( $result, $server, $request ) { + $context = new RequestContext(); + $context->set_rest_request( $request ); + $context->set_route( (string) $request->get_route() ); + + $params = $request->get_params(); + $context->set_args( $params ); + + return $this->pipeline->execute( + $context, + function ( RequestContext $ctx ) use ( $request ) { + if ( ! \function_exists( 'rest_do_request' ) ) { + return \Saltus\WP\Framework\MCP\Error\ErrorResponse::internal_error( + \__( 'WordPress REST dispatch is not available.', 'saltus-framework' ) + ); + } + + $response = \rest_do_request( $request ); + $ctx->set_response( $response ); + + return $response; + } + ); + } + + /** + * Build the default pipeline with the standard stage ordering. + * + * Order: Permission → Validation → RateLimit → Cache → Audit + * + * @param MiddlewareInterface ...$stages Optional additional stages appended after defaults. + * @return self + */ + public static function with_default_stages( MiddlewareInterface ...$stages ): self { + $pipeline = new MiddlewarePipeline(); + + $defaults = [ + // Permission stage injected by caller (requires CapabilityPolicy) + // Validation stage injected by caller + // RateLimit stage injected by caller + // Cache stage injected by caller + // Audit stage injected by caller + ]; + + foreach ( $stages as $stage ) { + $pipeline->add( $stage ); + } + + return new self( $pipeline ); + } +} diff --git a/src/MCP/Middleware/RateLimitMiddleware.php b/src/MCP/Middleware/RateLimitMiddleware.php new file mode 100644 index 00000000..6b02443b --- /dev/null +++ b/src/MCP/Middleware/RateLimitMiddleware.php @@ -0,0 +1,61 @@ +limiter = $limiter; + } + + /** + * @param RequestContext $context + * @param callable $next + * @return \WP_REST_Response|\WP_Error + */ + public function handle( RequestContext $context, callable $next ) { + $identifier = $this->resolve_identifier( $context ); + + $result = $this->limiter->check( $identifier ); + + if ( ! $result->allowed ) { + $retry_after = is_int( $result->retry_after ) ? $result->retry_after : 60; + + return \Saltus\WP\Framework\MCP\Error\ErrorResponse::rate_limited( $retry_after ); + } + + $context->set_attribute( 'rate_limit_remaining', $result->remaining ); + $context->set_attribute( 'rate_limit_reset_at', $result->reset_at ); + + return $next( $context ); + } + + /** + * Resolve a unique identifier for rate limiting. + * + * @param RequestContext $context + * @return string + */ + private function resolve_identifier( RequestContext $context ): string { + $meta = $context->get_tool_metadata(); + if ( ! empty( $meta['rate_limit_identifier'] ) && is_string( $meta['rate_limit_identifier'] ) ) { + return $meta['rate_limit_identifier']; + } + + $identifier = \function_exists( 'get_current_user_id' ) ? 'user:' . (int) \get_current_user_id() : 'user:0'; + if ( $identifier === 'user:0' && isset( $_SERVER['REMOTE_ADDR'] ) ) { + $identifier = 'ip:' . \hash( 'sha256', (string) $_SERVER['REMOTE_ADDR'] ); + } + + return $identifier; + } +} diff --git a/src/MCP/Middleware/RequestContext.php b/src/MCP/Middleware/RequestContext.php new file mode 100644 index 00000000..73811e6a --- /dev/null +++ b/src/MCP/Middleware/RequestContext.php @@ -0,0 +1,157 @@ + */ + private array $args = []; + + private ?\WP_REST_Request $rest_request = null; + + /** @var \WP_REST_Response|\WP_Error|null */ + private $response = null; + + /** @var array */ + private array $attributes = []; + + private string $route = ''; + + /** @var array */ + private array $tool_metadata = []; + + /** + * Get the raw request arguments (MCP tool args or REST params). + * + * @return array + */ + public function get_args(): array { + return $this->args; + } + + /** + * Set the raw request arguments. + * + * @param array $args + */ + public function set_args( array $args ): void { + $this->args = $args; + } + + /** + * Get the built REST request, if available. + * + * @return \WP_REST_Request|null + */ + public function get_rest_request(): ?\WP_REST_Request { + return $this->rest_request; + } + + /** + * Set the built REST request. + * + * @param \WP_REST_Request|null $request + */ + public function set_rest_request( ?\WP_REST_Request $request ): void { + $this->rest_request = $request; + } + + /** + * Get the response after dispatch, if available. + * + * @return \WP_REST_Response|\WP_Error|null + */ + public function get_response() { + return $this->response; + } + + /** + * Set the response after dispatch. + * + * @param \WP_REST_Response|\WP_Error|null $response + */ + public function set_response( $response ): void { + $this->response = $response; + } + + /** + * Get all route/tool attributes. + * + * @return array + */ + public function get_attributes(): array { + return $this->attributes; + } + + /** + * Set all route/tool attributes at once. + * + * @param array $attributes + */ + public function set_attributes( array $attributes ): void { + $this->attributes = $attributes; + } + + /** + * Get a single attribute value. + * + * @param string $key + * @param mixed $default_value + * @return mixed + */ + public function get_attribute( string $key, $default_value = null ) { + return $this->attributes[ $key ] ?? $default_value; + } + + /** + * Set a single attribute value. + * + * @param string $key + * @param mixed $value + */ + public function set_attribute( string $key, $value ): void { + $this->attributes[ $key ] = $value; + } + + /** + * Get the REST route string. + * + * @return string + */ + public function get_route(): string { + return $this->route; + } + + /** + * Set the REST route string. + * + * @param string $route + */ + public function set_route( string $route ): void { + $this->route = $route; + } + + /** + * Get the MCP tool metadata (name, parameters, etc.). + * + * @return array + */ + public function get_tool_metadata(): array { + return $this->tool_metadata; + } + + /** + * Set the MCP tool metadata. + * + * @param array $metadata + */ + public function set_tool_metadata( array $metadata ): void { + $this->tool_metadata = $metadata; + } +} diff --git a/src/MCP/Middleware/ValidationMiddleware.php b/src/MCP/Middleware/ValidationMiddleware.php new file mode 100644 index 00000000..2bd9f739 --- /dev/null +++ b/src/MCP/Middleware/ValidationMiddleware.php @@ -0,0 +1,116 @@ +get_tool_metadata(); + $schema = []; + + if ( ! empty( $tool_meta ) && isset( $tool_meta['parameters'] ) ) { + $schema = $tool_meta['parameters']; + } elseif ( $context->get_rest_request() !== null ) { + $schema = $this->build_schema_from_route( $context->get_rest_request() ); + } + + if ( empty( $schema ) ) { + return $next( $context ); + } + + $args = ! empty( $tool_meta ) ? $context->get_args() : $this->extract_args( $context ); + $valid = Validator::validate( $args, $schema ); + + if ( ! $valid['valid'] ) { + return \Saltus\WP\Framework\MCP\Error\ErrorResponse::invalid( + '', + \implode( '; ', $valid['errors'] ), + \__( 'Check the request parameters and ensure all required fields are present with the correct types.', 'saltus-framework' ) + ); + } + + return $next( $context ); + } + + /** + * Build a Validator-compatible schema from a REST request's registered args. + * + * @param \WP_REST_Request $request + * @return array + */ + private function build_schema_from_route( \WP_REST_Request $request ): array { + $schema = []; + $raw_args = $request->get_attributes(); + $args = $raw_args['args'] ?? []; + + foreach ( $args as $field => $config ) { + if ( ! is_array( $config ) ) { + continue; + } + + $rules = []; + + if ( ! empty( $config['required'] ) ) { + $rules['required'] = true; + } + + if ( ! empty( $config['type'] ) && is_string( $config['type'] ) ) { + $rules['type'] = $this->map_wp_type( $config['type'] ); + } + + if ( ! empty( $config['enum'] ) && is_array( $config['enum'] ) ) { + $rules['enum'] = $config['enum']; + } + + $schema[ $field ] = $rules; + } + + return $schema; + } + + /** + * Map WordPress REST API type names to Validator types. + * + * @param string $wp_type + * @return string + */ + private function map_wp_type( string $wp_type ): string { + $map = [ + 'string' => 'string', + 'integer' => 'integer', + 'number' => 'number', + 'boolean' => 'boolean', + 'object' => 'object', + 'array' => 'array', + ]; + + return $map[ $wp_type ] ?? 'string'; + } + + /** + * Extract request arguments from the context. + * + * @param RequestContext $context + * @return array + */ + private function extract_args( RequestContext $context ): array { + $request = $context->get_rest_request(); + if ( $request === null ) { + return []; + } + + return $request->get_params(); + } +} diff --git a/src/MCP/RateLimiter/RateLimitResult.php b/src/MCP/RateLimiter/RateLimitResult.php index c5272940..cde0d63c 100644 --- a/src/MCP/RateLimiter/RateLimitResult.php +++ b/src/MCP/RateLimiter/RateLimitResult.php @@ -3,6 +3,7 @@ /** * Value object representing the outcome of a rate limit check. + * @api */ class RateLimitResult { diff --git a/src/MCP/RateLimiter/RateLimiter.php b/src/MCP/RateLimiter/RateLimiter.php index a8881d0c..03327080 100644 --- a/src/MCP/RateLimiter/RateLimiter.php +++ b/src/MCP/RateLimiter/RateLimiter.php @@ -3,6 +3,7 @@ /** * Sliding-window rate limiter backed by WordPress transients. + * @api */ class RateLimiter { use \Saltus\WP\Framework\Infrastructure\Services\FilterAwareTrait; diff --git a/src/MCP/Tools/DuplicatePost.php b/src/MCP/Tools/DuplicatePost.php index 12ce4b89..26701f37 100644 --- a/src/MCP/Tools/DuplicatePost.php +++ b/src/MCP/Tools/DuplicatePost.php @@ -57,7 +57,7 @@ public function get_rest_capability(): ?RestCapabilityRequirement { * @return \WP_REST_Request|null */ public function build_rest_request( array $args ): ?\WP_REST_Request { - return $this->request( 'POST', '/saltus-framework/v1/duplicate/' . (int) ( $args['post_id'] ?? 0 ) ); + return $this->request( 'POST', $this->mcp_route( '/duplicate/' . (int) ( $args['post_id'] ?? 0 ) ) ); } /** diff --git a/src/MCP/Tools/ExportPost.php b/src/MCP/Tools/ExportPost.php index b2fa772a..485644a9 100644 --- a/src/MCP/Tools/ExportPost.php +++ b/src/MCP/Tools/ExportPost.php @@ -67,7 +67,7 @@ public function get_rest_capability(): ?RestCapabilityRequirement { * @return \WP_REST_Request|null */ public function build_rest_request( array $args ): ?\WP_REST_Request { - return $this->request( 'GET', '/saltus-framework/v1/export/' . (int) ( $args['post_id'] ?? 0 ) ); + return $this->request( 'GET', $this->mcp_route( '/export/' . (int) ( $args['post_id'] ?? 0 ) ) ); } /** diff --git a/src/MCP/Tools/GetHealth.php b/src/MCP/Tools/GetHealth.php index 5999390e..500d2301 100644 --- a/src/MCP/Tools/GetHealth.php +++ b/src/MCP/Tools/GetHealth.php @@ -51,7 +51,7 @@ public function get_rest_capability(): ?RestCapabilityRequirement { * @return \WP_REST_Request|null */ public function build_rest_request( array $args ): ?\WP_REST_Request { - return $this->request( 'GET', '/saltus-framework/v1/health' ); + return $this->request( 'GET', $this->mcp_route( '/health' ) ); } /** diff --git a/src/MCP/Tools/GetMetaFields.php b/src/MCP/Tools/GetMetaFields.php index 27bc9f45..831b35cc 100644 --- a/src/MCP/Tools/GetMetaFields.php +++ b/src/MCP/Tools/GetMetaFields.php @@ -68,7 +68,7 @@ public function get_rest_capability(): ?RestCapabilityRequirement { * @return \WP_REST_Request|null */ public function build_rest_request( array $args ): ?\WP_REST_Request { - return $this->request( 'GET', '/saltus-framework/v1/meta/' . rawurlencode( (string) ( $args['post_type'] ?? '' ) ) ); + return $this->request( 'GET', $this->mcp_route( '/meta/' . rawurlencode( (string) ( $args['post_type'] ?? '' ) ) ) ); } /** diff --git a/src/MCP/Tools/GetModel.php b/src/MCP/Tools/GetModel.php index 683aaf54..b8fc36f0 100644 --- a/src/MCP/Tools/GetModel.php +++ b/src/MCP/Tools/GetModel.php @@ -57,7 +57,7 @@ public function get_rest_capability(): ?RestCapabilityRequirement { * @return \WP_REST_Request|null */ public function build_rest_request( array $args ): ?\WP_REST_Request { - return $this->request( 'GET', '/saltus-framework/v1/models/' . rawurlencode( (string) ( $args['slug'] ?? '' ) ) ); + return $this->request( 'GET', $this->mcp_route( '/models/' . rawurlencode( (string) ( $args['slug'] ?? '' ) ) ) ); } /** diff --git a/src/MCP/Tools/GetSettings.php b/src/MCP/Tools/GetSettings.php index a37bd4fc..313f3864 100644 --- a/src/MCP/Tools/GetSettings.php +++ b/src/MCP/Tools/GetSettings.php @@ -67,7 +67,7 @@ public function get_rest_capability(): ?RestCapabilityRequirement { * @return \WP_REST_Request|null */ public function build_rest_request( array $args ): ?\WP_REST_Request { - return $this->request( 'GET', '/saltus-framework/v1/settings/' . rawurlencode( (string) ( $args['post_type'] ?? '' ) ) ); + return $this->request( 'GET', $this->mcp_route( '/settings/' . rawurlencode( (string) ( $args['post_type'] ?? '' ) ) ) ); } /** diff --git a/src/MCP/Tools/ListMetaFields.php b/src/MCP/Tools/ListMetaFields.php index 424b1ed1..69e7bfd1 100644 --- a/src/MCP/Tools/ListMetaFields.php +++ b/src/MCP/Tools/ListMetaFields.php @@ -62,7 +62,7 @@ public function get_rest_capability(): ?RestCapabilityRequirement { * @return \WP_REST_Request|null */ public function build_rest_request( array $args ): ?\WP_REST_Request { - return $this->request( 'GET', '/saltus-framework/v1/meta' ); + return $this->request( 'GET', $this->mcp_route( '/meta' ) ); } /** diff --git a/src/MCP/Tools/ListModels.php b/src/MCP/Tools/ListModels.php index d71a533e..31b68141 100644 --- a/src/MCP/Tools/ListModels.php +++ b/src/MCP/Tools/ListModels.php @@ -58,7 +58,7 @@ public function get_rest_capability(): ?RestCapabilityRequirement { * @return \WP_REST_Request|null */ public function build_rest_request( array $args ): ?\WP_REST_Request { - return $this->request( 'GET', '/saltus-framework/v1/models', $args ); + return $this->request( 'GET', $this->mcp_route( '/models' ), $args ); } /** diff --git a/src/MCP/Tools/ReorderPosts.php b/src/MCP/Tools/ReorderPosts.php index f91db221..8ff85bca 100644 --- a/src/MCP/Tools/ReorderPosts.php +++ b/src/MCP/Tools/ReorderPosts.php @@ -80,7 +80,7 @@ public function get_rest_capability(): ?RestCapabilityRequirement { * @return \WP_REST_Request|null */ public function build_rest_request( array $args ): ?\WP_REST_Request { - return $this->request( 'POST', '/saltus-framework/v1/reorder', [], [ 'items' => $args['items'] ?? [] ] ); + return $this->request( 'POST', $this->mcp_route( '/reorder' ), [], [ 'items' => $args['items'] ?? [] ] ); } /** diff --git a/src/MCP/Tools/RestBackedToolInterface.php b/src/MCP/Tools/RestBackedToolInterface.php index ef8bd23b..f5dcfca9 100644 --- a/src/MCP/Tools/RestBackedToolInterface.php +++ b/src/MCP/Tools/RestBackedToolInterface.php @@ -2,6 +2,9 @@ namespace Saltus\WP\Framework\MCP\Tools; +/** + * @api + */ interface RestBackedToolInterface extends ToolInterface { /** diff --git a/src/MCP/Tools/RestTool.php b/src/MCP/Tools/RestTool.php index c98bbad9..cf9e64a4 100644 --- a/src/MCP/Tools/RestTool.php +++ b/src/MCP/Tools/RestTool.php @@ -2,8 +2,11 @@ namespace Saltus\WP\Framework\MCP\Tools; +use Saltus\WP\Framework\MCP\MCPConfig; + /** * Abstract base for MCP tools that dispatch via the WordPress REST API. + * @api */ abstract class RestTool implements RestBackedToolInterface { @@ -34,6 +37,16 @@ public function cache_ttl(): int { return 300; } + /** + * Build a full REST route string from a path fragment using the configured MCP namespace. + * + * @param string $path Path fragment starting with '/' (e.g. '/models'). + * @return string Full route (e.g. '/saltus-framework/v1/models'). + */ + protected function mcp_route( string $path ): string { + return '/' . trim( MCPConfig::get_namespace(), '/' ) . '/' . ltrim( $path, '/' ); + } + /** * Build and return a WP_REST_Request instance. * diff --git a/src/MCP/Tools/ToolContributor.php b/src/MCP/Tools/ToolContributor.php index 4b5371f4..fa19ded5 100644 --- a/src/MCP/Tools/ToolContributor.php +++ b/src/MCP/Tools/ToolContributor.php @@ -5,6 +5,9 @@ use Saltus\WP\Framework\Modeler; use Saltus\WP\Framework\Rest\ModelRestPolicy; +/** + * @api + */ interface ToolContributor { /** diff --git a/src/MCP/Tools/ToolInterface.php b/src/MCP/Tools/ToolInterface.php index 4ee95517..b9fd1aa3 100644 --- a/src/MCP/Tools/ToolInterface.php +++ b/src/MCP/Tools/ToolInterface.php @@ -1,6 +1,9 @@ request( 'PUT', - '/saltus-framework/v1/meta/' . rawurlencode( (string) ( $args['post_type'] ?? '' ) ) . '/' . (int) ( $args['post_id'] ?? 0 ), + $this->mcp_route( '/meta/' . rawurlencode( (string) ( $args['post_type'] ?? '' ) ) . '/' . (int) ( $args['post_id'] ?? 0 ) ), [], $body ); diff --git a/src/MCP/Tools/UpdateSettings.php b/src/MCP/Tools/UpdateSettings.php index 17f95673..26bb4e98 100644 --- a/src/MCP/Tools/UpdateSettings.php +++ b/src/MCP/Tools/UpdateSettings.php @@ -74,7 +74,7 @@ public function get_rest_capability(): ?RestCapabilityRequirement { public function build_rest_request( array $args ): ?\WP_REST_Request { $body = is_array( $args['settings'] ?? null ) ? $args['settings'] : []; - return $this->request( 'PUT', '/saltus-framework/v1/settings/' . rawurlencode( (string) ( $args['post_type'] ?? '' ) ), [], $body ); + return $this->request( 'PUT', $this->mcp_route( '/settings/' . rawurlencode( (string) ( $args['post_type'] ?? '' ) ) ), [], $body ); } /** diff --git a/src/MCP/Validation/Validator.php b/src/MCP/Validation/Validator.php index 2ecdceae..9b2ab5ac 100644 --- a/src/MCP/Validation/Validator.php +++ b/src/MCP/Validation/Validator.php @@ -3,6 +3,7 @@ /** * Static argument validator against JSON Schema-like rule definitions. + * @api */ class Validator { diff --git a/src/Modeler.php b/src/Modeler.php index 4739a4e4..ade0cc2f 100644 --- a/src/Modeler.php +++ b/src/Modeler.php @@ -3,6 +3,7 @@ * Loads paths and models from the paths * * This is a simplified version of soberwp/Models + * @api */ namespace Saltus\WP\Framework; @@ -35,11 +36,22 @@ class Modeler implements RestRouteProvider, ToolContributor { /** @var array */ protected array $model_list = []; + + + /** + * Construct the modeler. + * @param ModelFactory $model_factory + */ public function __construct( ModelFactory $model_factory ) { $this->model_factory = $model_factory; // should contain a list of loaded models } + /** + * Initialize the modeler. + * + * @param string $project_path The project path. + */ public function init( string $project_path ): void { $path = $this->get_path( $project_path ); if ( ! $path ) { @@ -50,6 +62,10 @@ public function init( string $project_path ): void { /** * Get custom path + * + * @param string $project_path The project path. + * + * @return string|null The path. */ protected function get_path( string $project_path ): ?string { @@ -69,7 +85,9 @@ protected function get_path( string $project_path ): ?string { } /** - * Load Models + * Load Models. + * + * @param string $path The path to the model */ protected function load( string $path ): void { if ( file_exists( $path ) ) { @@ -176,6 +194,8 @@ protected function create( AbstractConfig $config ): void { /** * Adds the model to a list + * + * @param Model $model The model. */ protected function add( Model $model ): void { $this->model_list[ $model->get_name() ] = $model; @@ -184,13 +204,17 @@ protected function add( Model $model ): void { /** * Return all loaded models. * - * @return array Associative array keyed by model name. + * @return array Associative array keyed by model name. */ public function get_models(): array { return $this->model_list; } /** + * Get rest routes. + * + * @param Modeler $modeler + * @param ModelRestPolicy $policy * @return list */ public function get_rest_routes( Modeler $modeler, ModelRestPolicy $policy ): array { @@ -203,6 +227,10 @@ public function get_rest_routes( Modeler $modeler, ModelRestPolicy $policy ): ar } /** + * Get MCP tools. + * + * @param Modeler $modeler + * @param ModelRestPolicy|null $policy * @return list */ public function get_mcp_tools( Modeler $modeler, ?ModelRestPolicy $policy = null ): array { diff --git a/src/Models/BaseModel.php b/src/Models/BaseModel.php index be41dc98..b35b3803 100644 --- a/src/Models/BaseModel.php +++ b/src/Models/BaseModel.php @@ -3,6 +3,9 @@ use Noodlehaus\AbstractConfig; +/** + * @api + */ abstract class BaseModel { /** @@ -540,6 +543,15 @@ public function get_args(): array { return $this->args; } + /** + * Return the full raw model configuration. + * + * @return array + */ + public function get_config(): array { + return $this->data; + } + public function get_rest_base(): string { return is_string( $this->options['rest_base'] ?? null ) ? $this->options['rest_base'] diff --git a/src/Models/Config/NoFile.php b/src/Models/Config/NoFile.php index 6c6ea204..fd620e49 100644 --- a/src/Models/Config/NoFile.php +++ b/src/Models/Config/NoFile.php @@ -3,6 +3,9 @@ use Noodlehaus\AbstractConfig; +/** + * @api + */ class NoFile extends AbstractConfig { } diff --git a/src/Models/Model.php b/src/Models/Model.php index 31c1ef13..d04712da 100644 --- a/src/Models/Model.php +++ b/src/Models/Model.php @@ -2,6 +2,9 @@ namespace Saltus\WP\Framework\Models; +/** + * @api + */ interface Model { /** @@ -37,4 +40,11 @@ public function get_options(): array; * @return array */ public function get_args(): array; + + /** + * Get the full raw model configuration. + * + * @return array + */ + public function get_config(): array; } diff --git a/src/Models/ModelFactory.php b/src/Models/ModelFactory.php index 90df088a..0307acd2 100644 --- a/src/Models/ModelFactory.php +++ b/src/Models/ModelFactory.php @@ -6,6 +6,9 @@ use Saltus\WP\Framework\Infrastructure\Container\Container; use Saltus\WP\Framework\Infrastructure\Service\Processable; +/** + * @api + */ class ModelFactory { /** @var Container */ diff --git a/src/Models/PostType.php b/src/Models/PostType.php index 062ea33d..ba40d2be 100644 --- a/src/Models/PostType.php +++ b/src/Models/PostType.php @@ -7,6 +7,7 @@ * This model is used to register a custom post type * * @see https://developer.wordpress.org/reference/functions/register_post_type/ + * @api */ class PostType extends BaseModel implements Model { @@ -84,8 +85,8 @@ public function has_meta(): bool { */ protected function get_default_labels(): array { - $many_lower = strtolower( $this->many ); - $one_lower = strtolower( $this->one ); + $many_lower = $this->many_low; + $one_lower = $this->one_low; $labels = [ 'name' => $this->many, diff --git a/src/Models/Taxonomy.php b/src/Models/Taxonomy.php index abb8f542..c778fa7c 100644 --- a/src/Models/Taxonomy.php +++ b/src/Models/Taxonomy.php @@ -7,6 +7,7 @@ * This model is used to register a custom taxonomy * * @see https://developer.wordpress.org/reference/functions/register_taxonomy/ + * @api */ class Taxonomy extends BaseModel implements Model { @@ -46,15 +47,15 @@ private function get_default_options(): array { return $options; } - $config['hierarchical'] = false; + $options['hierarchical'] = false; if ( in_array( $this->config->get( 'type' ), [ 'cat', 'category' ], true ) ) { - $config['hierarchical'] = true; + $options['hierarchical'] = true; } // show in rest api by default - $config['show_in_rest'] = true; + $options['show_in_rest'] = true; - return $config; + return $options; } /** @@ -79,11 +80,11 @@ private function get_default_labels(): array { 'update_item' => 'Update ' . $this->one, 'add_new_item' => 'Add New ' . $this->one, 'new_item_name' => 'New ' . $this->one . ' Name', - 'separate_items_with_commas' => 'Separate ' . strtolower( $this->many ) . ' with commas', - 'add_or_remove_items' => 'Add or remove ' . strtolower( $this->many ), - 'choose_from_most_used' => 'Choose from the most used ' . strtolower( $this->many ), - 'not_found' => 'No ' . strtolower( $this->many ) . ' found.', - 'no_terms' => 'No ' . strtolower( $this->many ), + 'separate_items_with_commas' => 'Separate ' . $this->many_low . ' with commas', + 'add_or_remove_items' => 'Add or remove ' . $this->many_low, + 'choose_from_most_used' => 'Choose from the most used ' . $this->many_low, + 'not_found' => 'No ' . $this->many_low . ' found.', + 'no_terms' => 'No ' . $this->many_low, 'items_list_navigation' => $this->many . ' list navigation', 'items_list' => $this->many . ' list', ]; diff --git a/src/Rest/CapabilityPolicy.php b/src/Rest/CapabilityPolicy.php new file mode 100644 index 00000000..b7b61c77 --- /dev/null +++ b/src/Rest/CapabilityPolicy.php @@ -0,0 +1,233 @@ +modeler = $modeler; + } + + /** + * Check whether any model has the given capability enabled. + * + * @param string $gate The gate key (GATE_REST or GATE_MCP). + * @param string $capability The capability to check. + * @param string|null $model_type Optional model type filter. + * @return bool + */ + public function has_capability( string $gate, string $capability, ?string $model_type = null ): bool { + if ( $capability === self::CAPABILITY_HEALTH ) { + return true; + } + + foreach ( $this->modeler->get_models() as $model ) { + if ( $model_type !== null && $model->get_type() !== $model_type ) { + continue; + } + + if ( $this->is_enabled( $model, $gate, $capability ) ) { + return true; + } + } + + return false; + } + + /** + * Check whether a given model has the capability enabled for the given gate. + * + * @param Model $model The model to check. + * @param string $gate The gate key (GATE_REST or GATE_MCP). + * @param string $capability The capability to check. + * @return bool + */ + public function is_enabled( Model $model, string $gate, string $capability ): bool { + $options = $model->get_options(); + $global_val = null; + if ( array_key_exists( $gate, $options ) ) { + $global_val = (bool) $options[ $gate ]; + } + + if ( $capability === self::CAPABILITY_HEALTH ) { + return true; + } + + if ( $capability === self::CAPABILITY_MODELS ) { + if ( $gate === self::GATE_REST ) { + return $global_val !== false; + } + return $global_val === true; + } + + $config = $model->get_config(); + $feature_val = $this->resolve_feature_value( $config, $gate, $capability ); + + if ( $feature_val !== null ) { + return $feature_val; + } + + return $global_val === true; + } + + /** + * Get models that have the given capability enabled. + * + * @param string $gate The gate key (GATE_REST or GATE_MCP). + * @param string $capability The capability to check. + * @param string|null $model_type Optional model type filter. + * @return array + */ + public function get_enabled_models( string $gate, string $capability, ?string $model_type = null ): array { + $enabled = []; + + foreach ( $this->modeler->get_models() as $name => $model ) { + if ( $model_type !== null && $model->get_type() !== $model_type ) { + continue; + } + + if ( $this->is_enabled( $model, $gate, $capability ) ) { + $enabled[ $name ] = $model; + } + } + + return $enabled; + } + + /** + * Get a model by name from the modeler. + * + * @param string $name + * @return Model|null + */ + public function get_model( string $name ): ?Model { + $models = $this->modeler->get_models(); + + return $models[ $name ] ?? null; + } + + /** + * Check whether a specific post type has the capability enabled. + * + * @param string $post_type + * @param string $gate + * @param string $capability + * @return bool + */ + public function is_post_type_enabled( string $post_type, string $gate, string $capability ): bool { + $model = $this->get_model( $post_type ); + + return $model !== null + && $model->get_type() === 'post_type' + && $this->is_enabled( $model, $gate, $capability ); + } + + /** + * Check whether a specific post has the capability enabled. + * + * @param int $post_id + * @param string $gate + * @param string $capability + * @return bool + */ + public function is_post_enabled( int $post_id, string $gate, string $capability ): bool { + if ( ! function_exists( 'get_post' ) ) { + return false; + } + + $post = get_post( $post_id ); + if ( ! $post ) { + return false; + } + + return $this->is_post_type_enabled( (string) $post->post_type, $gate, $capability ); + } + + /** + * Get the model options array. + * + * @param Model $model + * @return array + */ + public function get_model_args( Model $model ): array { + return $model->get_args(); + } + + /** + * Get the configuration section for a specific capability. + * + * @param array $config + * @param string $capability + * @return mixed + */ + private function get_capability_config( array $config, string $capability ) { + if ( $capability === self::CAPABILITY_META || $capability === self::CAPABILITY_SETTINGS ) { + return $config[ $capability ] ?? null; + } + + $features = $config['features'] ?? []; + if ( ! is_array( $features ) ) { + return null; + } + + $feature_keys = [ + self::CAPABILITY_DUPLICATE => 'duplicate', + self::CAPABILITY_EXPORT => 'single_export', + self::CAPABILITY_REORDER => 'drag_and_drop', + ]; + + $key = $feature_keys[ $capability ] ?? null; + if ( $key === null ) { + return null; + } + + return $features[ $key ] ?? null; + } + + /** + * Resolve whether a capability is enabled from feature config. + * + * @param array $config + * @param string $gate + * @param string $capability + * @return bool|null + */ + private function resolve_feature_value( array $config, string $gate, string $capability ): ?bool { + $section = $this->get_capability_config( $config, $capability ); + if ( $section === null ) { + return null; + } + + if ( ! is_array( $section ) ) { + return (bool) $section; + } + + if ( ! array_key_exists( $gate, $section ) ) { + return true; + } + + return (bool) $section[ $gate ]; + } +} diff --git a/src/Rest/DuplicateController.php b/src/Rest/DuplicateController.php index ea1069a7..ddcbe208 100644 --- a/src/Rest/DuplicateController.php +++ b/src/Rest/DuplicateController.php @@ -8,13 +8,14 @@ use WP_REST_Response; use WP_Error; use Saltus\WP\Framework\Features\Duplicate\SaltusDuplicate; +use Saltus\WP\Framework\MCP\MCPConfig; /** * REST controller for duplicating posts. + * @api */ class DuplicateController extends WP_REST_Controller { - private const ROUTE_NAMESPACE = 'saltus-framework/v1'; private ?ModelRestPolicy $policy; /** @@ -22,7 +23,7 @@ class DuplicateController extends WP_REST_Controller { */ public function __construct( ?ModelRestPolicy $policy = null ) { $this->policy = $policy; - $this->namespace = self::ROUTE_NAMESPACE; + $this->namespace = MCPConfig::get_namespace(); $this->rest_base = 'duplicate'; } @@ -30,8 +31,10 @@ public function __construct( ?ModelRestPolicy $policy = null ) { * Register the REST route for post duplication. */ public function register_routes(): void { + /** @var non-falsy-string $namespace */ + $namespace = $this->namespace; register_rest_route( - self::ROUTE_NAMESPACE, + $namespace, '/' . $this->rest_base . '/(?P\d+)', [ 'methods' => WP_REST_Server::CREATABLE, @@ -98,7 +101,7 @@ public function create_item( $request ) { 'status' => 403, 'hint' => sprintf( /* translators: %s: post type slug */ - __( "Add 'saltus_rest' => [ 'capabilities' => [ 'duplicate' => true ] ] to the model config for '%s' in src/models/.", 'saltus-framework' ), + __( "Add 'show_in_rest' => true under 'features' => [ 'duplicate' => ... ] in the model config for '%s' in src/models/.", 'saltus-framework' ), $post->post_type ), ] diff --git a/src/Rest/ExportController.php b/src/Rest/ExportController.php index 0d8e8fb7..2112285b 100644 --- a/src/Rest/ExportController.php +++ b/src/Rest/ExportController.php @@ -7,13 +7,14 @@ use WP_REST_Response; use WP_Error; use Saltus\WP\Framework\Features\SingleExport\SaltusSingleExport; +use Saltus\WP\Framework\MCP\MCPConfig; /** * REST controller for exporting posts as WXR. + * @api */ class ExportController extends WP_REST_Controller { - private const ROUTE_NAMESPACE = 'saltus-framework/v1'; private ?ModelRestPolicy $policy; private SaltusSingleExport $exporter; @@ -24,7 +25,7 @@ class ExportController extends WP_REST_Controller { public function __construct( ?ModelRestPolicy $policy = null, ?SaltusSingleExport $exporter = null ) { $this->policy = $policy; $this->exporter = $exporter ?? new SaltusSingleExport( '', [] ); - $this->namespace = self::ROUTE_NAMESPACE; + $this->namespace = MCPConfig::get_namespace(); $this->rest_base = 'export'; } @@ -32,8 +33,10 @@ public function __construct( ?ModelRestPolicy $policy = null, ?SaltusSingleExpor * Register the REST route for post export. */ public function register_routes(): void { + /** @var non-falsy-string $namespace */ + $namespace = $this->namespace; \register_rest_route( - self::ROUTE_NAMESPACE, + $namespace, '/' . $this->rest_base . '/(?P\d+)', [ 'methods' => WP_REST_Server::READABLE, @@ -96,7 +99,7 @@ public function get_item( $request ) { 'status' => 403, 'hint' => sprintf( /* translators: %s: post type slug */ - __( "Add 'saltus_rest' => [ 'capabilities' => [ 'export' => true ] ] to the model config for '%s' in src/models/.", 'saltus-framework' ), + __( "Add 'show_in_rest' => true under 'features' => [ 'single_export' => ... ] in the model config for '%s' in src/models/.", 'saltus-framework' ), $post->post_type ), ] diff --git a/src/Rest/HealthController.php b/src/Rest/HealthController.php index 720af2a0..4b792f54 100644 --- a/src/Rest/HealthController.php +++ b/src/Rest/HealthController.php @@ -3,6 +3,7 @@ namespace Saltus\WP\Framework\Rest; use Saltus\WP\Framework\MCP\Audit\AuditLogger; +use Saltus\WP\Framework\MCP\MCPConfig; use WP_Error; use WP_REST_Controller; use WP_REST_Response; @@ -10,19 +11,18 @@ /** * REST controller exposing framework health and MCP runtime metrics. + * @api */ class HealthController extends WP_REST_Controller { use \Saltus\WP\Framework\Infrastructure\Services\FilterAwareTrait; - private const ROUTE_NAMESPACE = 'saltus-framework/v1'; - private string $version; private AuditLogger $audit_logger; public function __construct( string $version, ?AuditLogger $audit_logger = null ) { $this->version = $version; $this->audit_logger = $audit_logger ?? new AuditLogger(); - $this->namespace = self::ROUTE_NAMESPACE; + $this->namespace = MCPConfig::get_namespace(); $this->rest_base = 'health'; } @@ -30,8 +30,10 @@ public function __construct( string $version, ?AuditLogger $audit_logger = null * Register the health route. */ public function register_routes(): void { + /** @var non-falsy-string $namespace */ + $namespace = $this->namespace; register_rest_route( - self::ROUTE_NAMESPACE, + $namespace, '/' . $this->rest_base, [ 'methods' => WP_REST_Server::READABLE, diff --git a/src/Rest/MetaController.php b/src/Rest/MetaController.php index d3919587..bd856b94 100644 --- a/src/Rest/MetaController.php +++ b/src/Rest/MetaController.php @@ -8,15 +8,15 @@ use WP_REST_Response; use WP_Error; use Saltus\WP\Framework\Features\Meta\MetaFieldProvider; +use Saltus\WP\Framework\MCP\MCPConfig; use Saltus\WP\Framework\Modeler; /** * REST controller exposing meta field configuration per post type. + * @api */ class MetaController extends WP_REST_Controller { - private const ROUTE_NAMESPACE = 'saltus-framework/v1'; - protected Modeler $modeler; private ?ModelRestPolicy $policy; private MetaFieldProvider $meta_field_provider; @@ -30,7 +30,7 @@ public function __construct( Modeler $modeler, ?ModelRestPolicy $policy = null, $this->modeler = $modeler; $this->policy = $policy; $this->meta_field_provider = $meta_field_provider ?? new MetaFieldProvider(); - $this->namespace = self::ROUTE_NAMESPACE; + $this->namespace = MCPConfig::get_namespace(); $this->rest_base = 'meta'; } @@ -42,8 +42,10 @@ public function register_routes(): void { return; } + /** @var non-falsy-string $namespace */ + $namespace = $this->namespace; register_rest_route( - self::ROUTE_NAMESPACE, + $namespace, '/' . $this->rest_base, [ 'methods' => WP_REST_Server::READABLE, @@ -53,7 +55,7 @@ public function register_routes(): void { ); register_rest_route( - self::ROUTE_NAMESPACE, + $namespace, '/' . $this->rest_base . '/(?P[a-z0-9_-]+)', [ 'methods' => WP_REST_Server::READABLE, @@ -70,7 +72,7 @@ public function register_routes(): void { ); register_rest_route( - self::ROUTE_NAMESPACE, + $namespace, '/' . $this->rest_base . '/(?P[a-z0-9_-]+)/(?P\d+)', [ 'methods' => WP_REST_Server::EDITABLE, @@ -115,7 +117,7 @@ public function get_items_permissions_check( $request ) { __( 'You do not have permission to view meta fields.', 'saltus-framework' ), [ 'status' => 403, - 'hint' => __( "Assign edit_posts to your user, or ensure the model has 'saltus_rest' => [ 'capabilities' => [ 'meta' => true ] ] in its config.", 'saltus-framework' ), + 'hint' => __( "Assign edit_posts to your user, or add 'show_in_rest' => true under the 'meta' section in the model config.", 'saltus-framework' ), ] ); } @@ -172,7 +174,7 @@ public function update_item_permissions_check( $request ) { 'status' => 404, 'hint' => sprintf( /* translators: %s: post type slug */ - __( "Add 'saltus_rest' => [ 'capabilities' => [ 'meta' => true ] ] to the model config for '%s' in src/models/.", 'saltus-framework' ), + __( "Add 'show_in_rest' => true under the 'meta' section in the model config for '%s' in src/models/.", 'saltus-framework' ), $post_type ), ] diff --git a/src/Rest/ModelRestPolicy.php b/src/Rest/ModelRestPolicy.php index cc23a7aa..4e3e35d5 100644 --- a/src/Rest/ModelRestPolicy.php +++ b/src/Rest/ModelRestPolicy.php @@ -5,6 +5,9 @@ use Saltus\WP\Framework\Modeler; use Saltus\WP\Framework\Models\Model; +/** + * @api + */ class ModelRestPolicy { public const CAPABILITY_MODELS = 'models'; @@ -40,22 +43,83 @@ public function has_capability( string $capability, ?string $model_type = null ) } public function is_enabled( Model $model, string $capability ): bool { - $options = $this->get_model_options( $model ); - - if ( array_key_exists( 'show_in_rest', $options ) && $options['show_in_rest'] === false ) { - return false; + $options = $this->get_model_options( $model ); + $global_val = null; + if ( array_key_exists( 'show_in_rest', $options ) ) { + $global_val = (bool) $options['show_in_rest']; } - $saltus_rest = $options['saltus_rest'] ?? false; - if ( $saltus_rest === true ) { + if ( $capability === self::CAPABILITY_HEALTH ) { return true; } - if ( ! is_array( $saltus_rest ) ) { - return false; + if ( $capability === self::CAPABILITY_MODELS ) { + return $global_val !== false; + } + + $config = $model->get_config(); + $feature_val = $this->resolve_feature_value( $config, $capability ); + + if ( $feature_val !== null ) { + return $feature_val; + } + + return $global_val === true; + } + + /** + * Get the configuration section for a specific capability. + * + * @param array $config The model configuration. + * @param string $capability The capability. + * @return mixed + */ + private function get_capability_config( array $config, string $capability ) { + if ( $capability === self::CAPABILITY_META || $capability === self::CAPABILITY_SETTINGS ) { + return $config[ $capability ] ?? null; + } + + $features = $config['features'] ?? []; + if ( ! is_array( $features ) ) { + return null; + } + + $feature_keys = [ + self::CAPABILITY_DUPLICATE => 'duplicate', + self::CAPABILITY_EXPORT => 'single_export', + self::CAPABILITY_REORDER => 'drag_and_drop', + ]; + + $key = $feature_keys[ $capability ] ?? null; + if ( $key === null ) { + return null; + } + + return $features[ $key ] ?? null; + } + + /** + * Resolve the capability value from the feature configuration. + * + * @param array $config + * @param string $capability + * @return bool|null + */ + private function resolve_feature_value( array $config, string $capability ): ?bool { + $section = $this->get_capability_config( $config, $capability ); + if ( $section === null ) { + return null; + } + + if ( ! is_array( $section ) ) { + return (bool) $section; + } + + if ( ! array_key_exists( 'show_in_rest', $section ) ) { + return true; } - return ! empty( $saltus_rest[ $capability ] ); + return (bool) $section['show_in_rest']; } public function is_post_type_enabled( string $post_type, string $capability ): bool { diff --git a/src/Rest/ModelsController.php b/src/Rest/ModelsController.php index 282b5958..fc8b654d 100644 --- a/src/Rest/ModelsController.php +++ b/src/Rest/ModelsController.php @@ -7,17 +7,17 @@ use WP_REST_Request; use WP_REST_Response; use WP_Error; +use Saltus\WP\Framework\MCP\MCPConfig; use Saltus\WP\Framework\Modeler; use Saltus\WP\Framework\Models\Model; use Saltus\WP\Framework\Models\Taxonomy; /** * REST controller exposing registered Saltus models and their metadata. + * @api */ class ModelsController extends WP_REST_Controller { - private const ROUTE_NAMESPACE = 'saltus-framework/v1'; - protected Modeler $modeler; private ?ModelRestPolicy $policy; @@ -28,7 +28,7 @@ class ModelsController extends WP_REST_Controller { public function __construct( Modeler $modeler, ?ModelRestPolicy $policy = null ) { $this->modeler = $modeler; $this->policy = $policy; - $this->namespace = self::ROUTE_NAMESPACE; + $this->namespace = MCPConfig::get_namespace(); $this->rest_base = 'models'; } @@ -36,8 +36,10 @@ public function __construct( Modeler $modeler, ?ModelRestPolicy $policy = null ) * Register the REST routes for listing and reading models. */ public function register_routes(): void { + /** @var non-falsy-string $namespace */ + $namespace = $this->namespace; register_rest_route( - self::ROUTE_NAMESPACE, + $namespace, '/' . $this->rest_base, [ 'methods' => WP_REST_Server::READABLE, @@ -47,7 +49,7 @@ public function register_routes(): void { ); register_rest_route( - self::ROUTE_NAMESPACE, + $namespace, '/' . $this->rest_base . '/(?P[a-z0-9_-]+)', [ 'methods' => WP_REST_Server::READABLE, @@ -77,7 +79,7 @@ public function get_items_permissions_check( $request ) { __( 'You do not have permission to view models.', 'saltus-framework' ), [ 'status' => 403, - 'hint' => __( "Assign edit_posts to your user, or ensure at least one model has 'saltus_rest' => true in its config.", 'saltus-framework' ), + 'hint' => __( "Assign edit_posts to your user, or ensure at least one model has 'show_in_rest' => true in its options.", 'saltus-framework' ), ] ); } @@ -104,7 +106,7 @@ public function get_item_permissions_check( $request ) { 'status' => 403, 'hint' => sprintf( /* translators: %s: model name */ - __( "Assign edit_posts to your user, or ensure model '%s' has 'saltus_rest' => true in its config.", 'saltus-framework' ), + __( "Assign edit_posts to your user, or ensure model '%s' has 'show_in_rest' => true in its options.", 'saltus-framework' ), $model_name ?? '(unknown)' ), ] diff --git a/src/Rest/ReorderController.php b/src/Rest/ReorderController.php index db233e4f..b9a299cf 100644 --- a/src/Rest/ReorderController.php +++ b/src/Rest/ReorderController.php @@ -8,13 +8,14 @@ use WP_REST_Response; use WP_Error; use Saltus\WP\Framework\Features\DragAndDrop\ReorderPostsService; +use Saltus\WP\Framework\MCP\MCPConfig; /** * REST controller for reordering posts via menu_order updates. + * @api */ class ReorderController extends WP_REST_Controller { - private const ROUTE_NAMESPACE = 'saltus-framework/v1'; private ?ModelRestPolicy $policy; private ReorderPostsService $reorder_service; @@ -25,7 +26,7 @@ class ReorderController extends WP_REST_Controller { public function __construct( ?ModelRestPolicy $policy = null, ?ReorderPostsService $reorder_service = null ) { $this->policy = $policy; $this->reorder_service = $reorder_service ?? new ReorderPostsService(); - $this->namespace = self::ROUTE_NAMESPACE; + $this->namespace = MCPConfig::get_namespace(); $this->rest_base = 'reorder'; } @@ -33,8 +34,10 @@ public function __construct( ?ModelRestPolicy $policy = null, ?ReorderPostsServi * Register the REST route for reordering posts. */ public function register_routes(): void { + /** @var non-falsy-string $namespace */ + $namespace = $this->namespace; register_rest_route( - self::ROUTE_NAMESPACE, + $namespace, '/' . $this->rest_base, [ 'methods' => WP_REST_Server::CREATABLE, @@ -83,7 +86,7 @@ public function create_item_permissions_check( $request ) { __( 'You do not have permission to reorder posts.', 'saltus-framework' ), [ 'status' => 403, - 'hint' => __( "Assign edit_posts to your user, or ensure all requested posts are editable by the current user. Check that each post's post type has 'saltus_rest' configured.", 'saltus-framework' ), + 'hint' => __( "Assign edit_posts to your user, or ensure all requested posts are editable by the current user. Check that each post's post type has 'show_in_rest' configured under 'features' => [ 'drag_and_drop' => [ 'show_in_rest' => true ] ].", 'saltus-framework' ), ] ); } diff --git a/src/Rest/RestRouteDefinition.php b/src/Rest/RestRouteDefinition.php index c15d1b50..0cc13ee3 100644 --- a/src/Rest/RestRouteDefinition.php +++ b/src/Rest/RestRouteDefinition.php @@ -4,6 +4,7 @@ /** * Value object binding a capability, controller, and optional model type to a REST route. + * @api */ class RestRouteDefinition { diff --git a/src/Rest/RestRouteProvider.php b/src/Rest/RestRouteProvider.php index 0c5bc7a5..cecefb78 100644 --- a/src/Rest/RestRouteProvider.php +++ b/src/Rest/RestRouteProvider.php @@ -4,6 +4,9 @@ use Saltus\WP\Framework\Modeler; +/** + * @api + */ interface RestRouteProvider { /** diff --git a/src/Rest/RestServer.php b/src/Rest/RestServer.php index d3a20d7a..d064f85d 100644 --- a/src/Rest/RestServer.php +++ b/src/Rest/RestServer.php @@ -4,6 +4,7 @@ /** * Registers REST routes filtered by ModelRestPolicy capability checks. + * @api */ class RestServer { diff --git a/src/Rest/SettingsController.php b/src/Rest/SettingsController.php index 5ddab472..41c5ed15 100644 --- a/src/Rest/SettingsController.php +++ b/src/Rest/SettingsController.php @@ -8,13 +8,14 @@ use WP_REST_Response; use WP_Error; use Saltus\WP\Framework\Features\Settings\SettingsManager; +use Saltus\WP\Framework\MCP\MCPConfig; /** * REST controller for reading and updating per-post-type settings. + * @api */ class SettingsController extends WP_REST_Controller { - private const ROUTE_NAMESPACE = 'saltus-framework/v1'; private ?ModelRestPolicy $policy; private SettingsManager $settings_manager; @@ -25,7 +26,7 @@ class SettingsController extends WP_REST_Controller { public function __construct( ?ModelRestPolicy $policy = null, ?SettingsManager $settings_manager = null ) { $this->policy = $policy; $this->settings_manager = $settings_manager ?? new SettingsManager(); - $this->namespace = self::ROUTE_NAMESPACE; + $this->namespace = MCPConfig::get_namespace(); $this->rest_base = 'settings'; } @@ -33,8 +34,10 @@ public function __construct( ?ModelRestPolicy $policy = null, ?SettingsManager $ * Register the REST routes for reading and updating settings. */ public function register_routes(): void { + /** @var non-falsy-string $namespace */ + $namespace = $this->namespace; register_rest_route( - self::ROUTE_NAMESPACE, + $namespace, '/' . $this->rest_base . '/(?P[a-z0-9_-]+)', [ [ @@ -82,7 +85,7 @@ public function get_item_permissions_check( $request ) { 'status' => 404, 'hint' => sprintf( /* translators: %s: post type slug */ - __( "Add 'saltus_rest' => [ 'capabilities' => [ 'settings' => true ] ] to the model config for '%s' in src/models/.", 'saltus-framework' ), + __( "Add 'show_in_rest' => true under the 'settings' section in the model config for '%s' in src/models/.", 'saltus-framework' ), $post_type ), ] @@ -148,7 +151,7 @@ public function update_item_permissions_check( $request ) { 'status' => 404, 'hint' => sprintf( /* translators: %s: post type slug */ - __( "Add 'saltus_rest' => [ 'capabilities' => [ 'settings' => true ] ] to the model config for '%s' in src/models/.", 'saltus-framework' ), + __( "Add 'show_in_rest' => true under the 'settings' section in the model config for '%s' in src/models/.", 'saltus-framework' ), $post_type ), ] @@ -185,7 +188,7 @@ public function get_item( $request ) { 'status' => 404, 'hint' => sprintf( /* translators: %s: post type slug */ - __( "Add 'saltus_rest' => [ 'capabilities' => [ 'settings' => true ] ] to the model config for '%s' in src/models/.", 'saltus-framework' ), + __( "Add 'show_in_rest' => true under the 'settings' section in the model config for '%s' in src/models/.", 'saltus-framework' ), $post_type ), ] @@ -211,7 +214,7 @@ public function update_item( $request ) { 'status' => 404, 'hint' => sprintf( /* translators: %s: post type slug */ - __( "Add 'saltus_rest' => [ 'capabilities' => [ 'settings' => true ] ] to the model config for '%s' in src/models/.", 'saltus-framework' ), + __( "Add 'show_in_rest' => true under the 'settings' section in the model config for '%s' in src/models/.", 'saltus-framework' ), $post_type ), ] diff --git a/tests/Features/MCPFeatureTest.php b/tests/Features/MCPFeatureTest.php index a120d17c..29d77aef 100644 --- a/tests/Features/MCPFeatureTest.php +++ b/tests/Features/MCPFeatureTest.php @@ -110,11 +110,31 @@ public function testNativeRegistrationUsesToolContributorsFromDependencies(): vo public function testNativeRegistrationUsesDefaultFeatureToolContributors(): void { global $wp_actions_registered, $wp_abilities_registered; + $config = [ + 'meta' => [ + 'show_in_mcp' => true, + ], + 'settings' => [ + 'show_in_mcp' => true, + ], + 'features' => [ + 'duplicate' => [ + 'show_in_mcp' => true, + ], + 'single_export' => [ + 'show_in_mcp' => true, + ], + 'drag_and_drop' => [ + 'show_in_mcp' => true, + ], + ], + ]; + $modeler = new ModelerWithModels( $this->createStub( ModelFactory::class ), [ - 'book' => $this->createModelMock( 'post_type' ), - 'genre' => $this->createModelMock( 'taxonomy' ), + 'book' => $this->createModelMock( 'post_type', $config ), + 'genre' => $this->createModelMock( 'taxonomy', $config ), ] ); $feature = new MCP( @@ -147,12 +167,18 @@ public function testNativeRegistrationUsesDefaultFeatureToolContributors(): void $this->assertArrayHasKey( 'saltus/reorder-posts', $wp_abilities_registered ); } - private function createModelMock( string $type ): Model { - return new class( $type ) implements Model { + private function createModelMock( string $type, array $config = [] ): Model { + return new class( $type, $config ) implements Model { private string $type; + /** @var array */ + private array $config; - public function __construct( string $type ) { - $this->type = $type; + /** + * @param array $config + */ + public function __construct( string $type, array $config = [] ) { + $this->type = $type; + $this->config = $config; } public function setup(): void {} @@ -171,13 +197,17 @@ public function get_type(): string { public function get_options(): array { return [ 'show_in_rest' => true, - 'saltus_rest' => true, + 'mcp_tools' => true, ]; } public function get_args(): array { return []; } + + public function get_config(): array { + return $this->config; + } }; } } diff --git a/tests/Integration/RestRegistrationTest.php b/tests/Integration/RestRegistrationTest.php index beeb8de4..c2510f9a 100644 --- a/tests/Integration/RestRegistrationTest.php +++ b/tests/Integration/RestRegistrationTest.php @@ -93,6 +93,36 @@ public function testHealthRouteIsAlwaysIncluded(): void { $this->assertTrue( $policy->has_capability( ModelRestPolicy::CAPABILITY_HEALTH ) ); } + public function testIsEnabledHandlesMissingFeaturesConfigDefensively(): void { + $modeler = $this->createMock( Modeler::class ); + $policy = new ModelRestPolicy( $modeler ); + $model = $this->createMock( Model::class ); + $model->method( 'get_options' )->willReturn( [ 'show_in_rest' => true ] ); + $model->method( 'get_config' )->willReturn( [] ); + + $this->assertTrue( $policy->is_enabled( $model, ModelRestPolicy::CAPABILITY_EXPORT ) ); + } + + public function testIsEnabledHandlesNonArrayFeaturesConfigDefensively(): void { + $modeler = $this->createMock( Modeler::class ); + $policy = new ModelRestPolicy( $modeler ); + $model = $this->createMock( Model::class ); + $model->method( 'get_options' )->willReturn( [ 'show_in_rest' => true ] ); + $model->method( 'get_config' )->willReturn( [ 'features' => null ] ); + + $this->assertTrue( $policy->is_enabled( $model, ModelRestPolicy::CAPABILITY_EXPORT ) ); + } + + public function testIsEnabledHandlesExplicitDisable(): void { + $modeler = $this->createMock( Modeler::class ); + $policy = new ModelRestPolicy( $modeler ); + $model = $this->createMock( Model::class ); + $model->method( 'get_options' )->willReturn( [ 'show_in_rest' => true ] ); + $model->method( 'get_config' )->willReturn( [ 'meta' => false ] ); + + $this->assertFalse( $policy->is_enabled( $model, ModelRestPolicy::CAPABILITY_META ) ); + } + public function testHealthControllerImplementsRegisterRoutes(): void { $controller = new HealthController( '1.0.0' ); $this->assertTrue( method_exists( $controller, 'register_routes' ) ); diff --git a/tests/MCP/Abilities/AbilityRegistrarTest.php b/tests/MCP/Abilities/AbilityRegistrarTest.php index 0e4abbb3..c21a2c2f 100644 --- a/tests/MCP/Abilities/AbilityRegistrarTest.php +++ b/tests/MCP/Abilities/AbilityRegistrarTest.php @@ -18,6 +18,7 @@ use Saltus\WP\Framework\Modeler; use Saltus\WP\Framework\Models\Model; use Saltus\WP\Framework\Models\ModelFactory; +use Saltus\WP\Framework\MCP\McpPolicy; use Saltus\WP\Framework\Rest\ModelRestPolicy; require_once dirname( __DIR__, 2 ) . '/Rest/functions.php'; @@ -74,17 +75,20 @@ public function testRegisterFiltersRestBackedAbilitiesWhenPolicyIsInjected(): vo [ 'book' => $this->createModelMock( [ - 'show_in_rest' => true, - 'saltus_rest' => [ - 'models' => true, - 'meta' => true, + 'mcp_tools' => true, + ], + [ + 'meta' => [], + 'settings' => false, + 'features' => [ + 'duplicate' => false, ], ] ), ] ); - $registered = ( new AbilityRegistrar( $this->defaultToolProvider( $modeler ), null, new ModelRestPolicy( $modeler ) ) )->register(); + $registered = ( new AbilityRegistrar( $this->defaultToolProvider( $modeler ), null, new McpPolicy( $modeler ) ) )->register(); $this->assertContains( 'saltus/get-health', $registered ); $this->assertContains( 'saltus/list-models', $registered ); @@ -416,16 +420,25 @@ public function get_charset_collate(): string { * @param array $options * @return Model&object{options: array} */ - private function createModelMock( array $options ) { - return new class( $options ) implements Model { + /** + * @param array $options + * @param array $config + * @return Model&object{options: array} + */ + private function createModelMock( array $options, array $config = [] ) { + return new class( $options, $config ) implements Model { /** @var array */ public array $options; + /** @var array */ + public array $config; /** * @param array $options + * @param array $config */ - public function __construct( array $options ) { + public function __construct( array $options, array $config = [] ) { $this->options = $options; + $this->config = $config; } public function setup(): void {} @@ -445,6 +458,10 @@ public function get_options(): array { public function get_args(): array { return []; } + + public function get_config(): array { + return $this->config; + } }; } } diff --git a/tests/MCP/MCPConfigTest.php b/tests/MCP/MCPConfigTest.php new file mode 100644 index 00000000..723392d2 --- /dev/null +++ b/tests/MCP/MCPConfigTest.php @@ -0,0 +1,140 @@ +assertSame( 'saltus-framework/v1', MCPConfig::get_namespace() ); + } + + public function testGetNamespaceReturnsFilteredValueViaWpFilterValues(): void { + global $wp_filter_values; + $wp_filter_values['saltus/framework/mcp/namespace'] = 'my-plugin/v2'; + + $this->assertSame( 'my-plugin/v2', MCPConfig::get_namespace() ); + } + + public function testGetNamespaceReturnsFilteredValueViaAddFilter(): void { + global $wp_filters_registered; + $wp_filters_registered = []; + + add_filter( + 'saltus/framework/mcp/namespace', + function (): string { + return 'override/v3'; + } + ); + + $this->assertSame( 'override/v3', MCPConfig::get_namespace() ); + } + + public function testGetNamespaceFilterCallbackReceivesDefaultValue(): void { + add_filter( + 'saltus/framework/mcp/namespace', + function ( string $value ): string { + return strtoupper( $value ); + } + ); + + $this->assertSame( 'SALTUS-FRAMEWORK/V1', MCPConfig::get_namespace() ); + } + + public function testGetNamespaceFallsBackToDefaultWhenFilteredValueIsEmpty(): void { + global $wp_filter_values; + $wp_filter_values['saltus/framework/mcp/namespace'] = ''; + + $this->assertSame( 'saltus-framework/v1', MCPConfig::get_namespace() ); + } + + public function testGetAbilityCategoryReturnsDefault(): void { + $category = MCPConfig::get_ability_category(); + + $this->assertIsArray( $category ); + $this->assertSame( 'saltus-framework', $category['id'] ); + $this->assertSame( 'Saltus Framework', $category['label'] ); + $this->assertSame( 'Saltus Framework content modeling and administration abilities.', $category['description'] ); + } + + public function testGetAbilityCategoryReturnsFilteredValue(): void { + global $wp_filter_values; + $wp_filter_values['saltus/framework/mcp/ability_category'] = [ + 'id' => 'custom-plugin', + 'label' => 'Custom Plugin', + 'description' => 'Custom abilities.', + ]; + + $category = MCPConfig::get_ability_category(); + $this->assertSame( 'custom-plugin', $category['id'] ); + $this->assertSame( 'Custom Plugin', $category['label'] ); + $this->assertSame( 'Custom abilities.', $category['description'] ); + } + + public function testGetAbilityCategoryFilterReceivesDefault(): void { + add_filter( + 'saltus/framework/mcp/ability_category', + function ( array $category ): array { + $category['label'] = 'Overridden Label'; + return $category; + } + ); + + $category = MCPConfig::get_ability_category(); + $this->assertSame( 'saltus-framework', $category['id'] ); + $this->assertSame( 'Overridden Label', $category['label'] ); + } + + public function testGetAbilityCategoryFilterReturnsIncompleteArrayMergesWithDefaults(): void { + global $wp_filter_values; + $wp_filter_values['saltus/framework/mcp/ability_category'] = [ + 'id' => 'custom-id', + ]; + + $category = MCPConfig::get_ability_category(); + $this->assertSame( 'custom-id', $category['id'] ); + $this->assertSame( 'Saltus Framework', $category['label'] ); + $this->assertSame( 'Saltus Framework content modeling and administration abilities.', $category['description'] ); + } + + public function testGetAbilityPrefixReturnsDefault(): void { + $this->assertSame( 'saltus/', MCPConfig::get_ability_prefix() ); + } + + public function testGetAbilityPrefixReturnsFilteredValue(): void { + global $wp_filter_values; + $wp_filter_values['saltus/framework/mcp/ability_prefix'] = 'custom/'; + + $this->assertSame( 'custom/', MCPConfig::get_ability_prefix() ); + } + + public function testGetAbilityPrefixFilterReceivesDefault(): void { + add_filter( + 'saltus/framework/mcp/ability_prefix', + function ( string $prefix ): string { + return 'new-' . $prefix; + } + ); + + $this->assertSame( 'new-saltus/', MCPConfig::get_ability_prefix() ); + } +} diff --git a/tests/MCP/McpPolicyTest.php b/tests/MCP/McpPolicyTest.php new file mode 100644 index 00000000..9b153a4f --- /dev/null +++ b/tests/MCP/McpPolicyTest.php @@ -0,0 +1,239 @@ +createStub( Modeler::class ); + $modeler->method( 'get_models' )->willReturn( [] ); + + $policy = new McpPolicy( $modeler ); + + $this->assertTrue( $policy->has_capability( ModelRestPolicy::CAPABILITY_HEALTH ) ); + } + + public function testHasCapabilityReturnsTrueWhenModelHasMcpTools(): void { + $modeler = $this->createStub( Modeler::class ); + $modeler->method( 'get_models' )->willReturn( + [ + 'book' => $this->createModelMock( [ 'mcp_tools' => true ], [ 'meta' => [ 'show_in_mcp' => true ] ] ), + ] + ); + + $policy = new McpPolicy( $modeler ); + + $this->assertTrue( $policy->has_capability( ModelRestPolicy::CAPABILITY_META, 'post_type' ) ); + } + + public function testHasCapabilityReturnsFalseWhenNoModelsHaveMcpTools(): void { + $modeler = $this->createStub( Modeler::class ); + $modeler->method( 'get_models' )->willReturn( + [ + 'book' => $this->createModelMock( [ 'show_in_rest' => true ], [] ), + ] + ); + + $policy = new McpPolicy( $modeler ); + + $this->assertFalse( $policy->has_capability( ModelRestPolicy::CAPABILITY_META ) ); + } + + public function testHasCapabilityReturnsFalseWhenFeatureDisabledViaShowInMcp(): void { + $modeler = $this->createStub( Modeler::class ); + $modeler->method( 'get_models' )->willReturn( + [ + 'book' => $this->createModelMock( + [ 'mcp_tools' => true ], + [ 'settings' => [ 'show_in_mcp' => false ] ] + ), + ] + ); + + $policy = new McpPolicy( $modeler ); + + $this->assertFalse( $policy->has_capability( ModelRestPolicy::CAPABILITY_SETTINGS ) ); + } + + public function testHasCapabilityReturnsTrueWhenFeatureConfigLacksShowInMcp(): void { + $modeler = $this->createStub( Modeler::class ); + $modeler->method( 'get_models' )->willReturn( + [ + 'book' => $this->createModelMock( + [ 'mcp_tools' => true ], + [ 'meta' => [] ] + ), + ] + ); + + $policy = new McpPolicy( $modeler ); + + $this->assertTrue( $policy->has_capability( ModelRestPolicy::CAPABILITY_META ) ); + } + + public function testHasCapabilityFiltersByModelType(): void { + $modeler = $this->createStub( Modeler::class ); + $modeler->method( 'get_models' )->willReturn( + [ + 'book' => $this->createModelMock( + [ 'mcp_tools' => true ], + [ 'features' => [ 'duplicate' => [ 'show_in_mcp' => true ] ] ] + ), + ] + ); + + $policy = new McpPolicy( $modeler ); + + $this->assertFalse( $policy->has_capability( ModelRestPolicy::CAPABILITY_DUPLICATE, 'taxonomy' ) ); + } + + public function testHasCapabilityReturnsTrueForModelsWhenMcpToolsIsTrue(): void { + $modeler = $this->createStub( Modeler::class ); + $modeler->method( 'get_models' )->willReturn( + [ + 'book' => $this->createModelMock( [ 'mcp_tools' => true ], [] ), + ] + ); + + $policy = new McpPolicy( $modeler ); + + $this->assertTrue( $policy->has_capability( ModelRestPolicy::CAPABILITY_MODELS ) ); + } + + public function testIsEnabledReturnsFalseWhenMcpToolsIsAbsent(): void { + $modeler = $this->createStub( Modeler::class ); + $policy = new McpPolicy( $modeler ); + $model = $this->createModelMock( [ 'show_in_rest' => true ], [] ); + + $this->assertFalse( $policy->is_enabled( $model, ModelRestPolicy::CAPABILITY_META ) ); + } + + public function testIsEnabledReturnsFalseWhenMcpToolsIsFalse(): void { + $modeler = $this->createStub( Modeler::class ); + $policy = new McpPolicy( $modeler ); + $model = $this->createModelMock( [ 'mcp_tools' => false ], [] ); + + $this->assertFalse( $policy->is_enabled( $model, ModelRestPolicy::CAPABILITY_META ) ); + } + + public function testIsEnabledReturnsTrueForHealth(): void { + $modeler = $this->createStub( Modeler::class ); + $policy = new McpPolicy( $modeler ); + $model = $this->createModelMock( [ 'mcp_tools' => true ], [] ); + + $this->assertTrue( $policy->is_enabled( $model, ModelRestPolicy::CAPABILITY_HEALTH ) ); + } + + public function testIsEnabledReturnsTrueForModels(): void { + $modeler = $this->createStub( Modeler::class ); + $policy = new McpPolicy( $modeler ); + $model = $this->createModelMock( [ 'mcp_tools' => true ], [] ); + + $this->assertTrue( $policy->is_enabled( $model, ModelRestPolicy::CAPABILITY_MODELS ) ); + } + + public function testIsEnabledChecksFeatureLevelShowInMcp(): void { + $modeler = $this->createStub( Modeler::class ); + $policy = new McpPolicy( $modeler ); + + $model = $this->createModelMock( + [ 'mcp_tools' => true ], + [ 'features' => [ 'single_export' => [ 'show_in_mcp' => false ] ] ] + ); + + $this->assertFalse( $policy->is_enabled( $model, ModelRestPolicy::CAPABILITY_EXPORT ) ); + } + + public function testIsEnabledHandlesMissingFeaturesConfigDefensively(): void { + $modeler = $this->createStub( Modeler::class ); + $policy = new McpPolicy( $modeler ); + + $model = $this->createModelMock( + [ 'mcp_tools' => true ], + [] // config has no features key + ); + + $this->assertTrue( $policy->is_enabled( $model, ModelRestPolicy::CAPABILITY_EXPORT ) ); + } + + public function testIsEnabledHandlesNonArrayFeaturesConfigDefensively(): void { + $modeler = $this->createStub( Modeler::class ); + $policy = new McpPolicy( $modeler ); + + $model = $this->createModelMock( + [ 'mcp_tools' => true ], + [ 'features' => null ] // config features key is null + ); + + $this->assertTrue( $policy->is_enabled( $model, ModelRestPolicy::CAPABILITY_EXPORT ) ); + } + + public function testIsEnabledHandlesExplicitDisable(): void { + $modeler = $this->createStub( Modeler::class ); + $policy = new McpPolicy( $modeler ); + + $model = $this->createModelMock( + [ 'mcp_tools' => true ], + [ 'meta' => false ] + ); + + $this->assertFalse( $policy->is_enabled( $model, ModelRestPolicy::CAPABILITY_META ) ); + } + + public function testGetModelReturnsNullForUnknownModel(): void { + $modeler = $this->createStub( Modeler::class ); + $modeler->method( 'get_models' )->willReturn( [] ); + + $policy = new McpPolicy( $modeler ); + + $this->assertNull( $policy->get_model( 'nonexistent' ) ); + } + + public function testGetModelReturnsModelForKnownName(): void { + $book = $this->createModelMock( [], [] ); + $modeler = $this->createStub( Modeler::class ); + $modeler->method( 'get_models' )->willReturn( [ 'book' => $book ] ); + + $policy = new McpPolicy( $modeler ); + + $this->assertSame( $book, $policy->get_model( 'book' ) ); + } + + /** + * @param array $options + * @param array $config + * @return Model&object{options: array} + */ + private function createModelMock( array $options, array $config = [] ) { + return new class( $options, $config ) implements Model { + public array $options; + public array $config; + + public function __construct( array $options, array $config = [] ) { + $this->options = $options; + $this->config = $config; + } + + public function setup(): void {} + public function get_name(): string { return 'book'; } + public function get_type(): string { return 'post_type'; } + /** @return array */ + public function get_options(): array { return $this->options; } + public function get_args(): array { return []; } + /** @return array */ + public function get_config(): array { return $this->config; } + }; + } +} diff --git a/tests/MCP/Middleware/AuditMiddlewareTest.php b/tests/MCP/Middleware/AuditMiddlewareTest.php new file mode 100644 index 00000000..55abf3a5 --- /dev/null +++ b/tests/MCP/Middleware/AuditMiddlewareTest.php @@ -0,0 +1,77 @@ +inserts = []; + } + + public function testRecordsSuccessAudit(): void { + global $wpdb; + + $pipeline = new MiddlewarePipeline(); + $pipeline->add( new AuditMiddleware( new AuditLogger() ) ); + + $context = new RequestContext(); + $context->set_tool_metadata( [ 'name' => 'list_models' ] ); + $context->set_args( [ 'type' => 'post_types' ] ); + + $pipeline->execute( $context, function () { + return new \WP_REST_Response( [ 'ok' => true ] ); + } ); + + $this->assertNotEmpty( $wpdb->inserts ); + $this->assertSame( 'success', $wpdb->inserts[0]['data']['status'] ); + $this->assertStringContainsString( 'list_models', $wpdb->inserts[0]['data']['ability'] ); + } + + public function testRecordsErrorAudit(): void { + global $wpdb; + + $pipeline = new MiddlewarePipeline(); + $pipeline->add( new AuditMiddleware( new AuditLogger() ) ); + + $context = new RequestContext(); + $context->set_tool_metadata( [ 'name' => 'failing_tool' ] ); + + $pipeline->execute( $context, function () { + return new \WP_Error( 'some_error', 'Something failed' ); + } ); + + $this->assertNotEmpty( $wpdb->inserts ); + $this->assertSame( 'error', $wpdb->inserts[0]['data']['status'] ); + $this->assertSame( 'some_error', $wpdb->inserts[0]['data']['error_code'] ); + } + + public function testRecordsRouteBasedAudit(): void { + global $wpdb; + + $pipeline = new MiddlewarePipeline(); + $pipeline->add( new AuditMiddleware( new AuditLogger() ) ); + + $context = new RequestContext(); + $request = new \WP_REST_Request( 'GET', '/saltus-framework/v1/models' ); + $context->set_rest_request( $request ); + $context->set_route( '/saltus-framework/v1/models' ); + + $pipeline->execute( $context, function () { + return new \WP_REST_Response( [ 'ok' => true ] ); + } ); + + $this->assertNotEmpty( $wpdb->inserts ); + $this->assertStringContainsString( 'rest:', $wpdb->inserts[0]['data']['ability'] ); + } +} diff --git a/tests/MCP/Middleware/CacheMiddlewareTest.php b/tests/MCP/Middleware/CacheMiddlewareTest.php new file mode 100644 index 00000000..b0845778 --- /dev/null +++ b/tests/MCP/Middleware/CacheMiddlewareTest.php @@ -0,0 +1,100 @@ +add( new CacheMiddleware( $cache ) ); + $callCount = 0; + + $context = new RequestContext(); + $context->set_args( [ 'type' => 'post_types' ] ); + $context->set_tool_metadata( [ 'name' => 'list_models' ] ); + $context->set_attribute( 'cache_ttl', 300 ); + + $result1 = $pipeline->execute( $context, function () use ( &$callCount ) { + ++$callCount; + return new \WP_REST_Response( [ 'data' => 'fresh' ] ); + } ); + + $this->assertInstanceOf( \WP_REST_Response::class, $result1 ); + $this->assertSame( 1, $callCount ); + + $result2 = $pipeline->execute( $context, function () use ( &$callCount ) { + ++$callCount; + return new \WP_REST_Response( [ 'data' => 'fresh' ] ); + } ); + + $this->assertInstanceOf( \WP_REST_Response::class, $result2 ); + $this->assertSame( 1, $callCount, 'Dispatch should not be called again for cached response' ); + } + + public function testBypassesCacheForNonGet(): void { + $cache = new TransientCache(); + $pipeline = new MiddlewarePipeline(); + $pipeline->add( new CacheMiddleware( $cache ) ); + $callCount = 0; + + $context = new RequestContext(); + $request = new \WP_REST_Request( 'POST', '/saltus-framework/v1/settings/movie' ); + $context->set_rest_request( $request ); + $context->set_attribute( 'cache_ttl', 300 ); + + $result1 = $pipeline->execute( $context, function () use ( &$callCount ) { + ++$callCount; + return new \WP_REST_Response( [ 'updated' => true ] ); + } ); + + $this->assertSame( 1, $callCount ); + + $result2 = $pipeline->execute( $context, function () use ( &$callCount ) { + ++$callCount; + return new \WP_REST_Response( [ 'updated' => true ] ); + } ); + + $this->assertSame( 2, $callCount, 'POST requests should not be cached' ); + } + + public function testPassesErrorResponsesWithoutCaching(): void { + $cache = new TransientCache(); + $pipeline = new MiddlewarePipeline(); + $pipeline->add( new CacheMiddleware( $cache ) ); + + $context = new RequestContext(); + $context->set_args( [ 'bad' => 'args' ] ); + $context->set_tool_metadata( [ 'name' => 'bad_tool' ] ); + + $result = $pipeline->execute( $context, function () { + return new \WP_Error( 'error', 'Something went wrong' ); + } ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + + $cache_key = 'saltus_mcp_' . hash( 'sha256', wp_json_encode( [ + 'tool' => 'bad_tool', + 'args' => [ 'bad' => 'args' ], + 'user' => 1, + 'locale' => 'en_US', + ] ) ); + + $this->assertNull( $cache->get( $cache_key ) ); + } +} diff --git a/tests/MCP/Middleware/ErrorResponseTest.php b/tests/MCP/Middleware/ErrorResponseTest.php new file mode 100644 index 00000000..ed9fd4c7 --- /dev/null +++ b/tests/MCP/Middleware/ErrorResponseTest.php @@ -0,0 +1,81 @@ +assertInstanceOf( \WP_Error::class, $error ); + $this->assertSame( 'rest_forbidden', $error->get_error_code() ); + $this->assertSame( 403, $error->get_error_data()['status'] ?? null ); + $this->assertSame( 'Assign edit_posts to your user.', $error->get_error_data()['hint'] ?? '' ); + } + + public function testForbiddenMinimal(): void { + $error = ErrorResponse::forbidden(); + + $this->assertSame( 'rest_forbidden', $error->get_error_code() ); + $this->assertArrayNotHasKey( 'hint', $error->get_error_data() ); + } + + public function testNotFound(): void { + $error = ErrorResponse::not_found( 'model', 'Model not registered.' ); + + $this->assertInstanceOf( \WP_Error::class, $error ); + $this->assertSame( 'model_not_found', $error->get_error_code() ); + $this->assertSame( 404, $error->get_error_data()['status'] ?? null ); + $this->assertSame( 'Model not registered.', $error->get_error_data()['hint'] ?? '' ); + } + + public function testNotFoundMinimal(): void { + $error = ErrorResponse::not_found(); + + $this->assertSame( 'not_found', $error->get_error_code() ); + } + + public function testInvalid(): void { + $error = ErrorResponse::invalid( 'post_type', 'Must be a string', 'Provide a valid post type slug.' ); + + $this->assertInstanceOf( \WP_Error::class, $error ); + $this->assertSame( 'rest_invalid_param', $error->get_error_code() ); + $this->assertSame( 400, $error->get_error_data()['status'] ?? null ); + $this->assertSame( 'post_type', $error->get_error_data()['field'] ?? '' ); + $this->assertSame( 'Must be a string', $error->get_error_data()['reason'] ?? '' ); + } + + public function testRateLimited(): void { + $error = ErrorResponse::rate_limited( 30 ); + + $this->assertInstanceOf( \WP_Error::class, $error ); + $this->assertSame( 'rate_limited', $error->get_error_code() ); + $this->assertSame( 429, $error->get_error_data()['status'] ?? null ); + $this->assertSame( 30, $error->get_error_data()['retry_after'] ?? 0 ); + } + + public function testInternalError(): void { + $error = ErrorResponse::internal_error( 'Something broke.', 'Contact support.' ); + + $this->assertInstanceOf( \WP_Error::class, $error ); + $this->assertSame( 'rest_internal_error', $error->get_error_code() ); + $this->assertSame( 500, $error->get_error_data()['status'] ?? null ); + $this->assertSame( 'Contact support.', $error->get_error_data()['hint'] ?? '' ); + } + + public function testDispatchError(): void { + $upstream = new \WP_Error( 'db_error', 'Database query failed.', [ 'status' => 503 ] ); + $error = ErrorResponse::dispatch_error( $upstream ); + + $this->assertInstanceOf( \WP_Error::class, $error ); + $this->assertSame( 'db_error', $error->get_error_code() ); + $this->assertSame( 503, $error->get_error_data()['status'] ?? null ); + } +} diff --git a/tests/MCP/Middleware/MiddlewarePipelineTest.php b/tests/MCP/Middleware/MiddlewarePipelineTest.php new file mode 100644 index 00000000..2b8cedfd --- /dev/null +++ b/tests/MCP/Middleware/MiddlewarePipelineTest.php @@ -0,0 +1,135 @@ +add( new class implements MiddlewareInterface { + public function handle( RequestContext $context, callable $next ) { + return new \WP_REST_Response( [ 'handled' => true ] ); + } + } ); + + $context = new RequestContext(); + $result = $pipeline->execute( $context, function () { + return new \WP_REST_Response( [ 'dispatched' => true ] ); + } ); + + $this->assertInstanceOf( \WP_REST_Response::class, $result ); + $data = $result->get_data(); + $this->assertTrue( $data['handled'] ); + } + + public function testExecutesMultipleStagesInOrder(): void { + $pipeline = new MiddlewarePipeline(); + $GLOBALS['_test_log'] = []; + + $pipeline->add( new class implements MiddlewareInterface { + public function handle( RequestContext $context, callable $next ) { + $GLOBALS['_test_log'][] = 'first'; + return $next( $context ); + } + } ); + + $pipeline->add( new class implements MiddlewareInterface { + public function handle( RequestContext $context, callable $next ) { + $GLOBALS['_test_log'][] = 'second'; + return $next( $context ); + } + } ); + + $pipeline->add( new class implements MiddlewareInterface { + public function handle( RequestContext $context, callable $next ) { + $GLOBALS['_test_log'][] = 'third'; + return $next( $context ); + } + } ); + + $context = new RequestContext(); + $pipeline->execute( $context, function () { + $GLOBALS['_test_log'][] = 'dispatch'; + return new \WP_REST_Response( [ 'ok' => true ] ); + } ); + + $this->assertSame( [ 'first', 'second', 'third', 'dispatch' ], $GLOBALS['_test_log'] ); + unset( $GLOBALS['_test_log'] ); + } + + public function testShortCircuitsOnError(): void { + $pipeline = new MiddlewarePipeline(); + $GLOBALS['_test_log'] = []; + + $pipeline->add( new class implements MiddlewareInterface { + public function handle( RequestContext $context, callable $next ) { + $GLOBALS['_test_log'][] = 'pass'; + return $next( $context ); + } + } ); + + $pipeline->add( new class implements MiddlewareInterface { + public function handle( RequestContext $context, callable $next ) { + $GLOBALS['_test_log'][] = 'block'; + return new \WP_Error( 'blocked', 'Blocked' ); + } + } ); + + $pipeline->add( new class implements MiddlewareInterface { + public function handle( RequestContext $context, callable $next ) { + $GLOBALS['_test_log'][] = 'should_not_reach'; + return $next( $context ); + } + } ); + + $context = new RequestContext(); + $result = $pipeline->execute( $context, function () { + $GLOBALS['_test_log'][] = 'dispatch'; + return new \WP_REST_Response( [ 'ok' => true ] ); + } ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( 'blocked', $result->get_error_code() ); + $this->assertSame( [ 'pass', 'block' ], $GLOBALS['_test_log'] ); + unset( $GLOBALS['_test_log'] ); + } + + public function testExecutesWithNoStages(): void { + $pipeline = new MiddlewarePipeline(); + + $context = new RequestContext(); + $result = $pipeline->execute( $context, function () { + return new \WP_REST_Response( [ 'direct' => true ] ); + } ); + + $this->assertInstanceOf( \WP_REST_Response::class, $result ); + $data = $result->get_data(); + $this->assertTrue( $data['direct'] ); + } + + public function testContextIsMutableThroughChain(): void { + $pipeline = new MiddlewarePipeline(); + + $pipeline->add( new class implements MiddlewareInterface { + public function handle( RequestContext $context, callable $next ) { + $context->set_attribute( 'trace', 'added' ); + return $next( $context ); + } + } ); + + $context = new RequestContext(); + $pipeline->execute( $context, function ( RequestContext $ctx ) { + $this->assertSame( 'added', $ctx->get_attribute( 'trace' ) ); + return new \WP_REST_Response( [ 'ok' => true ] ); + } ); + } +} diff --git a/tests/MCP/Middleware/PermissionMiddlewareTest.php b/tests/MCP/Middleware/PermissionMiddlewareTest.php new file mode 100644 index 00000000..0368838a --- /dev/null +++ b/tests/MCP/Middleware/PermissionMiddlewareTest.php @@ -0,0 +1,114 @@ +createStub( ModelFactory::class ) ); + $pipeline = new MiddlewarePipeline(); + $pipeline->add( new PermissionMiddleware( new CapabilityPolicy( $modeler ) ) ); + + $context = new RequestContext(); + $request = new \WP_REST_Request( 'GET', '/saltus-framework/v1/health' ); + $context->set_rest_request( $request ); + + $result = $pipeline->execute( $context, function () { + return new \WP_REST_Response( [ 'ok' => true ] ); + } ); + + $this->assertInstanceOf( \WP_REST_Response::class, $result ); + } + + public function testBlocksWithoutPermission(): void { + global $wp_current_user_can; + $wp_current_user_can = false; + + $modeler = new Modeler( $this->createStub( ModelFactory::class ) ); + $pipeline = new MiddlewarePipeline(); + $pipeline->add( new PermissionMiddleware( new CapabilityPolicy( $modeler ) ) ); + + $context = new RequestContext(); + $request = new \WP_REST_Request( 'GET', '/saltus-framework/v1/models' ); + $context->set_rest_request( $request ); + + $result = $pipeline->execute( $context, function () { + return new \WP_REST_Response( [ 'ok' => true ] ); + } ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( 'rest_forbidden', $result->get_error_code() ); + } + + public function testAllowsToolWithPermissionCallback(): void { + $modeler = new Modeler( $this->createStub( ModelFactory::class ) ); + $pipeline = new MiddlewarePipeline(); + $pipeline->add( new PermissionMiddleware( new CapabilityPolicy( $modeler ) ) ); + + $context = new RequestContext(); + $context->set_tool_metadata( [ + 'name' => 'list_models', + 'has_permission' => function () { + return true; + }, + ] ); + + $result = $pipeline->execute( $context, function () { + return new \WP_REST_Response( [ 'ok' => true ] ); + } ); + + $this->assertInstanceOf( \WP_REST_Response::class, $result ); + } + + public function testBlocksToolWithoutPermission(): void { + $modeler = new Modeler( $this->createStub( ModelFactory::class ) ); + $pipeline = new MiddlewarePipeline(); + $pipeline->add( new PermissionMiddleware( new CapabilityPolicy( $modeler ) ) ); + + $context = new RequestContext(); + $context->set_tool_metadata( [ + 'name' => 'delete_post', + 'has_permission' => function () { + return false; + }, + ] ); + + $result = $pipeline->execute( $context, function () { + return new \WP_REST_Response( [ 'ok' => true ] ); + } ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( 'rest_forbidden', $result->get_error_code() ); + } + + public function testPassesThroughWithoutRequestOrTool(): void { + $modeler = new Modeler( $this->createStub( ModelFactory::class ) ); + $pipeline = new MiddlewarePipeline(); + $pipeline->add( new PermissionMiddleware( new CapabilityPolicy( $modeler ) ) ); + + $context = new RequestContext(); + + $result = $pipeline->execute( $context, function () { + return new \WP_REST_Response( [ 'ok' => true ] ); + } ); + + $this->assertInstanceOf( \WP_REST_Response::class, $result ); + } +} diff --git a/tests/MCP/Middleware/PipelineIntegrationTest.php b/tests/MCP/Middleware/PipelineIntegrationTest.php new file mode 100644 index 00000000..2f2201e0 --- /dev/null +++ b/tests/MCP/Middleware/PipelineIntegrationTest.php @@ -0,0 +1,77 @@ +add( new class implements MiddlewareInterface { + public function handle( RequestContext $context, callable $next ) { + return new \WP_Error( 'blocked', 'Blocked by middleware' ); + } + } ); + + $integration = new PipelineIntegration( $pipeline ); + $server = new \WP_REST_Server(); + $request = new \WP_REST_Request( 'GET', '/test' ); + + $result = $integration->on_rest_pre_dispatch( null, $server, $request ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( 'blocked', $result->get_error_code() ); + } + + public function testRestPreDispatchReturnsResponse(): void { + $pipeline = new MiddlewarePipeline(); + $pipeline->add( new class implements MiddlewareInterface { + public function handle( RequestContext $context, callable $next ) { + return $next( $context ); + } + } ); + + $integration = new PipelineIntegration( $pipeline ); + $server = new \WP_REST_Server(); + $request = new \WP_REST_Request( 'GET', '/saltus-framework/v1/health' ); + + $result = $integration->on_rest_pre_dispatch( null, $server, $request ); + + $this->assertInstanceOf( \WP_REST_Response::class, $result ); + } + + public function testRegisterHooksAddsFilter(): void { + global $wp_filters_registered; + $wp_filters_registered = []; + + $pipeline = new MiddlewarePipeline(); + $integration = new PipelineIntegration( $pipeline ); + $integration->register_rest_hooks(); + + $this->assertArrayHasKey( 'rest_pre_dispatch', $wp_filters_registered ); + $filter = $wp_filters_registered['rest_pre_dispatch'][0] ?? null; + $this->assertNotNull( $filter ); + $this->assertSame( [ $integration, 'on_rest_pre_dispatch' ], $filter['callback'] ); + } + + public function testWithDefaultStagesCreatesPipeline(): void { + $stage = new class implements MiddlewareInterface { + public function handle( RequestContext $context, callable $next ) { + return $next( $context ); + } + }; + + $integration = PipelineIntegration::with_default_stages( $stage ); + + $this->assertInstanceOf( PipelineIntegration::class, $integration ); + } +} diff --git a/tests/MCP/Middleware/RateLimitMiddlewareTest.php b/tests/MCP/Middleware/RateLimitMiddlewareTest.php new file mode 100644 index 00000000..061ae982 --- /dev/null +++ b/tests/MCP/Middleware/RateLimitMiddlewareTest.php @@ -0,0 +1,97 @@ +add( new RateLimitMiddleware( new RateLimiter( 5, 60 ) ) ); + + for ( $i = 0; $i < 5; $i++ ) { + $context = new RequestContext(); + $result = $pipeline->execute( $context, function () { + return new \WP_REST_Response( [ 'ok' => true ] ); + } ); + $this->assertInstanceOf( \WP_REST_Response::class, $result ); + } + } + + public function testBlocksRequestOverLimit(): void { + $pipeline = new MiddlewarePipeline(); + $pipeline->add( new RateLimitMiddleware( new RateLimiter( 2, 60 ) ) ); + + $context1 = new RequestContext(); + $pipeline->execute( $context1, function () { + return new \WP_REST_Response( [ 'ok' => true ] ); + } ); + + $context2 = new RequestContext(); + $pipeline->execute( $context2, function () { + return new \WP_REST_Response( [ 'ok' => true ] ); + } ); + + $context3 = new RequestContext(); + $result = $pipeline->execute( $context3, function () { + return new \WP_REST_Response( [ 'ok' => true ] ); + } ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( 'rate_limited', $result->get_error_code() ); + $this->assertSame( 429, $result->get_error_data()['status'] ?? null ); + } + + public function testAllowsDifferentIdentifiersIndependently(): void { + $pipeline = new MiddlewarePipeline(); + $pipeline->add( new RateLimitMiddleware( new RateLimiter( 1, 60 ) ) ); + + $context = new RequestContext(); + $context->set_tool_metadata( [ 'rate_limit_identifier' => 'client_a' ] ); + $pipeline->execute( $context, function () { + return new \WP_REST_Response( [ 'ok' => true ] ); + } ); + + $context2 = new RequestContext(); + $context2->set_tool_metadata( [ 'rate_limit_identifier' => 'client_b' ] ); + $result = $pipeline->execute( $context2, function () { + return new \WP_REST_Response( [ 'ok' => true ] ); + } ); + + $this->assertInstanceOf( \WP_REST_Response::class, $result ); + + $context3 = new RequestContext(); + $context3->set_tool_metadata( [ 'rate_limit_identifier' => 'client_a' ] ); + $blocked = $pipeline->execute( $context3, function () { + return new \WP_REST_Response( [ 'ok' => true ] ); + } ); + + $this->assertInstanceOf( \WP_Error::class, $blocked ); + } + + public function testSetsRateLimitAttributesOnContext(): void { + $pipeline = new MiddlewarePipeline(); + $pipeline->add( new RateLimitMiddleware( new RateLimiter( 5, 60 ) ) ); + + $context = new RequestContext(); + $pipeline->execute( $context, function ( RequestContext $ctx ) { + $this->assertNotNull( $ctx->get_attribute( 'rate_limit_remaining' ) ); + $this->assertNotNull( $ctx->get_attribute( 'rate_limit_reset_at' ) ); + return new \WP_REST_Response( [ 'ok' => true ] ); + } ); + } +} diff --git a/tests/MCP/Middleware/RequestContextTest.php b/tests/MCP/Middleware/RequestContextTest.php new file mode 100644 index 00000000..adebd59b --- /dev/null +++ b/tests/MCP/Middleware/RequestContextTest.php @@ -0,0 +1,76 @@ +assertSame( [], $context->get_args() ); + + $context->set_args( [ 'post_type' => 'movie' ] ); + $this->assertSame( [ 'post_type' => 'movie' ], $context->get_args() ); + } + + public function testRestRequest(): void { + $context = new RequestContext(); + $this->assertNull( $context->get_rest_request() ); + + $request = new \WP_REST_Request( 'GET', '/test' ); + $context->set_rest_request( $request ); + $this->assertSame( $request, $context->get_rest_request() ); + + $context->set_rest_request( null ); + $this->assertNull( $context->get_rest_request() ); + } + + public function testResponse(): void { + $context = new RequestContext(); + $this->assertNull( $context->get_response() ); + + $response = new \WP_REST_Response( [ 'ok' => true ] ); + $context->set_response( $response ); + $this->assertSame( $response, $context->get_response() ); + + $context->set_response( null ); + $this->assertNull( $context->get_response() ); + } + + public function testAttributes(): void { + $context = new RequestContext(); + $this->assertSame( [], $context->get_attributes() ); + $this->assertNull( $context->get_attribute( 'nonexistent' ) ); + $this->assertSame( 'default', $context->get_attribute( 'nonexistent', 'default' ) ); + + $context->set_attribute( 'key1', 'value1' ); + $context->set_attribute( 'key2', 42 ); + $this->assertSame( 'value1', $context->get_attribute( 'key1' ) ); + $this->assertSame( 42, $context->get_attribute( 'key2' ) ); + + $context->set_attributes( [ 'new' => 'all' ] ); + $this->assertSame( [ 'new' => 'all' ], $context->get_attributes() ); + } + + public function testRoute(): void { + $context = new RequestContext(); + $this->assertSame( '', $context->get_route() ); + + $context->set_route( '/saltus-framework/v1/models' ); + $this->assertSame( '/saltus-framework/v1/models', $context->get_route() ); + } + + public function testToolMetadata(): void { + $context = new RequestContext(); + $this->assertSame( [], $context->get_tool_metadata() ); + + $context->set_tool_metadata( [ 'name' => 'list_models', 'parameters' => [] ] ); + $this->assertSame( [ 'name' => 'list_models', 'parameters' => [] ], $context->get_tool_metadata() ); + } +} diff --git a/tests/MCP/Middleware/ValidationMiddlewareTest.php b/tests/MCP/Middleware/ValidationMiddlewareTest.php new file mode 100644 index 00000000..50b7d42b --- /dev/null +++ b/tests/MCP/Middleware/ValidationMiddlewareTest.php @@ -0,0 +1,112 @@ +add( new ValidationMiddleware() ); + + $context = new RequestContext(); + $context->set_args( [ 'post_type' => 'movie' ] ); + $context->set_tool_metadata( [ + 'name' => 'list_posts', + 'parameters' => [ + 'post_type' => [ 'required' => true, 'type' => 'string' ], + ], + ] ); + + $result = $pipeline->execute( $context, function () { + return new \WP_REST_Response( [ 'ok' => true ] ); + } ); + + $this->assertInstanceOf( \WP_REST_Response::class, $result ); + } + + public function testRejectsInvalidToolArgs(): void { + $pipeline = new MiddlewarePipeline(); + $pipeline->add( new ValidationMiddleware() ); + + $context = new RequestContext(); + $context->set_args( [ 'post_type' => 123 ] ); + $context->set_tool_metadata( [ + 'name' => 'list_posts', + 'parameters' => [ + 'post_type' => [ 'required' => true, 'type' => 'string' ], + ], + ] ); + + $result = $pipeline->execute( $context, function () { + return new \WP_REST_Response( [ 'ok' => true ] ); + } ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( 'rest_invalid_param', $result->get_error_code() ); + } + + public function testRejectsMissingRequiredToolArgs(): void { + $pipeline = new MiddlewarePipeline(); + $pipeline->add( new ValidationMiddleware() ); + + $context = new RequestContext(); + $context->set_args( [] ); + $context->set_tool_metadata( [ + 'name' => 'list_posts', + 'parameters' => [ + 'post_type' => [ 'required' => true, 'type' => 'string' ], + ], + ] ); + + $result = $pipeline->execute( $context, function () { + return new \WP_REST_Response( [ 'ok' => true ] ); + } ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + } + + public function testPassesThroughWithoutSchema(): void { + $pipeline = new MiddlewarePipeline(); + $pipeline->add( new ValidationMiddleware() ); + + $context = new RequestContext(); + $context->set_args( [ 'anything' => 'goes' ] ); + + $result = $pipeline->execute( $context, function () { + return new \WP_REST_Response( [ 'ok' => true ] ); + } ); + + $this->assertInstanceOf( \WP_REST_Response::class, $result ); + } + + public function testValidatesRestArgs(): void { + $pipeline = new MiddlewarePipeline(); + $pipeline->add( new ValidationMiddleware() ); + + $context = new RequestContext(); + $request = new \WP_REST_Request( 'GET', '/saltus-framework/v1/models' ); + $request->set_param( 'post_type', 'movie' ); + + $attrs = $request->get_attributes(); + $attrs['args'] = [ + 'post_type' => [ 'required' => true, 'type' => 'string' ], + ]; + + $context->set_rest_request( $request ); + + $result = $pipeline->execute( $context, function () { + return new \WP_REST_Response( [ 'ok' => true ] ); + } ); + + $this->assertInstanceOf( \WP_REST_Response::class, $result ); + } +} diff --git a/tests/MCP/Tools/DeletePostTest.php b/tests/MCP/Tools/DeletePostTest.php new file mode 100644 index 00000000..14cff093 --- /dev/null +++ b/tests/MCP/Tools/DeletePostTest.php @@ -0,0 +1,81 @@ +tool = new DeletePost(); + } + + public function testBasicGetters(): void { + $this->assertSame( 'delete_post', $this->tool->get_name() ); + $this->assertStringContainsString( 'Delete (trash or force delete) a post by ID', $this->tool->get_description() ); + + $params = $this->tool->get_parameters(); + $this->assertArrayHasKey( 'post_id', $params ); + $this->assertArrayHasKey( 'post_type', $params ); + $this->assertArrayHasKey( 'force', $params ); + } + + public function testBuildRestRequest(): void { + // Default posts post_type + $request = $this->tool->build_rest_request( [ + 'post_id' => 123, + ] ); + + $this->assertInstanceOf( WP_REST_Request::class, $request ); + $this->assertSame( 'DELETE', $request->get_method() ); + $this->assertSame( '/wp/v2/posts/123', $request->get_route() ); + $this->assertSame( [ 'force' => false ], $request->get_params() ); + + // Custom post_type and force flag + global $wp_post_type_objects; + $wp_post_type_objects['book'] = (object) [ + 'rest_base' => 'books', + ]; + + $request = $this->tool->build_rest_request( [ + 'post_id' => 456, + 'post_type' => 'book', + 'force' => true, + ] ); + + $this->assertSame( '/wp/v2/books/456', $request->get_route() ); + $this->assertSame( [ 'force' => true ], $request->get_params() ); + } + + public function testHasPermission(): void { + global $wp_current_user_can; + + // When user can delete post + $wp_current_user_can = [ + 'delete_post:123' => true, + ]; + $this->assertTrue( $this->tool->has_permission( [ 'post_id' => 123 ] ) ); + + // When user cannot delete post + $wp_current_user_can = [ + 'delete_post:123' => false, + ]; + $this->assertFalse( $this->tool->has_permission( [ 'post_id' => 123 ] ) ); + + // Empty post_id + $this->assertFalse( $this->tool->has_permission( [] ) ); + } +} diff --git a/tests/MCP/Tools/GetHealthTest.php b/tests/MCP/Tools/GetHealthTest.php new file mode 100644 index 00000000..8676f15a --- /dev/null +++ b/tests/MCP/Tools/GetHealthTest.php @@ -0,0 +1,53 @@ +tool = new GetHealth(); + } + + public function testBasicGetters(): void { + $this->assertSame( 'get_health', $this->tool->get_name() ); + $this->assertStringContainsString( 'Get Saltus Framework health', $this->tool->get_description() ); + $this->assertSame( [], $this->tool->get_parameters() ); + $this->assertTrue( $this->tool->is_cacheable() ); + $this->assertSame( 60, $this->tool->cache_ttl() ); + + $capability = $this->tool->get_rest_capability(); + $this->assertNotNull( $capability ); + $this->assertSame( ModelRestPolicy::CAPABILITY_HEALTH, $capability->get_capability() ); + } + + public function testBuildRestRequest(): void { + $request = $this->tool->build_rest_request( [] ); + + $this->assertInstanceOf( WP_REST_Request::class, $request ); + $this->assertSame( 'GET', $request->get_method() ); + $this->assertSame( '/saltus-framework/v1/health', $request->get_route() ); + } + + public function testHasPermission(): void { + global $wp_current_user_can; + + $wp_current_user_can = true; + $this->assertTrue( $this->tool->has_permission( [] ) ); + + $wp_current_user_can = false; + $this->assertFalse( $this->tool->has_permission( [] ) ); + } +} diff --git a/tests/Rest/DuplicateControllerTest.php b/tests/Rest/DuplicateControllerTest.php index 91935480..eab795c3 100644 --- a/tests/Rest/DuplicateControllerTest.php +++ b/tests/Rest/DuplicateControllerTest.php @@ -103,12 +103,14 @@ public function testCreateItemReturnsErrorWhenModelDoesNotEnableDuplicate(): voi $modeler = $this->createStub( Modeler::class ); $modeler->method( 'get_models' )->willReturn( [ - 'book' => $this->createModelMock( - [ - 'show_in_rest' => true, - 'saltus_rest' => [ 'duplicate' => false ], - ] - ), + 'book' => $this->createModelMock( + [ + 'show_in_rest' => true, + ], + [ + 'features' => [ 'duplicate' => [ 'show_in_rest' => false ] ], + ] + ), ] ); $this->controller = new DuplicateController( new ModelRestPolicy( $modeler ) ); @@ -185,16 +187,20 @@ public function testCreateItemReturnsErrorWhenDuplicatedPostCannotBeRetrieved(): * @param array $options * @return Model&object{options: array} */ - private function createModelMock( array $options ) { - return new class( $options ) implements Model { + private function createModelMock( array $options, array $config = [] ) { + return new class( $options, $config ) implements Model { /** @var array */ public array $options; + /** @var array */ + public array $config; /** * @param array $options + * @param array $config */ - public function __construct( array $options ) { + public function __construct( array $options, array $config = [] ) { $this->options = $options; + $this->config = $config; } public function setup(): void {} @@ -211,6 +217,10 @@ public function get_options(): array { return $this->options; } + public function get_config(): array { + return $this->config; + } + public function get_args(): array { return []; } diff --git a/tests/Rest/MetaControllerTest.php b/tests/Rest/MetaControllerTest.php index 3cdb9158..abf9fafd 100644 --- a/tests/Rest/MetaControllerTest.php +++ b/tests/Rest/MetaControllerTest.php @@ -136,7 +136,9 @@ public function testGetAllItemsFiltersModelsWhenPolicyIsInjected(): void { 'Books', [ 'show_in_rest' => true, - 'saltus_rest' => [ 'meta' => true ], + ], + [ + 'meta' => [ 'show_in_rest' => true ], ] ), 'movie' => $this->createModelMock( @@ -146,7 +148,9 @@ public function testGetAllItemsFiltersModelsWhenPolicyIsInjected(): void { 'Movies', [ 'show_in_rest' => true, - 'saltus_rest' => [ 'meta' => false ], + ], + [ + 'meta' => [ 'show_in_rest' => false ], ] ), 'hidden' => $this->createModelMock( @@ -156,7 +160,9 @@ public function testGetAllItemsFiltersModelsWhenPolicyIsInjected(): void { 'Hidden', [ 'show_in_rest' => false, - 'saltus_rest' => true, + ], + [ + 'meta' => [ 'show_in_rest' => false ], ] ), ] @@ -525,7 +531,7 @@ private function postTypeObject( string $post_type, string $edit_capability ): \ /** * @return \Saltus\WP\Framework\Models\Model&object{args: array} */ - private function createModelMock( string $type, ?array $meta = null, string $label_singular = '', string $label_plural = '', array $options = [] ) { + private function createModelMock( string $type, ?array $meta = null, string $label_singular = '', string $label_plural = '', array $options = [], array $config = [] ) { $args = []; if ( $meta !== null ) { @@ -538,21 +544,25 @@ private function createModelMock( string $type, ?array $meta = null, string $lab $args['label_plural'] = $label_plural; } - return new class( $type, $args, $options ) implements \Saltus\WP\Framework\Models\Model { + return new class( $type, $args, $options, $config ) implements \Saltus\WP\Framework\Models\Model { /** @var array */ public array $args; /** @var array */ public array $options; + /** @var array */ + public array $config = []; private string $type; /** * @param array $args * @param array $options + * @param array $config */ - public function __construct( string $type, array $args, array $options ) { + public function __construct( string $type, array $args, array $options, array $config = [] ) { $this->type = $type; $this->args = $args; $this->options = $options; + $this->config = $config; } public function setup(): void {} @@ -572,6 +582,10 @@ public function get_options(): array { public function get_args(): array { return $this->args; } + + public function get_config(): array { + return $this->config; + } }; } } diff --git a/tests/Rest/ModelsControllerTest.php b/tests/Rest/ModelsControllerTest.php index 6a8fad05..e2cc5516 100644 --- a/tests/Rest/ModelsControllerTest.php +++ b/tests/Rest/ModelsControllerTest.php @@ -165,22 +165,9 @@ public function testGetItemsFiltersModelsWhenPolicyIsInjected(): void { [ 'public' => true, 'show_in_rest' => true, - 'saltus_rest' => [ 'models' => true ], ] ); $model2 = $this->createModelMock( - 'post_type', - 'Movies', - 'Movies', - 'movie', - 'post_type', - [ - 'public' => true, - 'show_in_rest' => true, - 'saltus_rest' => [ 'models' => false ], - ] - ); - $model3 = $this->createModelMock( 'post_type', 'Hidden', 'Hidden', @@ -189,15 +176,13 @@ public function testGetItemsFiltersModelsWhenPolicyIsInjected(): void { [ 'public' => true, 'show_in_rest' => false, - 'saltus_rest' => true, ] ); $this->modeler->method( 'get_models' )->willReturn( [ 'book' => $model1, - 'movie' => $model2, - 'hidden' => $model3, + 'hidden' => $model2, ] ); $this->controller = new ModelsController( $this->modeler, new ModelRestPolicy( $this->modeler ) ); @@ -257,6 +242,10 @@ public function get_options(): array { public function get_args(): array { return []; } + + public function get_config(): array { + return []; + } }; $this->modeler->method( 'get_models' )->willReturn( [ 'book' => $model ] ); @@ -307,9 +296,10 @@ private function createModelMock( string $getType = 'post_type', array $options = [], string $description = '', - bool $featuredImage = true + bool $featuredImage = true, + array $config = [] ) { - return new class( $type, $one, $many, $name, $getType, $options, $description, $featuredImage ) implements Model { + return new class( $type, $one, $many, $name, $getType, $options, $description, $featuredImage, $config ) implements Model { public string $type; public string $one; public string $many; @@ -318,10 +308,13 @@ private function createModelMock( public bool $featured_image; /** @var array */ public array $options; + /** @var array */ + public array $config; private string $getType; /** * @param array $options + * @param array $config */ public function __construct( string $type, @@ -331,7 +324,8 @@ public function __construct( string $getType, array $options, string $description, - bool $featuredImage + bool $featuredImage, + array $config = [] ) { $this->type = $type; $this->one = $one; @@ -341,6 +335,7 @@ public function __construct( $this->options = $options; $this->description = $description; $this->featured_image = $featuredImage; + $this->config = $config; } public function setup(): void {} @@ -360,6 +355,10 @@ public function get_options(): array { public function get_args(): array { return []; } + + public function get_config(): array { + return $this->config; + } }; } diff --git a/tests/Rest/ReorderControllerTest.php b/tests/Rest/ReorderControllerTest.php index cdb65481..9033dfe5 100644 --- a/tests/Rest/ReorderControllerTest.php +++ b/tests/Rest/ReorderControllerTest.php @@ -142,7 +142,9 @@ public function testCreateItemSkipsPostsWhoseModelDoesNotEnableReorder(): void { 'book' => $this->createModelMock( [ 'show_in_rest' => true, - 'saltus_rest' => [ 'reorder' => false ], + ], + [ + 'features' => [ 'drag_and_drop' => [ 'show_in_rest' => false ] ], ] ), ] @@ -201,16 +203,20 @@ public function testCreateItemUpdatesMenuOrder(): void { * @param array $options * @return Model&object{options: array} */ - private function createModelMock( array $options ) { - return new class( $options ) implements Model { + private function createModelMock( array $options, array $config = [] ) { + return new class( $options, $config ) implements Model { /** @var array */ public array $options; + /** @var array */ + public array $config; /** * @param array $options + * @param array $config */ - public function __construct( array $options ) { + public function __construct( array $options, array $config = [] ) { $this->options = $options; + $this->config = $config; } public function setup(): void {} @@ -230,6 +236,10 @@ public function get_options(): array { public function get_args(): array { return []; } + + public function get_config(): array { + return $this->config; + } }; } } diff --git a/tests/Rest/RestServerTest.php b/tests/Rest/RestServerTest.php index 3b3c3545..a5f343ed 100644 --- a/tests/Rest/RestServerTest.php +++ b/tests/Rest/RestServerTest.php @@ -40,7 +40,25 @@ public function testRegisterRoutesRegistersAllControllerRoutes(): void { 'post_type', [ 'show_in_rest' => true, - 'saltus_rest' => true, + ], + [ + 'meta' => [ + 'show_in_rest' => true, + ], + 'settings' => [ + 'show_in_rest' => true, + ], + 'features' => [ + 'duplicate' => [ + 'show_in_rest' => true, + ], + 'single_export' => [ + 'show_in_rest' => true, + ], + 'drag_and_drop' => [ + 'show_in_rest' => true, + ], + ], ] ), ] @@ -76,9 +94,10 @@ public function testRegisterRoutesRegistersMoreThanOneRoute(): void { 'post_type', [ 'show_in_rest' => true, - 'saltus_rest' => [ - 'models' => true, - 'settings' => true, + ], + [ + 'settings' => [ + 'show_in_rest' => true, ], ] ), @@ -90,7 +109,7 @@ public function testRegisterRoutesRegistersMoreThanOneRoute(): void { $this->assertGreaterThan( 1, count( $wp_rest_routes_registered ) ); } - public function testRegisterRoutesRegistersOnlyHealthWithoutOptIn(): void { + public function testRegisterRoutesRegistersAllRoutesByDefault(): void { global $wp_rest_routes_registered; $this->modeler->method( 'get_models' )->willReturn( @@ -101,7 +120,7 @@ public function testRegisterRoutesRegistersOnlyHealthWithoutOptIn(): void { $this->createServer()->register_routes(); - $this->assertCount( 1, $wp_rest_routes_registered ); + $this->assertCount( 10, $wp_rest_routes_registered ); $this->assertSame( '/health', $wp_rest_routes_registered[0]['route'] ); } @@ -114,7 +133,6 @@ public function testRegisterRoutesRespectsShowInRestFalse(): void { 'post_type', [ 'show_in_rest' => false, - 'saltus_rest' => true, ] ), ] @@ -129,18 +147,22 @@ public function testRegisterRoutesRespectsShowInRestFalse(): void { /** * @return Model&object{options: array} */ - private function createModelMock( string $type, array $options ) { - return new class( $type, $options ) implements Model { + private function createModelMock( string $type, array $options, array $config = [] ) { + return new class( $type, $options, $config ) implements Model { private string $type; /** @var array */ public array $options; + /** @var array */ + public array $config; /** * @param array $options + * @param array $config */ - public function __construct( string $type, array $options ) { + public function __construct( string $type, array $options, array $config = [] ) { $this->type = $type; $this->options = $options; + $this->config = $config; } public function setup(): void {} @@ -160,6 +182,10 @@ public function get_options(): array { public function get_args(): array { return []; } + + public function get_config(): array { + return $this->config; + } }; } diff --git a/tests/Rest/SettingsControllerTest.php b/tests/Rest/SettingsControllerTest.php index 339265fe..44db7284 100644 --- a/tests/Rest/SettingsControllerTest.php +++ b/tests/Rest/SettingsControllerTest.php @@ -116,7 +116,9 @@ public function testGetItemReturnsNotFoundWhenModelDoesNotEnableSettings(): void 'book' => $this->createModelMock( [ 'show_in_rest' => true, - 'saltus_rest' => [ 'settings' => false ], + ], + [ + 'settings' => [ 'show_in_rest' => false ], ] ), ] @@ -267,16 +269,16 @@ public function testGetItemSchema(): void { * @param array $options * @return Model&object{options: array} */ - private function createModelMock( array $options ) { - return new class( $options ) implements Model { + private function createModelMock( array $options, array $config = [] ) { + return new class( $options, $config ) implements Model { /** @var array */ public array $options; + /** @var array */ + public array $config; - /** - * @param array $options - */ - public function __construct( array $options ) { + public function __construct( array $options, array $config = [] ) { $this->options = $options; + $this->config = $config; } public function setup(): void {} @@ -296,6 +298,10 @@ public function get_options(): array { public function get_args(): array { return []; } + + public function get_config(): array { + return $this->config; + } }; } diff --git a/tests/Rest/functions.php b/tests/Rest/functions.php index 0ecc8ab1..d247389e 100644 --- a/tests/Rest/functions.php +++ b/tests/Rest/functions.php @@ -122,6 +122,10 @@ public function get_method(): string { public function get_route(): string { return $this->route; } + + public function get_attributes(): array { + return []; + } } } @@ -839,6 +843,12 @@ function esc_url( $url ): string { } } +if ( ! function_exists( 'esc_url_raw' ) ) { + function esc_url_raw( $url ): string { + return (string) $url; + } +} + if ( ! function_exists( 'esc_html__' ) ) { function esc_html__( string $text, string $domain = 'default' ): string { return $text; diff --git a/tests/Unit/ModelerLegacyTest.php b/tests/Unit/ModelerLegacyTest.php index 4904e567..5dd0b93d 100644 --- a/tests/Unit/ModelerLegacyTest.php +++ b/tests/Unit/ModelerLegacyTest.php @@ -213,4 +213,8 @@ public function get_options(): array { public function get_args(): array { return []; } + + public function get_config(): array { + return []; + } }