diff --git a/.agents/skills/saltus-mcp/SKILL.md b/.agents/skills/saltus-mcp/SKILL.md new file mode 100644 index 00000000..9a476cc4 --- /dev/null +++ b/.agents/skills/saltus-mcp/SKILL.md @@ -0,0 +1,199 @@ +--- +name: saltus-mcp +description: "Use when connecting an AI agent/client to Saltus Framework MCP/Abilities (saltus/*) on an active WordPress site — discovery, health-first flow, model/meta inspection, safe read/write patterns, permission and rate-limit handling, and write confirmation flows." +compatibility: "Requires WordPress 7.0+ with the Abilities API and an active plugin built on Saltus Framework. Client-side skill; no PHP required on the agent side." +--- + +# Saltus MCP + +Connect an AI client to the WordPress-native MCP/Abilities surface exposed by Saltus Framework. + +## When to use + +Use this skill when the task involves: + +- consuming `saltus/*` abilities from an AI client or editor agent, +- discovering what post types, taxonomies, meta fields, and tools a Saltus site exposes, +- reading or writing WordPress content through Saltus tools, +- diagnosing `saltus/*` call failures (permissions, params, rate limits, model visibility). + +## Prerequisites + +- A running WordPress site (7.0+) with the Abilities API. +- An active plugin using Saltus Framework (registers `saltus/*` abilities on `wp_abilities_api_init`). +- Client access to the abilities (discovery list, or a configured tool list). +- The site URL and a user with the needed WordPress capabilities. + +## Discovery + +- Discover abilities from WordPress and filter for the `saltus/` prefix. +- Treat the ability definitions as the source of truth for tool names, input schemas, permissions, and transport metadata. +- Each Saltus ability carries metadata like: + +```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 (e.g. `saltus/list-models`) for native execution. + +## Recommended call flow + +1. `saltus/get-health` +2. `saltus/list-models` +3. `saltus/list-meta-fields` +4. Choose the relevant model +5. `saltus/get-model` or `saltus/get-meta-fields` for 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 target model, fields, and permissions are clear + +For narrow workflows that already know the post type, skip the aggregate metadata call and use `saltus/get-meta-fields` directly. + +## Health first + +Call `saltus/get-health` at session start. + +| Health signal | Client behavior | +|---------------|-----------------| +| `status: ok` | Continue normally | +| `status: degraded` | Prefer read-only planning; explain the degraded state before writes | +| High `error_rate` | Avoid repeated retries; inspect permission and input errors | +| High latency | Reduce broad listing calls; keep page sizes modest | +| Rate limit enabled | Respect rate-limit errors and `retry_after` data | + +Health is framework-scoped; it does not prove a specific model or write is available. + +## Model discovery + +- Use `saltus/list-models` to discover post types and taxonomies. Never assume a model exists from a user phrase. +- Map user language to model names after reading labels and slugs; prefer exact slugs. +- If multiple models are plausible, ask the user to choose. +- Treat missing models as configuration or permission issues, not empty content. + +## Metadata discovery + +- Use `saltus/list-meta-fields` for site-wide discovery; `saltus/get-meta-fields` for one post type. +- Saltus returns raw config in `meta` and normalized data in `normalized.fields` (flattened paths) plus `normalized.rest_meta_keys`. +- Prefer `normalized.fields` for reasoning. Example paths: + +```text +points_info.coordinates.latitude +points_info.coordinates.longitude +points_info.tooltipContent +``` + +- When writing post meta, map the field path back to its writable REST meta root. Update the containing serialized structure carefully; do not send only the leaf path. + +## Safe read patterns + +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 task. For terms: discover the taxonomy with `saltus/list-models`, call `saltus/list-terms`, and use returned term IDs in create/update calls. + +## Safe write patterns + +Before creating or updating, know: target post type, target post ID (for updates/deletes), writable meta roots, expected field shape, current user intent, and the relevant capability outcome. + +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 (many posts from one instruction) unless the exact target list is shown and confirmed. + +## Destructive actions + +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; never bypass them. + +| Failure | Likely cause | Client response | +|---------|--------------|-----------------| +| `rest_forbidden` | Current user lacks capability | Explain required access and stop | +| `invalid_params` | Missing or malformed arguments | Fix arguments once, then retry | +| model not found | Model not registered / not REST-enabled / not visible | Ask for a different model or admin config | +| write denied | User can read but not mutate | Offer a read-only summary instead | + +Do not retry permission failures repeatedly; they are stable until role or model config changes. + +## Rate limits + +- 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 responses in WordPress transients. Repeated reads may return cached data. To confirm a mutation: execute the write, let Saltus clear its MCP cache, then read the object again. Mutating tools clear the cache after execution. + +## Planning heuristics + +| 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` | + +## Prompt guidance + +Ground 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 + +- 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 docs: https://docs.saltus.dev/mcp/ +- Abilities reference: https://docs.saltus.dev/mcp/abilities.html +- Client integration guide: https://docs.saltus.dev/mcp/clients.html 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..15bd620c 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: @@ -106,35 +106,117 @@ The above example will create a Custom Post Type 'movie' and a hierarchical Taxo Currently there are 2 types of Model, one for **Custom Post Types** and another for **Taxonomies**. Depending what you define, you’ll have different parameters available. -### Model type = ‘cpt’ +### Model type = `cpt` | Parameter | Description | | --- | --- | -| active | `boolean` - sets this model to active or inactive | -type | `string` - type of model | -name | `string` - identifier of the custom post type | -features | `array` - Features that this CPT will support (More Info Soon) | -supports | `array` - refer to the supports argument of the [register_post_type](https://developer.wordpress.org/reference/functions/register_post_type/) function from WordPress | -labels | `array` - Everything related with labels for this custom post type (More Info Soon) | -options | `array` - Refer to the second argument in the [register_post_type](https://developer.wordpress.org/reference/functions/register_post_type/) function from WordPress | -block_editor | `boolean` - if the Block Editor should be enabled or not | -meta | `array` - Information for the Metaboxes for this CPT (More Info Soon) | -settings | `array` - Information for the Settings page of this CPT (More Info Soon) | - -Example File for a CPT Model (Soon) +| `active` | `boolean` - set to `false` to skip this model; defaults to active | +| `type` | `string` - `cpt`, `post-type`, `posttype`, or `post_type` | +| `name` | `string` - WordPress post type identifier (maximum 20 characters) | +| `features` | `array` - enable `admin_cols`, `admin_filters`, `draganddrop`, `duplicate`, `quick_edit`, `remember_tabs`, and `single_export`; see [Features Reference](docs/guides/features.md) | +| `supports` | `array` - WordPress post type supports such as `title`, `editor`, and `thumbnail` | +| `labels` | `array` - singular/plural names, text domain, featured-image wording, and UI/message/label overrides | +| `options` | `array` - overrides passed to [`register_post_type()`](https://developer.wordpress.org/reference/functions/register_post_type/) | +| `block_editor` | `boolean` - set to `false` to disable the block editor for this post type | +| `blocks` | `boolean|array` - generate dynamic list and/or single blocks; see [Blocks Guide](docs/guides/blocks.md) | +| `meta` | `array` - Codestar metaboxes keyed by metabox ID, each with `fields` or `sections`; `register_rest_api: true` exposes declared fields | +| `settings` | `array` - Codestar settings pages keyed by option/page ID, each with page arguments and `fields` or `sections` | +| `saltus_rest` | `boolean|array` - opt models and feature routes into the Saltus REST surface | +| `mcp_tools` | `boolean|array` - control model visibility through WordPress-native MCP/Abilities | + +The `labels` object supports `has_one`, `has_many`, `text_domain`, and `featured_image`. Use `overrides.ui.enter_title_here` for the editor title placeholder, `overrides.labels` for any WordPress registration label, `overrides.messages` for post-update messages, and `overrides.bulk_messages` for bulk-action messages. See the [features and model reference](docs/guides/features.md#labels) for the accepted message keys. + +Metaboxes and settings pages use [Codestar Framework field definitions](https://codestarframework.com/documentation/#/fields). Fields may be supplied directly or grouped into `sections`. For metaboxes, `data_type: serialize` stores the box as one serialized value; the default `unserialize` mode stores fields separately. Set `register_rest_api: true` on a metabox to register its fields with the WordPress REST API. + +Complete CPT model: +```php + 'cpt', + 'name' => 'movie', + 'supports' => [ 'title', 'editor', 'excerpt', 'thumbnail' ], + 'labels' => [ + 'has_one' => 'Movie', + 'has_many' => 'Movies', + 'text_domain' => 'my-plugin', + 'featured_image' => 'Poster', + 'overrides' => [ + 'ui' => [ 'enter_title_here' => 'Enter movie title' ], + ], + ], + 'options' => [ + 'has_archive' => true, + 'rewrite' => [ 'slug' => 'movies' ], + ], + 'blocks' => [ 'list' => true, 'single' => true ], + 'saltus_rest' => true, + 'mcp_tools' => true, + 'features' => [ + 'admin_cols' => [ + 'release_year' => [ 'title' => 'Year', 'meta_key' => 'release_year' ], + 'genre' => [ 'title' => 'Genres', 'taxonomy' => 'genre' ], + ], + 'admin_filters' => [ + 'genre' => [ 'taxonomy' => 'genre', 'label' => 'All genres' ], + ], + 'duplicate' => [ 'label' => 'Duplicate movie' ], + 'single_export' => [ 'label' => 'Export movie' ], + ], + 'meta' => [ + 'movie_details' => [ + 'title' => 'Movie Details', + 'register_rest_api' => true, + 'fields' => [ + 'release_year' => [ 'type' => 'number', 'title' => 'Release year' ], + 'rating' => [ 'type' => 'text', 'title' => 'Rating' ], + ], + ], + ], + 'settings' => [ + 'movie_settings' => [ + 'title' => 'Movie Settings', + 'fields' => [ + 'items_per_page' => [ 'type' => 'number', 'title' => 'Items per page' ], + ], + ], + ], +]; +``` -### Model type = ‘category’ or ‘tag’ +### Model type = `category` or `tag` | Parameter | Description | | --- | --- | -type | `string` - ‘category’ // or 'tag' to set it to non-hierarchical automatically | -name | `string` - identifier of this taxonomy | -associations | `string` - to what CPT it should be associated | -labels | `array` - Everything related with labels for this custom post type (More Info Soon) | -options | `array` - Refer to the third parameter for register_taxonomy function from WordPress | +| `active` | `boolean` - set to `false` to skip this model; defaults to active | +| `type` | `string` - `category`/`cat` for hierarchical behavior, or `tag`, `taxonomy`, or `tax` for non-hierarchical behavior | +| `name` | `string` - WordPress taxonomy identifier (maximum 32 characters) | +| `associations` | `string|array` - post type or post types associated with the taxonomy | +| `labels` | `array` - the same singular/plural and `overrides.labels` structure used by CPT models | +| `options` | `array` - overrides passed to [`register_taxonomy()`](https://developer.wordpress.org/reference/functions/register_taxonomy/) | + +Complete taxonomy model: -Example File for a Taxonomy Model (Soon) +```php + 'category', + 'name' => 'genre', + 'associations' => [ 'movie' ], + 'labels' => [ + 'has_one' => 'Genre', + 'has_many' => 'Genres', + 'overrides' => [ + 'labels' => [ 'all_items' => 'All movie genres' ], + ], + ], + 'options' => [ + 'public' => true, + 'show_in_rest' => true, + 'rewrite' => [ 'slug' => 'movie-genre' ], + ], +]; +``` ## Filters/Hooks @@ -187,7 +269,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 @@ -228,6 +310,8 @@ Saltus wraps ability execution with WordPress-native audit logging, rate limitin | `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 registered meta fields for a post | +| `list_block_models` | List model-driven blocks and their attributes | Meta field discovery preserves the raw Saltus/Codestar configuration in `meta` and includes normalized MCP-friendly metadata in `normalized.fields` and `normalized.rest_meta_keys`. Nested fields are exposed as paths such as `points_info.coordinates.latitude`, with JSON-schema-like types and REST writability information. diff --git a/assets/Feature/Blocks/editor.js b/assets/Feature/Blocks/editor.js new file mode 100644 index 00000000..22cc1f7c --- /dev/null +++ b/assets/Feature/Blocks/editor.js @@ -0,0 +1,117 @@ +( function ( wp, definitions ) { + 'use strict'; + + if ( ! wp || ! Array.isArray( definitions ) ) { + return; + } + + var el = wp.element.createElement; + var Fragment = wp.element.Fragment; + var InspectorControls = wp.blockEditor.InspectorControls; + var PanelBody = wp.components.PanelBody; + var RangeControl = wp.components.RangeControl; + var SelectControl = wp.components.SelectControl; + var TextControl = wp.components.TextControl; + var ToggleControl = wp.components.ToggleControl; + var CheckboxControl = wp.components.CheckboxControl; + var ServerSideRender = wp.serverSideRender; + + function metaControls( definition, attributes, setAttributes ) { + return ( definition.metaFields || [] ).map( function ( field ) { + var path = field.path || ''; + var selected = attributes.metaFields || []; + return el( CheckboxControl, { + key: path, + label: field.label || path, + checked: selected.indexOf( path ) !== -1, + onChange: function ( checked ) { + var next = selected.filter( function ( item ) { return item !== path; } ); + if ( checked ) { + next.push( path ); + } + setAttributes( { metaFields: next } ); + } + } ); + } ); + } + + function editComponent( definition ) { + return function ( props ) { + var attributes = props.attributes; + var controls = []; + + if ( definition.view === 'list' ) { + controls.push( + el( RangeControl, { + key: 'postsToShow', label: wp.i18n.__( 'Posts to show', 'saltus-framework' ), + min: 1, max: 100, value: attributes.postsToShow, + onChange: function ( value ) { props.setAttributes( { postsToShow: value } ); } + } ), + el( SelectControl, { + key: 'orderBy', label: wp.i18n.__( 'Order by', 'saltus-framework' ), value: attributes.orderBy, + options: [ 'date', 'title', 'modified', 'menu_order', 'ID' ].map( function ( value ) { return { label: value, value: value }; } ), + onChange: function ( value ) { props.setAttributes( { orderBy: value } ); } + } ), + el( SelectControl, { + key: 'order', label: wp.i18n.__( 'Order', 'saltus-framework' ), value: attributes.order, + options: [ { label: 'Descending', value: 'DESC' }, { label: 'Ascending', value: 'ASC' } ], + onChange: function ( value ) { props.setAttributes( { order: value } ); } + } ), + el( TextControl, { + key: 'taxonomy', label: wp.i18n.__( 'Taxonomy slug', 'saltus-framework' ), value: attributes.taxonomy, + onChange: function ( value ) { props.setAttributes( { taxonomy: value } ); } + } ), + el( TextControl, { + key: 'terms', label: wp.i18n.__( 'Term slugs', 'saltus-framework' ), + value: ( attributes.terms || [] ).join( ', ' ), + onChange: function ( value ) { props.setAttributes( { terms: value.split( ',' ).map( function ( term ) { return term.trim(); } ).filter( Boolean ) } ); } + } ), + el( ToggleControl, { + key: 'showExcerpt', label: wp.i18n.__( 'Show excerpt', 'saltus-framework' ), checked: attributes.showExcerpt, + onChange: function ( value ) { props.setAttributes( { showExcerpt: value } ); } + } ), + el( ToggleControl, { + key: 'showDate', label: wp.i18n.__( 'Show date', 'saltus-framework' ), checked: attributes.showDate, + onChange: function ( value ) { props.setAttributes( { showDate: value } ); } + } ) + ); + } else { + controls.push( + el( TextControl, { + key: 'postId', label: wp.i18n.__( 'Post ID', 'saltus-framework' ), type: 'number', value: attributes.postId || '', + onChange: function ( value ) { props.setAttributes( { postId: parseInt( value, 10 ) || 0 } ); } + } ), + el( ToggleControl, { + key: 'showTitle', label: wp.i18n.__( 'Show title', 'saltus-framework' ), checked: attributes.showTitle, + onChange: function ( value ) { props.setAttributes( { showTitle: value } ); } + } ), + el( ToggleControl, { + key: 'showContent', label: wp.i18n.__( 'Show content', 'saltus-framework' ), checked: attributes.showContent, + onChange: function ( value ) { props.setAttributes( { showContent: value } ); } + } ) + ); + } + + return el( Fragment, {}, + el( InspectorControls, {}, + el( PanelBody, { title: wp.i18n.__( 'Display', 'saltus-framework' ), initialOpen: true }, controls ), + el( PanelBody, { title: wp.i18n.__( 'Meta fields', 'saltus-framework' ), initialOpen: false }, metaControls( definition, attributes, props.setAttributes ) ) + ), + el( ServerSideRender, { block: definition.name, attributes: attributes } ) + ); + }; + } + + definitions.forEach( function ( definition ) { + wp.blocks.registerBlockType( definition.name, { + apiVersion: 3, + title: definition.title, + description: definition.description, + category: 'widgets', + icon: definition.view === 'list' ? 'list-view' : 'media-document', + attributes: definition.attributes, + edit: editComponent( definition ), + save: function () { return null; } + } ); + } ); +}( window.wp, ( window.saltusBlockDefinitions || {} ).items || [] ) ); diff --git a/assets/Feature/Blocks/style.css b/assets/Feature/Blocks/style.css new file mode 100644 index 00000000..d84a1196 --- /dev/null +++ b/assets/Feature/Blocks/style.css @@ -0,0 +1,43 @@ +.saltus-block { + box-sizing: border-box; + color: inherit; +} + +.saltus-block__list { + display: grid; + gap: 1rem; + list-style: none; + margin: 0; + padding: 0; +} + +.saltus-block__item { + border-block-end: 1px solid currentColor; + padding-block-end: 1rem; +} + +.saltus-block__title { + font: inherit; + font-weight: 600; + margin-block: 0 0.5rem; +} + +.saltus-block__date { + display: block; + font-size: 0.875em; + opacity: 0.72; +} + +.saltus-block__meta { + display: grid; + grid-template-columns: minmax(8rem, auto) 1fr; + margin-block: 1rem 0; +} + +.saltus-block__meta dt { + font-weight: 600; +} + +.saltus-block__meta dd { + margin: 0; +} diff --git a/bin/generate-mcp-docs.php b/bin/generate-mcp-docs.php index d83591cf..6ff6a085 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; @@ -74,7 +80,7 @@ static function ( ToolInterface $a, ToolInterface $b ): int { ); $reference = build_reference_document( $tools ); -write_file_if_changed( $root . '/docs/MCP-ABILITIES.md', $reference ); +write_file_if_changed( $root . '/docs/mcp/abilities.md', $reference ); replace_generated_section( $root . '/docs/MCP.md', '', @@ -132,21 +138,17 @@ function build_reference_document( array $tools ): string { '', 'Saltus Framework exposes ' . count( $tools ) . ' WordPress-native MCP/Abilities tools.', '', - build_table( $tools ), - '', ]; foreach ( $tools as $tool ) { $metadata = tool_metadata( $tool ); $lines[] = '## `' . $tool->get_name() . '`'; $lines[] = ''; - $lines[] = $tool->get_description(); + $lines[] = '**Ability:** ' . $metadata['ability'] . ''; $lines[] = ''; - $lines[] = '- Ability: `' . $metadata['ability'] . '`'; - $lines[] = '- REST request: `' . $metadata['rest_request'] . '`'; - $lines[] = '- REST capability: `' . $metadata['capability'] . '`'; - $lines[] = '- Cacheable: `' . $metadata['cacheable'] . '`'; - $lines[] = '- Cache TTL: `' . $metadata['cache_ttl'] . '`'; + $lines[] = '**REST request:** ' . $metadata['rest_request'] . ''; + $lines[] = ''; + $lines[] = $tool->get_description(); $lines[] = ''; $lines[] = '### Parameters'; $lines[] = ''; @@ -166,30 +168,13 @@ function build_embedded_section( array $tools ): string { [ '', '', - build_table( $tools ), + 'Saltus Framework exposes ' . count( $tools ) . ' WordPress-native MCP/Abilities tools.', '', - 'For full generated parameter details, see [MCP-ABILITIES.md](MCP-ABILITIES.md).', + 'For full details including parameters, see [Abilities Reference](/mcp/abilities).', ] ); } -/** - * @param list $tools - */ -function build_table( array $tools ): string { - $lines = [ - '| Tool | Ability | REST request | Description |', - '|------|---------|--------------|-------------|', - ]; - - foreach ( $tools as $tool ) { - $metadata = tool_metadata( $tool ); - $lines[] = '| `' . esc_md( $tool->get_name() ) . '` | `' . esc_md( $metadata['ability'] ) . '` | `' . esc_md( $metadata['rest_request'] ) . '` | ' . esc_md( $tool->get_description() ) . ' |'; - } - - return implode( "\n", $lines ); -} - /** * @return array{ability: string, rest_request: string, capability: string, cacheable: string, cache_ttl: string} */ diff --git a/bin/generate-wpcli-docs.php b/bin/generate-wpcli-docs.php new file mode 100644 index 00000000..a52d8524 --- /dev/null +++ b/bin/generate-wpcli-docs.php @@ -0,0 +1,62 @@ +#!/usr/bin/env php +', + '', + 'Saltus exposes every WordPress-native MCP/Ability through an equivalent `wp saltus` command. Commands execute WordPress APIs and shared framework services directly; they do not make HTTP requests.', + '', + '## Output and JSON input', + '', + 'List and detail commands support `--format=table|json|yaml`; table is the default. Commands accepting JSON support either an inline value or `@path/to/file.json`.', + '', + '## Commands', + '', + '| Ability | Command | Description |', + '|---|---|---|', +]; + +foreach ( CommandCatalog::all() as $command ) { + $markdown_command = str_replace( '|', '\\|', $command['command'] ); + $lines[] = '| `' . $command['ability'] . '` | `' . $markdown_command . '` | ' . $command['description'] . ' |'; +} + +$lines = array_merge( + $lines, + [ + '', + '## Examples', + '', + '```bash', + 'wp saltus model list --format=json', + 'wp saltus post create book "The Left Hand of Darkness" --status=draft --meta=\'{"isbn":"9780441478125"}\'', + 'wp saltus settings update book @settings.json', + 'wp saltus meta update book 42 @meta.json', + 'wp saltus reorder @order.json', + 'wp saltus post export 42 --file=book-42.xml', + '```', + '', + 'WP-CLI shell access is the authorization boundary. REST and MCP visibility gates do not hide CLI commands.', + '', + ] +); + +$contents = implode( "\n", $lines ); +$target = $root . '/docs/guides/wp-cli.md'; +$current = is_file( $target ) ? file_get_contents( $target ) : false; + +if ( $current !== $contents ) { + file_put_contents( $target, $contents ); +} + +echo 'Generated WP-CLI docs for ' . count( CommandCatalog::all() ) . " abilities.\n"; diff --git a/build/stubs.php b/build/stubs.php index e69de29b..4dfe61b1 100644 --- a/build/stubs.php +++ b/build/stubs.php @@ -0,0 +1,14 @@ +> $items @param list $fields */ + function format_items( string $format, array $items, array $fields ): void {} +} diff --git a/composer.json b/composer.json index 305bf52d..f81dd0b7 100644 --- a/composer.json +++ b/composer.json @@ -52,7 +52,8 @@ "squizlabs/php_codesniffer": "^3.9", "szepeviktor/phpstan-wordpress": "^2.1", "wp-coding-standards/wpcs": "^3.3", - "yoast/phpunit-polyfills": "^4.0" + "yoast/phpunit-polyfills": "^4.0", + "phpdocumentor/phpdocumentor": "^3.10" }, "scripts": { "test": "./vendor/bin/phpunit -c phpunit.xml", @@ -66,13 +67,20 @@ "test:phpstan": "./vendor/bin/phpstan analyse --memory-limit=2G", "test:phpcs": "./vendor/bin/phpcs --standard=phpcs.xml", "fix:phpcbf": "./vendor/bin/phpcbf --standard=phpcs.xml", - "docs:mcp": "php bin/generate-mcp-docs.php" + "docs:mcp": "php bin/generate-mcp-docs.php", + "docs:api": "php phpDocumentor.phar -c phpdoc.dist.xml", + "docs:wpcli": "php bin/generate-wpcli-docs.php", + "docs:all": [ + "@docs:mcp", + "@docs:wpcli", + "@docs:api" + ] }, "minimum-stability": "dev", "prefer-stable": true, "config": { "platform": { - "php": "7.4" + "php": "8.4" }, "allow-plugins": { "dealerdirect/phpcodesniffer-composer-installer": true, diff --git a/composer.lock b/composer.lock index b43b0607..8877c785 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "0a04661c7210eff30b21fc479472a448", + "content-hash": "bce85a68ed489c615e1989dce75bc1f7", "packages": [ { "name": "hassankhan/config", @@ -166,6 +166,81 @@ ], "time": "2026-05-06T08:26:05+00:00" }, + { + "name": "dflydev/dot-access-data", + "version": "v3.0.3", + "source": { + "type": "git", + "url": "https://github.com/dflydev/dflydev-dot-access-data.git", + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dflydev/dflydev-dot-access-data/zipball/a23a2bf4f31d3518f3ecb38660c95715dfead60f", + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^0.12.42", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.3", + "scrutinizer/ocular": "1.6.0", + "squizlabs/php_codesniffer": "^3.5", + "vimeo/psalm": "^4.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Dflydev\\DotAccessData\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Dragonfly Development Inc.", + "email": "info@dflydev.com", + "homepage": "http://dflydev.com" + }, + { + "name": "Beau Simensen", + "email": "beau@dflydev.com", + "homepage": "http://beausimensen.com" + }, + { + "name": "Carlos Frutos", + "email": "carlos@kiwing.it", + "homepage": "https://github.com/cfrutos" + }, + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com" + } + ], + "description": "Given a deep data structure, access data by dot notation.", + "homepage": "https://github.com/dflydev/dflydev-dot-access-data", + "keywords": [ + "access", + "data", + "dot", + "notation" + ], + "support": { + "issues": "https://github.com/dflydev/dflydev-dot-access-data/issues", + "source": "https://github.com/dflydev/dflydev-dot-access-data/tree/v3.0.3" + }, + "time": "2024-07-08T12:26:09+00:00" + }, { "name": "digitalrevolution/php-codesniffer-baseline", "version": "v1.1.2", @@ -217,6 +292,54 @@ }, "time": "2022-05-31T08:26:56+00:00" }, + { + "name": "doctrine/deprecations", + "version": "1.1.6", + "source": { + "type": "git", + "url": "https://github.com/doctrine/deprecations.git", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "phpunit/phpunit": "<=7.5 || >=14" + }, + "require-dev": { + "doctrine/coding-standard": "^9 || ^12 || ^14", + "phpstan/phpstan": "1.4.10 || 2.1.30", + "phpstan/phpstan-phpunit": "^1.0 || ^2", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", + "psr/log": "^1 || ^2 || ^3" + }, + "suggest": { + "psr/log": "Allows logging deprecations via PSR-3 logger implementation" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Deprecations\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.", + "homepage": "https://www.doctrine-project.org/", + "support": { + "issues": "https://github.com/doctrine/deprecations/issues", + "source": "https://github.com/doctrine/deprecations/tree/1.1.6" + }, + "time": "2026-02-07T07:09:04+00:00" + }, { "name": "doctrine/instantiator", "version": "1.5.0", @@ -288,751 +411,5346 @@ "time": "2022-12-30T00:15:36+00:00" }, { - "name": "myclabs/deep-copy", - "version": "1.13.4", + "name": "doctrine/lexer", + "version": "3.0.1", "source": { "type": "git", - "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + "url": "https://github.com/doctrine/lexer.git", + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", - "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "url": "https://api.github.com/repos/doctrine/lexer/zipball/31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", "shasum": "" }, "require": { - "php": "^7.1 || ^8.0" - }, - "conflict": { - "doctrine/collections": "<1.6.8", - "doctrine/common": "<2.13.3 || >=3 <3.2.2" + "php": "^8.1" }, "require-dev": { - "doctrine/collections": "^1.6.8", - "doctrine/common": "^2.13.3 || ^3.2.2", - "phpspec/prophecy": "^1.10", - "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + "doctrine/coding-standard": "^12", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.5", + "psalm/plugin-phpunit": "^0.18.3", + "vimeo/psalm": "^5.21" }, "type": "library", "autoload": { - "files": [ - "src/DeepCopy/deep_copy.php" - ], "psr-4": { - "DeepCopy\\": "src/DeepCopy/" + "Doctrine\\Common\\Lexer\\": "src" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], - "description": "Create deep copies (clones) of your objects", + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Lexer parser library that can be used in Top-Down, Recursive Descent Parsers.", + "homepage": "https://www.doctrine-project.org/projects/lexer.html", "keywords": [ - "clone", - "copy", - "duplicate", - "object", - "object graph" + "annotations", + "docblock", + "lexer", + "parser", + "php" ], "support": { - "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + "issues": "https://github.com/doctrine/lexer/issues", + "source": "https://github.com/doctrine/lexer/tree/3.0.1" }, "funding": [ { - "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Flexer", "type": "tidelift" } ], - "time": "2025-08-01T08:46:24+00:00" + "time": "2024-02-05T11:56:58+00:00" }, { - "name": "nikic/php-parser", - "version": "v5.8.0", + "name": "jawira/plantuml", + "version": "v1.2026.6", "source": { "type": "git", - "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" + "url": "https://github.com/jawira/plantuml.git", + "reference": "5a8bb9b94703905229d3b6a859c8b3fefea5a8be" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", - "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "url": "https://api.github.com/repos/jawira/plantuml/zipball/5a8bb9b94703905229d3b6a859c8b3fefea5a8be", + "reference": "5a8bb9b94703905229d3b6a859c8b3fefea5a8be", "shasum": "" }, - "require": { - "ext-json": "*", - "ext-tokenizer": "*", - "php": ">=7.4" - }, - "require-dev": { - "ircmaxell/php-yacc": "^0.0.7", - "phpunit/phpunit": "^9.0" - }, "bin": [ - "bin/php-parse" + "bin/plantuml" ], "type": "library", - "extra": { - "branch-alias": { - "dev-master": "5.x-dev" - } - }, - "autoload": { - "psr-4": { - "PhpParser\\": "lib/PhpParser" - } - }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "GPL-3.0-or-later" ], "authors": [ { - "name": "Nikita Popov" + "name": "Jawira Portugal" } ], - "description": "A PHP parser written in PHP", + "description": "Provides PlantUML executable and plantuml.jar", "keywords": [ - "parser", - "php" + "diagram", + "jar", + "plantuml", + "plantuml.jar", + "uml" ], "support": { - "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" + "issues": "https://github.com/jawira/plantuml/issues", + "source": "https://github.com/jawira/plantuml/tree/v1.2026.6" }, - "time": "2026-07-04T14:30:18+00:00" + "time": "2026-06-12T13:00:45+00:00" }, { - "name": "phar-io/manifest", - "version": "2.0.4", + "name": "jawira/plantuml-encoding", + "version": "v1.1.1", "source": { "type": "git", - "url": "https://github.com/phar-io/manifest.git", - "reference": "54750ef60c58e43759730615a392c31c80e23176" + "url": "https://github.com/jawira/plantuml-encoding.git", + "reference": "fe8bce2d7ff5bb5cccf374349999cef7d6246a32" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", - "reference": "54750ef60c58e43759730615a392c31c80e23176", + "url": "https://api.github.com/repos/jawira/plantuml-encoding/zipball/fe8bce2d7ff5bb5cccf374349999cef7d6246a32", + "reference": "fe8bce2d7ff5bb5cccf374349999cef7d6246a32", "shasum": "" }, "require": { - "ext-dom": "*", - "ext-libxml": "*", - "ext-phar": "*", - "ext-xmlwriter": "*", - "phar-io/version": "^3.0.1", - "php": "^7.2 || ^8.0" + "ext-zlib": "*", + "php": ">=7.4" }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.0.x-dev" - } + "require-dev": { + "phpstan/phpstan": "^2" }, + "type": "library", "autoload": { - "classmap": [ - "src/" + "files": [ + "src/plantuml_functions.php" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Arne Blankerts", - "email": "arne@blankerts.de", - "role": "Developer" - }, - { - "name": "Sebastian Heuer", - "email": "sebastian@phpeople.de", - "role": "Developer" - }, - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "Developer" + "name": "Jawira Portugal", + "homepage": "https://jawira.com" } ], - "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "description": "PlantUML encoding functions", + "keywords": [ + "encodep", + "encoding", + "functions", + "plantuml", + "uml" + ], "support": { - "issues": "https://github.com/phar-io/manifest/issues", - "source": "https://github.com/phar-io/manifest/tree/2.0.4" + "issues": "https://github.com/jawira/plantuml-encoding/issues", + "source": "https://github.com/jawira/plantuml-encoding/tree/v1.1.1" }, - "funding": [ - { - "url": "https://github.com/theseer", - "type": "github" - } - ], - "time": "2024-03-03T12:33:53+00:00" + "time": "2024-12-22T17:49:30+00:00" }, { - "name": "phar-io/version", - "version": "3.2.1", + "name": "jean85/pretty-package-versions", + "version": "2.1.1", "source": { "type": "git", - "url": "https://github.com/phar-io/version.git", - "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + "url": "https://github.com/Jean85/pretty-package-versions.git", + "reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", - "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "url": "https://api.github.com/repos/Jean85/pretty-package-versions/zipball/4d7aa5dab42e2a76d99559706022885de0e18e1a", + "reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a", "shasum": "" }, "require": { - "php": "^7.2 || ^8.0" + "composer-runtime-api": "^2.1.0", + "php": "^7.4|^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.2", + "jean85/composer-provided-replaced-stub-package": "^1.0", + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^7.5|^8.5|^9.6", + "rector/rector": "^2.0", + "vimeo/psalm": "^4.3 || ^5.0" }, "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, "autoload": { - "classmap": [ - "src/" - ] + "psr-4": { + "Jean85\\": "src/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Arne Blankerts", - "email": "arne@blankerts.de", - "role": "Developer" - }, - { - "name": "Sebastian Heuer", - "email": "sebastian@phpeople.de", - "role": "Developer" - }, - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "Developer" + "name": "Alessandro Lai", + "email": "alessandro.lai85@gmail.com" } ], - "description": "Library for handling version information and constraints", + "description": "A library to get pretty versions strings of installed dependencies", + "keywords": [ + "composer", + "package", + "release", + "versions" + ], "support": { - "issues": "https://github.com/phar-io/version/issues", - "source": "https://github.com/phar-io/version/tree/3.2.1" + "issues": "https://github.com/Jean85/pretty-package-versions/issues", + "source": "https://github.com/Jean85/pretty-package-versions/tree/2.1.1" }, - "time": "2022-02-21T01:04:05+00:00" + "time": "2025-03-19T14:43:43+00:00" }, { - "name": "php-stubs/wordpress-stubs", - "version": "v7.0.0", + "name": "league/commonmark", + "version": "2.8.3", "source": { "type": "git", - "url": "https://github.com/php-stubs/wordpress-stubs.git", - "reference": "d74b963ed4f47303859bf73c741c80f554c83dc6" + "url": "https://github.com/thephpleague/commonmark.git", + "reference": "1902f60f984235023acbe03db6ad614a37b3c3e7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-stubs/wordpress-stubs/zipball/d74b963ed4f47303859bf73c741c80f554c83dc6", - "reference": "d74b963ed4f47303859bf73c741c80f554c83dc6", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/1902f60f984235023acbe03db6ad614a37b3c3e7", + "reference": "1902f60f984235023acbe03db6ad614a37b3c3e7", "shasum": "" }, - "conflict": { - "phpdocumentor/reflection-docblock": "5.6.1" + "require": { + "ext-mbstring": "*", + "league/config": "^1.1.1", + "php": "^7.4 || ^8.0", + "psr/event-dispatcher": "^1.0", + "symfony/deprecation-contracts": "^2.1 || ^3.0", + "symfony/polyfill-php80": "^1.16" }, "require-dev": { - "dealerdirect/phpcodesniffer-composer-installer": "^1.0", - "nikic/php-parser": "^5.5", - "php": "^7.4 || ^8.0", + "cebe/markdown": "^1.0", + "commonmark/cmark": "0.31.1", + "commonmark/commonmark.js": "0.31.1", + "composer/package-versions-deprecated": "^1.8", + "embed/embed": "^4.4", + "erusev/parsedown": "^1.0", + "ext-json": "*", + "github/gfm": "0.29.0", + "michelf/php-markdown": "^1.4 || ^2.0", + "nyholm/psr7": "^1.5", + "phpstan/phpstan": "^2.0.0", + "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0 || ^12.0.0 || ^13.0.0", + "scrutinizer/ocular": "^1.8.1", + "symfony/finder": "^5.3 | ^6.0 | ^7.0 || ^8.0", + "symfony/process": "^5.4 | ^6.0 | ^7.0 || ^8.0", + "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0 || ^8.0", + "unleashedtech/php-coding-standard": "^3.1.1", + "vimeo/psalm": "^4.24.0 || ^5.0.0 || ^6.0.0" + }, + "suggest": { + "symfony/yaml": "v2.3+ required if using the Front Matter extension" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.9-dev" + } + }, + "autoload": { + "psr-4": { + "League\\CommonMark\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + } + ], + "description": "Highly-extensible PHP Markdown parser which fully supports the CommonMark spec and GitHub-Flavored Markdown (GFM)", + "homepage": "https://commonmark.thephpleague.com", + "keywords": [ + "commonmark", + "flavored", + "gfm", + "github", + "github-flavored", + "markdown", + "md", + "parser" + ], + "support": { + "docs": "https://commonmark.thephpleague.com/", + "forum": "https://github.com/thephpleague/commonmark/discussions", + "issues": "https://github.com/thephpleague/commonmark/issues", + "rss": "https://github.com/thephpleague/commonmark/releases.atom", + "source": "https://github.com/thephpleague/commonmark" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/commonmark", + "type": "tidelift" + } + ], + "time": "2026-07-12T15:29:16+00:00" + }, + { + "name": "league/config", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/config.git", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/config/zipball/754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", + "shasum": "" + }, + "require": { + "dflydev/dot-access-data": "^3.0.1", + "nette/schema": "^1.2", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.8.2", + "phpunit/phpunit": "^9.5.5", + "scrutinizer/ocular": "^1.8.1", + "unleashedtech/php-coding-standard": "^3.1", + "vimeo/psalm": "^4.7.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.2-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Config\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + } + ], + "description": "Define configuration arrays with strict schemas and access values with dot notation", + "homepage": "https://config.thephpleague.com", + "keywords": [ + "array", + "config", + "configuration", + "dot", + "dot-access", + "nested", + "schema" + ], + "support": { + "docs": "https://config.thephpleague.com/", + "issues": "https://github.com/thephpleague/config/issues", + "rss": "https://github.com/thephpleague/config/releases.atom", + "source": "https://github.com/thephpleague/config" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + } + ], + "time": "2022-12-11T20:36:23+00:00" + }, + { + "name": "league/csv", + "version": "9.28.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/csv.git", + "reference": "6582ace29ae09ba5b07049d40ea13eb19c8b5073" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/csv/zipball/6582ace29ae09ba5b07049d40ea13eb19c8b5073", + "reference": "6582ace29ae09ba5b07049d40ea13eb19c8b5073", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "php": "^8.1.2" + }, + "require-dev": { + "ext-dom": "*", + "ext-xdebug": "*", + "friendsofphp/php-cs-fixer": "^3.92.3", + "phpbench/phpbench": "^1.4.3", + "phpstan/phpstan": "^1.12.32", + "phpstan/phpstan-deprecation-rules": "^1.2.1", + "phpstan/phpstan-phpunit": "^1.4.2", + "phpstan/phpstan-strict-rules": "^1.6.2", + "phpunit/phpunit": "^10.5.16 || ^11.5.22 || ^12.5.4", + "symfony/var-dumper": "^6.4.8 || ^7.4.0 || ^8.0" + }, + "suggest": { + "ext-dom": "Required to use the XMLConverter and the HTMLConverter classes", + "ext-iconv": "Needed to ease transcoding CSV using iconv stream filters", + "ext-mbstring": "Needed to ease transcoding CSV using mb stream filters", + "ext-mysqli": "Requiered to use the package with the MySQLi extension", + "ext-pdo": "Required to use the package with the PDO extension", + "ext-pgsql": "Requiered to use the package with the PgSQL extension", + "ext-sqlite3": "Required to use the package with the SQLite3 extension" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "9.x-dev" + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "League\\Csv\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://github.com/nyamsprod/", + "role": "Developer" + } + ], + "description": "CSV data manipulation made easy in PHP", + "homepage": "https://csv.thephpleague.com", + "keywords": [ + "convert", + "csv", + "export", + "filter", + "import", + "read", + "transform", + "write" + ], + "support": { + "docs": "https://csv.thephpleague.com", + "issues": "https://github.com/thephpleague/csv/issues", + "rss": "https://github.com/thephpleague/csv/releases.atom", + "source": "https://github.com/thephpleague/csv" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2025-12-27T15:18:42+00:00" + }, + { + "name": "league/flysystem", + "version": "1.1.10", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem.git", + "reference": "3239285c825c152bcc315fe0e87d6b55f5972ed1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/3239285c825c152bcc315fe0e87d6b55f5972ed1", + "reference": "3239285c825c152bcc315fe0e87d6b55f5972ed1", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "league/mime-type-detection": "^1.3", + "php": "^7.2.5 || ^8.0" + }, + "conflict": { + "league/flysystem-sftp": "<1.0.6" + }, + "require-dev": { + "phpspec/prophecy": "^1.11.1", + "phpunit/phpunit": "^8.5.8" + }, + "suggest": { + "ext-ftp": "Allows you to use FTP server storage", + "ext-openssl": "Allows you to use FTPS server storage", + "league/flysystem-aws-s3-v2": "Allows you to use S3 storage with AWS SDK v2", + "league/flysystem-aws-s3-v3": "Allows you to use S3 storage with AWS SDK v3", + "league/flysystem-azure": "Allows you to use Windows Azure Blob storage", + "league/flysystem-cached-adapter": "Flysystem adapter decorator for metadata caching", + "league/flysystem-eventable-filesystem": "Allows you to use EventableFilesystem", + "league/flysystem-rackspace": "Allows you to use Rackspace Cloud Files", + "league/flysystem-sftp": "Allows you to use SFTP server storage via phpseclib", + "league/flysystem-webdav": "Allows you to use WebDAV storage", + "league/flysystem-ziparchive": "Allows you to use ZipArchive adapter", + "spatie/flysystem-dropbox": "Allows you to use Dropbox storage", + "srmklive/flysystem-dropbox-v2": "Allows you to use Dropbox storage for PHP 5 applications" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Flysystem\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frenky.net" + } + ], + "description": "Filesystem abstraction: Many filesystems, one API.", + "keywords": [ + "Cloud Files", + "WebDAV", + "abstraction", + "aws", + "cloud", + "copy.com", + "dropbox", + "file systems", + "files", + "filesystem", + "filesystems", + "ftp", + "rackspace", + "remote", + "s3", + "sftp", + "storage" + ], + "support": { + "issues": "https://github.com/thephpleague/flysystem/issues", + "source": "https://github.com/thephpleague/flysystem/tree/1.1.10" + }, + "funding": [ + { + "url": "https://offset.earth/frankdejonge", + "type": "other" + } + ], + "time": "2022-10-04T09:16:37+00:00" + }, + { + "name": "league/mime-type-detection", + "version": "1.17.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/mime-type-detection.git", + "reference": "f5f47eff7c48ed1003069a2ca67f316fb4021c76" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/f5f47eff7c48ed1003069a2ca67f316fb4021c76", + "reference": "f5f47eff7c48ed1003069a2ca67f316fb4021c76", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.2", + "phpstan/phpstan": "^0.12.68", + "phpunit/phpunit": "^8.5.8 || ^9.3 || ^10.0 || ^11.0 || ^12.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\MimeTypeDetection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "Mime-type detection for Flysystem", + "support": { + "issues": "https://github.com/thephpleague/mime-type-detection/issues", + "source": "https://github.com/thephpleague/mime-type-detection/tree/1.17.0" + }, + "funding": [ + { + "url": "https://github.com/frankdejonge", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/flysystem", + "type": "tidelift" + } + ], + "time": "2026-07-09T11:49:27+00:00" + }, + { + "name": "league/tactician", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/tactician.git", + "reference": "93b2eafa4321f84164f3d3f607a32a953f3fff0b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/tactician/zipball/93b2eafa4321f84164f3d3f607a32a953f3fff0b", + "reference": "93b2eafa4321f84164f3d3f607a32a953f3fff0b", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "require-dev": { + "mockery/mockery": "^1.3", + "phpunit/phpunit": "^7.5.20 || ^9.3.8", + "squizlabs/php_codesniffer": "^3.5.8" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Tactician\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ross Tuck", + "homepage": "http://tactician.thephpleague.com" + } + ], + "description": "A small, flexible command bus. Handy for building service layers.", + "keywords": [ + "command", + "command bus", + "service layer" + ], + "support": { + "issues": "https://github.com/thephpleague/tactician/issues", + "source": "https://github.com/thephpleague/tactician/tree/v1.2.0" + }, + "time": "2025-12-21T18:05:37+00:00" + }, + { + "name": "league/uri", + "version": "7.8.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri.git", + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/08cf38e3924d4f56238125547b5720496fac8fd4", + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4", + "shasum": "" + }, + "require": { + "league/uri-interfaces": "^7.8.1", + "php": "^8.1", + "psr/http-factory": "^1" + }, + "conflict": { + "league/uri-schemes": "^1.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-dom": "to convert the URI into an HTML anchor tag", + "ext-fileinfo": "to create Data URI from file contennts", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "ext-uri": "to use the PHP native URI class", + "jeremykendall/php-domain-parser": "to further parse the URI host and resolve its Public Suffix and Top Level Domain", + "league/uri-components": "to provide additional tools to manipulate URI objects components", + "league/uri-polyfill": "to backport the PHP URI extension for older versions of PHP", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "URI manipulation library", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "URN", + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "middleware", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc2141", + "rfc3986", + "rfc3987", + "rfc6570", + "rfc8141", + "uri", + "uri-template", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri/tree/7.8.1" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2026-03-15T20:22:25+00:00" + }, + { + "name": "league/uri-interfaces", + "version": "7.8.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri-interfaces.git", + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/85d5c77c5d6d3af6c54db4a78246364908f3c928", + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "php": "^8.1", + "psr/http-message": "^1.1 || ^2.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "Common tools for parsing and resolving RFC3987/RFC3986 URI", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc3986", + "rfc3987", + "rfc6570", + "uri", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.1" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2026-03-08T20:05:35+00:00" + }, + { + "name": "masterminds/html5", + "version": "2.10.1", + "source": { + "type": "git", + "url": "https://github.com/Masterminds/html5-php.git", + "reference": "fd5018f6815fff903946d0564977b44ce8010e29" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fd5018f6815fff903946d0564977b44ce8010e29", + "reference": "fd5018f6815fff903946d0564977b44ce8010e29", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9 || ^10" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Masterminds\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Matt Butcher", + "email": "technosophos@gmail.com" + }, + { + "name": "Matt Farina", + "email": "matt@mattfarina.com" + }, + { + "name": "Asmir Mustafic", + "email": "goetas@gmail.com" + } + ], + "description": "An HTML5 parser and serializer.", + "homepage": "http://masterminds.github.io/html5-php", + "keywords": [ + "HTML5", + "dom", + "html", + "parser", + "querypath", + "serializer", + "xml" + ], + "support": { + "issues": "https://github.com/Masterminds/html5-php/issues", + "source": "https://github.com/Masterminds/html5-php/tree/2.10.1" + }, + "time": "2026-06-23T18:43:15+00:00" + }, + { + "name": "monolog/monolog", + "version": "3.10.0", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/b321dd6749f0bf7189444158a3ce785cc16d69b0", + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/log": "^2.0 || ^3.0" + }, + "provide": { + "psr/log-implementation": "3.0.0" + }, + "require-dev": { + "aws/aws-sdk-php": "^3.0", + "doctrine/couchdb": "~1.0@dev", + "elasticsearch/elasticsearch": "^7 || ^8", + "ext-json": "*", + "graylog2/gelf-php": "^1.4.2 || ^2.0", + "guzzlehttp/guzzle": "^7.4.5", + "guzzlehttp/psr7": "^2.2", + "mongodb/mongodb": "^1.8 || ^2.0", + "php-amqplib/php-amqplib": "~2.4 || ^3", + "php-console/php-console": "^3.1.8", + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^10.5.17 || ^11.0.7", + "predis/predis": "^1.1 || ^2", + "rollbar/rollbar": "^4.0", + "ruflin/elastica": "^7 || ^8", + "symfony/mailer": "^5.4 || ^6", + "symfony/mime": "^5.4 || ^6" + }, + "suggest": { + "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB", + "doctrine/couchdb": "Allow sending log messages to a CouchDB server", + "elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client", + "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)", + "ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler", + "ext-mbstring": "Allow to work properly with unicode symbols", + "ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)", + "ext-openssl": "Required to send log messages using SSL", + "ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)", + "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server", + "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)", + "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib", + "rollbar/rollbar": "Allow sending log messages to Rollbar", + "ruflin/elastica": "Allow sending log messages to an Elastic Search server" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Monolog\\": "src/Monolog" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "https://seld.be" + } + ], + "description": "Sends your logs to files, sockets, inboxes, databases and various web services", + "homepage": "https://github.com/Seldaek/monolog", + "keywords": [ + "log", + "logging", + "psr-3" + ], + "support": { + "issues": "https://github.com/Seldaek/monolog/issues", + "source": "https://github.com/Seldaek/monolog/tree/3.10.0" + }, + "funding": [ + { + "url": "https://github.com/Seldaek", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/monolog/monolog", + "type": "tidelift" + } + ], + "time": "2026-01-02T08:56:05+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, + { + "name": "nette/schema", + "version": "v1.3.5", + "source": { + "type": "git", + "url": "https://github.com/nette/schema.git", + "reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/schema/zipball/f0ab1a3cda782dbc5da270d28545236aa80c4002", + "reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002", + "shasum": "" + }, + "require": { + "nette/utils": "^4.0", + "php": "8.1 - 8.5" + }, + "require-dev": { + "nette/phpstan-rules": "^1.0", + "nette/tester": "^2.6", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1.39@stable", + "tracy/tracy": "^2.8" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3-dev" + } + }, + "autoload": { + "psr-4": { + "Nette\\": "src" + }, + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "📐 Nette Schema: validating data structures against a given Schema.", + "homepage": "https://nette.org", + "keywords": [ + "config", + "nette" + ], + "support": { + "issues": "https://github.com/nette/schema/issues", + "source": "https://github.com/nette/schema/tree/v1.3.5" + }, + "time": "2026-02-23T03:47:12+00:00" + }, + { + "name": "nette/utils", + "version": "v4.1.5", + "source": { + "type": "git", + "url": "https://github.com/nette/utils.git", + "reference": "b043439dbdf954e6c28b5ea7e34b0100f83165e0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/utils/zipball/b043439dbdf954e6c28b5ea7e34b0100f83165e0", + "reference": "b043439dbdf954e6c28b5ea7e34b0100f83165e0", + "shasum": "" + }, + "require": { + "php": "8.2 - 8.5" + }, + "conflict": { + "nette/finder": "<3", + "nette/schema": "<1.2.2" + }, + "require-dev": { + "jetbrains/phpstorm-attributes": "^1.2", + "nette/phpstan-rules": "^1.0", + "nette/tester": "^2.5", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1@stable", + "tracy/tracy": "^2.9" + }, + "suggest": { + "ext-gd": "to use Image", + "ext-iconv": "to use Strings::chr(), ord() and reverse()", + "ext-intl": "to use Strings::webalize(), toAscii(), normalize() and compare()", + "ext-json": "to use Nette\\Utils\\Json", + "ext-mbstring": "to use Strings::lower() etc...", + "ext-tokenizer": "to use Nette\\Utils\\Reflection::getUseStatements()" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.1-dev" + } + }, + "autoload": { + "psr-4": { + "Nette\\": "src" + }, + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "🛠 Nette Utils: lightweight utilities for string & array manipulation, image handling, safe JSON encoding/decoding, validation, slug or strong password generating etc.", + "homepage": "https://nette.org", + "keywords": [ + "array", + "core", + "datetime", + "images", + "json", + "nette", + "paginator", + "password", + "slugify", + "string", + "unicode", + "utf-8", + "utility", + "validation" + ], + "support": { + "issues": "https://github.com/nette/utils/issues", + "source": "https://github.com/nette/utils/tree/v4.1.5" + }, + "time": "2026-07-17T23:02:45+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.8.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" + }, + "time": "2026-07-04T14:30:18+00:00" + }, + { + "name": "parsica-php/parsica", + "version": "0.8.3", + "source": { + "type": "git", + "url": "https://github.com/parsica-php/parsica.git", + "reference": "e5b0a763e26e89de39a0790e949b8ff7f0909d73" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/parsica-php/parsica/zipball/e5b0a763e26e89de39a0790e949b8ff7f0909d73", + "reference": "e5b0a763e26e89de39a0790e949b8ff7f0909d73", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "ext-json": "*", + "mathiasverraes/uptodocs": "dev-main", + "phpbench/phpbench": "^1.0.1", + "phpunit/phpunit": "^9.0", + "psr/event-dispatcher": "^1.0", + "vimeo/psalm": "^4.30" + }, + "type": "library", + "autoload": { + "files": [ + "src/characters.php", + "src/combinators.php", + "src/numeric.php", + "src/predicates.php", + "src/primitives.php", + "src/recursion.php", + "src/sideEffects.php", + "src/space.php", + "src/strings.php", + "src/Expression/expression.php", + "src/Internal/FP.php", + "src/Curry/functions.php" + ], + "psr-4": { + "Parsica\\Parsica\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mathias Verraes", + "email": "mathias@verraes.net", + "homepage": "https://verraes.net" + }, + { + "name": "Toon Daelman", + "email": "spinnewebber_toon@hotmail.com", + "homepage": "https://github.com/turanct" + } + ], + "description": "The easiest way to build robust parsers in PHP.", + "homepage": "https://parsica-php.github.io/", + "keywords": [ + "parser", + "parser combinator", + "parser-combinator", + "parsing" + ], + "support": { + "issues": "https://github.com/parsica-php/parsica/issues", + "source": "https://github.com/parsica-php/parsica/tree/0.8.3" + }, + "funding": [ + { + "url": "https://github.com/turanct", + "type": "github" + } + ], + "time": "2025-07-09T08:39:51+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "php-stubs/wordpress-stubs", + "version": "v7.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-stubs/wordpress-stubs.git", + "reference": "d74b963ed4f47303859bf73c741c80f554c83dc6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-stubs/wordpress-stubs/zipball/d74b963ed4f47303859bf73c741c80f554c83dc6", + "reference": "d74b963ed4f47303859bf73c741c80f554c83dc6", + "shasum": "" + }, + "conflict": { + "phpdocumentor/reflection-docblock": "5.6.1" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "nikic/php-parser": "^5.5", + "php": "^7.4 || ^8.0", "php-stubs/generator": "^0.8.6", "phpdocumentor/reflection-docblock": "^6.0", "phpstan/phpstan": "^2.1", "phpunit/phpunit": "^9.5", - "symfony/polyfill-php80": "*", - "szepeviktor/phpcs-psr-12-neutron-hybrid-ruleset": "^1.1.1", - "wp-coding-standards/wpcs": "3.1.0 as 2.3.0" + "symfony/polyfill-php80": "*", + "szepeviktor/phpcs-psr-12-neutron-hybrid-ruleset": "^1.1.1", + "wp-coding-standards/wpcs": "3.1.0 as 2.3.0" + }, + "suggest": { + "paragonie/sodium_compat": "Pure PHP implementation of libsodium", + "symfony/polyfill-php80": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "szepeviktor/phpstan-wordpress": "WordPress extensions for PHPStan" + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "WordPress function and class declaration stubs for static analysis.", + "homepage": "https://github.com/php-stubs/wordpress-stubs", + "keywords": [ + "PHPStan", + "static analysis", + "wordpress" + ], + "support": { + "issues": "https://github.com/php-stubs/wordpress-stubs/issues", + "source": "https://github.com/php-stubs/wordpress-stubs/tree/v7.0.0" + }, + "time": "2026-05-21T21:41:34+00:00" + }, + { + "name": "phpcompatibility/php-compatibility", + "version": "9.3.5", + "source": { + "type": "git", + "url": "https://github.com/PHPCompatibility/PHPCompatibility.git", + "reference": "9fb324479acf6f39452e0655d2429cc0d3914243" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibility/zipball/9fb324479acf6f39452e0655d2429cc0d3914243", + "reference": "9fb324479acf6f39452e0655d2429cc0d3914243", + "shasum": "" + }, + "require": { + "php": ">=5.3", + "squizlabs/php_codesniffer": "^2.3 || ^3.0.2" + }, + "conflict": { + "squizlabs/php_codesniffer": "2.6.2" + }, + "require-dev": { + "phpunit/phpunit": "~4.5 || ^5.0 || ^6.0 || ^7.0" + }, + "suggest": { + "dealerdirect/phpcodesniffer-composer-installer": "^0.5 || This Composer plugin will sort out the PHPCS 'installed_paths' automatically.", + "roave/security-advisories": "dev-master || Helps prevent installing dependencies with known security issues." + }, + "type": "phpcodesniffer-standard", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Wim Godden", + "homepage": "https://github.com/wimg", + "role": "lead" + }, + { + "name": "Juliette Reinders Folmer", + "homepage": "https://github.com/jrfnl", + "role": "lead" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCompatibility/PHPCompatibility/graphs/contributors" + } + ], + "description": "A set of sniffs for PHP_CodeSniffer that checks for PHP cross-version compatibility.", + "homepage": "http://techblog.wimgodden.be/tag/codesniffer/", + "keywords": [ + "compatibility", + "phpcs", + "standards" + ], + "support": { + "issues": "https://github.com/PHPCompatibility/PHPCompatibility/issues", + "source": "https://github.com/PHPCompatibility/PHPCompatibility" + }, + "time": "2019-12-27T09:44:58+00:00" + }, + { + "name": "phpcompatibility/phpcompatibility-paragonie", + "version": "1.3.4", + "source": { + "type": "git", + "url": "https://github.com/PHPCompatibility/PHPCompatibilityParagonie.git", + "reference": "244d7b04fc4bc2117c15f5abe23eb933b5f02bbf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibilityParagonie/zipball/244d7b04fc4bc2117c15f5abe23eb933b5f02bbf", + "reference": "244d7b04fc4bc2117c15f5abe23eb933b5f02bbf", + "shasum": "" + }, + "require": { + "phpcompatibility/php-compatibility": "^9.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "paragonie/random_compat": "dev-master", + "paragonie/sodium_compat": "dev-master" + }, + "suggest": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0 || This Composer plugin will sort out the PHP_CodeSniffer 'installed_paths' automatically.", + "roave/security-advisories": "dev-master || Helps prevent installing dependencies with known security issues." + }, + "type": "phpcodesniffer-standard", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Wim Godden", + "role": "lead" + }, + { + "name": "Juliette Reinders Folmer", + "role": "lead" + } + ], + "description": "A set of rulesets for PHP_CodeSniffer to check for PHP cross-version compatibility issues in projects, while accounting for polyfills provided by the Paragonie polyfill libraries.", + "homepage": "http://phpcompatibility.com/", + "keywords": [ + "compatibility", + "paragonie", + "phpcs", + "polyfill", + "standards", + "static analysis" + ], + "support": { + "issues": "https://github.com/PHPCompatibility/PHPCompatibilityParagonie/issues", + "security": "https://github.com/PHPCompatibility/PHPCompatibilityParagonie/security/policy", + "source": "https://github.com/PHPCompatibility/PHPCompatibilityParagonie" + }, + "funding": [ + { + "url": "https://github.com/PHPCompatibility", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcompatibility", + "type": "thanks_dev" + } + ], + "time": "2025-09-19T17:43:28+00:00" + }, + { + "name": "phpcompatibility/phpcompatibility-wp", + "version": "2.1.8", + "source": { + "type": "git", + "url": "https://github.com/PHPCompatibility/PHPCompatibilityWP.git", + "reference": "7c8d18b4d90dac9e86b0869a608fa09158e168fa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibilityWP/zipball/7c8d18b4d90dac9e86b0869a608fa09158e168fa", + "reference": "7c8d18b4d90dac9e86b0869a608fa09158e168fa", + "shasum": "" + }, + "require": { + "phpcompatibility/php-compatibility": "^9.0", + "phpcompatibility/phpcompatibility-paragonie": "^1.0", + "squizlabs/php_codesniffer": "^3.3" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0" + }, + "suggest": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0 || This Composer plugin will sort out the PHP_CodeSniffer 'installed_paths' automatically.", + "roave/security-advisories": "dev-master || Helps prevent installing dependencies with known security issues." + }, + "type": "phpcodesniffer-standard", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Wim Godden", + "role": "lead" + }, + { + "name": "Juliette Reinders Folmer", + "role": "lead" + } + ], + "description": "A ruleset for PHP_CodeSniffer to check for PHP cross-version compatibility issues in projects, while accounting for polyfills provided by WordPress.", + "homepage": "http://phpcompatibility.com/", + "keywords": [ + "compatibility", + "phpcs", + "standards", + "static analysis", + "wordpress" + ], + "support": { + "issues": "https://github.com/PHPCompatibility/PHPCompatibilityWP/issues", + "security": "https://github.com/PHPCompatibility/PHPCompatibilityWP/security/policy", + "source": "https://github.com/PHPCompatibility/PHPCompatibilityWP" + }, + "funding": [ + { + "url": "https://github.com/PHPCompatibility", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcompatibility", + "type": "thanks_dev" + } + ], + "time": "2025-10-18T00:05:59+00:00" + }, + { + "name": "phpcsstandards/phpcsextra", + "version": "1.5.0", + "source": { + "type": "git", + "url": "https://github.com/PHPCSStandards/PHPCSExtra.git", + "reference": "b598aa890815b8df16363271b659d73280129101" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCSStandards/PHPCSExtra/zipball/b598aa890815b8df16363271b659d73280129101", + "reference": "b598aa890815b8df16363271b659d73280129101", + "shasum": "" + }, + "require": { + "php": ">=5.4", + "phpcsstandards/phpcsutils": "^1.2.0", + "squizlabs/php_codesniffer": "^3.13.5 || ^4.0.1" + }, + "require-dev": { + "php-parallel-lint/php-console-highlighter": "^1.0", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpcsstandards/phpcsdevcs": "^1.2.0", + "phpcsstandards/phpcsdevtools": "^1.2.1", + "phpunit/phpunit": "^4.5 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4" + }, + "type": "phpcodesniffer-standard", + "extra": { + "branch-alias": { + "dev-stable": "1.x-dev", + "dev-develop": "1.x-dev" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Juliette Reinders Folmer", + "homepage": "https://github.com/jrfnl", + "role": "lead" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCSStandards/PHPCSExtra/graphs/contributors" + } + ], + "description": "A collection of sniffs and standards for use with PHP_CodeSniffer.", + "keywords": [ + "PHP_CodeSniffer", + "phpcbf", + "phpcodesniffer-standard", + "phpcs", + "standards", + "static analysis" + ], + "support": { + "issues": "https://github.com/PHPCSStandards/PHPCSExtra/issues", + "security": "https://github.com/PHPCSStandards/PHPCSExtra/security/policy", + "source": "https://github.com/PHPCSStandards/PHPCSExtra" + }, + "funding": [ + { + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" + } + ], + "time": "2025-11-12T23:06:57+00:00" + }, + { + "name": "phpcsstandards/phpcsutils", + "version": "1.2.2", + "source": { + "type": "git", + "url": "https://github.com/PHPCSStandards/PHPCSUtils.git", + "reference": "c216317e96c8b3f5932808f9b0f1f7a14e3bbf55" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCSStandards/PHPCSUtils/zipball/c216317e96c8b3f5932808f9b0f1f7a14e3bbf55", + "reference": "c216317e96c8b3f5932808f9b0f1f7a14e3bbf55", + "shasum": "" + }, + "require": { + "dealerdirect/phpcodesniffer-composer-installer": "^0.4.1 || ^0.5 || ^0.6.2 || ^0.7 || ^1.0", + "php": ">=5.4", + "squizlabs/php_codesniffer": "^3.13.5 || ^4.0.1" + }, + "require-dev": { + "ext-filter": "*", + "php-parallel-lint/php-console-highlighter": "^1.0", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpcsstandards/phpcsdevcs": "^1.2.0", + "yoast/phpunit-polyfills": "^1.1.0 || ^2.0.0 || ^3.0.0" + }, + "type": "phpcodesniffer-standard", + "extra": { + "branch-alias": { + "dev-stable": "1.x-dev", + "dev-develop": "1.x-dev" + } + }, + "autoload": { + "classmap": [ + "PHPCSUtils/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Juliette Reinders Folmer", + "homepage": "https://github.com/jrfnl", + "role": "lead" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCSStandards/PHPCSUtils/graphs/contributors" + } + ], + "description": "A suite of utility functions for use with PHP_CodeSniffer", + "homepage": "https://phpcsutils.com/", + "keywords": [ + "PHP_CodeSniffer", + "phpcbf", + "phpcodesniffer-standard", + "phpcs", + "phpcs3", + "phpcs4", + "standards", + "static analysis", + "tokens", + "utility" + ], + "support": { + "docs": "https://phpcsutils.com/", + "issues": "https://github.com/PHPCSStandards/PHPCSUtils/issues", + "security": "https://github.com/PHPCSStandards/PHPCSUtils/security/policy", + "source": "https://github.com/PHPCSStandards/PHPCSUtils" + }, + "funding": [ + { + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" + } + ], + "time": "2025-12-08T14:27:58+00:00" + }, + { + "name": "phpdocumentor/filesystem", + "version": "1.10.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/filesystem.git", + "reference": "1234be43d6604bd5ca0951e5d0e8e61e62b872b0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/filesystem/zipball/1234be43d6604bd5ca0951e5d0e8e61e62b872b0", + "reference": "1234be43d6604bd5ca0951e5d0e8e61e62b872b0", + "shasum": "" + }, + "require": { + "php": "^8.1", + "webmozart/assert": "^1.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\FileSystem\\": "src/" + }, + "classmap": [ + "Flysystem/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Filesystem abstraction for phpdocumentor projects.", + "homepage": "https://www.phpdoc.org", + "support": { + "issues": "https://github.com/phpDocumentor/filesystem/issues", + "source": "https://github.com/phpDocumentor/filesystem/tree/1.10.0" + }, + "time": "2026-04-13T20:20:50+00:00" + }, + { + "name": "phpdocumentor/flyfinder", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/FlyFinder.git", + "reference": "6e145e676d9fbade7527fd8d4c99ab36b687b958" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/FlyFinder/zipball/6e145e676d9fbade7527fd8d4c99ab36b687b958", + "reference": "6e145e676d9fbade7527fd8d4c99ab36b687b958", + "shasum": "" + }, + "require": { + "league/flysystem": "^1.0", + "php": "^7.2||^8.0" + }, + "require-dev": { + "league/flysystem-memory": "~1", + "mockery/mockery": "^1.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Flyfinder\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Flysystem plugin to add file finding capabilities to the Filesystem entity", + "homepage": "http://www.phpdoc.org", + "keywords": [ + "Flysystem", + "phpdoc" + ], + "support": { + "issues": "https://github.com/phpDocumentor/FlyFinder/issues", + "source": "https://github.com/phpDocumentor/FlyFinder/tree/1.1.0" + }, + "time": "2021-06-04T13:44:40+00:00" + }, + { + "name": "phpdocumentor/graphviz", + "version": "2.1.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/GraphViz.git", + "reference": "115999dc7f31f2392645aa825a94a6b165e1cedf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/GraphViz/zipball/115999dc7f31f2392645aa825a94a6b165e1cedf", + "reference": "115999dc7f31f2392645aa825a94a6b165e1cedf", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "ext-simplexml": "*", + "mockery/mockery": "^1.2", + "phpstan/phpstan": "^0.12", + "phpunit/phpunit": "^8.2 || ^9.2", + "psalm/phar": "^4.15" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\GraphViz\\": "src/phpDocumentor/GraphViz", + "phpDocumentor\\GraphViz\\PHPStan\\": "./src/phpDocumentor/PHPStan" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "mike.vanriel@naenius.com" + } + ], + "description": "Wrapper for Graphviz", + "support": { + "issues": "https://github.com/phpDocumentor/GraphViz/issues", + "source": "https://github.com/phpDocumentor/GraphViz/tree/2.1.0" + }, + "time": "2021-12-13T19:03:21+00:00" + }, + { + "name": "phpdocumentor/guides", + "version": "1.10.1", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/guides-core.git", + "reference": "f90731c13f0132dac864c21adce866f0f756541a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/guides-core/zipball/f90731c13f0132dac864c21adce866f0f756541a", + "reference": "f90731c13f0132dac864c21adce866f0f756541a", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.1", + "ext-json": "*", + "ext-zlib": "*", + "league/flysystem": "^1.1.0 || ^3.29.1", + "league/tactician": "^1.1", + "league/uri": "^7.5.1", + "php": "^8.1", + "phpdocumentor/filesystem": "^1.7.4", + "phpdocumentor/flyfinder": "^1.1 || ^2.0", + "psr/event-dispatcher": "^1.0", + "symfony/clock": "^6.4.8 || ^7.4 || ^8", + "symfony/html-sanitizer": "^6.4.8 || ^7.4 || ^8", + "symfony/http-client": "^6.4.9 || ^7.4 || ^8", + "symfony/polyfill-php84": "^1.33.0", + "symfony/string": "^6.4.9 || ^7.4 || ^8", + "symfony/translation-contracts": "^3.5.1", + "twig/twig": "~2.15 || ^3.0", + "webmozart/assert": "^1.11" + }, + "require-dev": { + "psr/log": "^3.0.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Guides\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Contains the core functionality from the phpDocumentor Guides.", + "homepage": "https://www.phpdoc.org", + "support": { + "issues": "https://github.com/phpDocumentor/guides-core/issues", + "source": "https://github.com/phpDocumentor/guides-core/tree/1.10.1" + }, + "time": "2026-05-08T14:29:08+00:00" + }, + { + "name": "phpdocumentor/guides-graphs", + "version": "1.9.6", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/guides-graphs.git", + "reference": "580ffeb50d12363973b09a24109fb4d7a198efdd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/guides-graphs/zipball/580ffeb50d12363973b09a24109fb4d7a198efdd", + "reference": "580ffeb50d12363973b09a24109fb4d7a198efdd", + "shasum": "" + }, + "require": { + "jawira/plantuml-encoding": "^1.0", + "php": "^8.1", + "phpdocumentor/guides": "^1.0 || ^2.0", + "phpdocumentor/guides-restructured-text": "^1.0 || ^2.0", + "symfony/process": "^5.4 || ^6.3 || ^7.0 || ^8.0", + "twig/twig": "~2.0 || ^3.0" + }, + "suggest": { + "jawira/plantuml": "To render graphs locally using plant uml" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Guides\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Provides Plantuml support for restructured text rendered by the phpDocumentor's Guides library.", + "homepage": "https://www.phpdoc.org", + "support": { + "issues": "https://github.com/phpDocumentor/guides-graphs/issues", + "source": "https://github.com/phpDocumentor/guides-graphs/tree/1.9.6" + }, + "time": "2026-01-18T19:20:03+00:00" + }, + { + "name": "phpdocumentor/guides-markdown", + "version": "1.10.2", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/guides-markdown.git", + "reference": "8e30419d5724f025014d3fd6ecc2648a7ebda2a9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/guides-markdown/zipball/8e30419d5724f025014d3fd6ecc2648a7ebda2a9", + "reference": "8e30419d5724f025014d3fd6ecc2648a7ebda2a9", + "shasum": "" + }, + "require": { + "league/commonmark": "^2.4", + "php": "^8.1", + "phpdocumentor/guides": "^1.0 || ^2.0", + "symfony/yaml": "^6.4 || ^7.0 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Guides\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Adds Markdown support to the phpDocumentor's Guides library.", + "homepage": "https://www.phpdoc.org", + "support": { + "issues": "https://github.com/phpDocumentor/guides-markdown/issues", + "source": "https://github.com/phpDocumentor/guides-markdown/tree/1.10.2" + }, + "time": "2026-05-12T21:21:19+00:00" + }, + { + "name": "phpdocumentor/guides-restructured-text", + "version": "1.10.1", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/guides-restructured-text.git", + "reference": "382bb6f6ae20cb289999bbbd5b8d42a50032bb44" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/guides-restructured-text/zipball/382bb6f6ae20cb289999bbbd5b8d42a50032bb44", + "reference": "382bb6f6ae20cb289999bbbd5b8d42a50032bb44", + "shasum": "" + }, + "require": { + "doctrine/lexer": "^3.0", + "league/csv": "^9.27.0", + "php": "^8.1", + "phpdocumentor/guides": "^1.0 || ^2.0", + "webmozart/assert": "^1.11" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Guides\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Adds reStructuredText Markup support to the phpDocumentor's Guides library.", + "homepage": "https://www.phpdoc.org", + "support": { + "source": "https://github.com/phpDocumentor/guides-restructured-text/tree/1.10.1" + }, + "time": "2026-05-05T21:54:11+00:00" + }, + { + "name": "phpdocumentor/json-path", + "version": "0.2.1", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/json-path.git", + "reference": "f1b1cd2ce99c5be7ef576c516d3d476a2e2b6566" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/json-path/zipball/f1b1cd2ce99c5be7ef576c516d3d476a2e2b6566", + "reference": "f1b1cd2ce99c5be7ef576c516d3d476a2e2b6566", + "shasum": "" + }, + "require": { + "parsica-php/parsica": "^0.8.3", + "php": "8.1.*|8.2.*|8.3.*|8.4.*", + "symfony/property-access": "^5.4|^6.4|^7.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "phpDocumentor\\JsonPath\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "jaapio", + "email": "jaap@phpdoc.org" + } + ], + "description": "Access php objects with json path expressions", + "support": { + "issues": "https://github.com/phpDocumentor/json-path/issues", + "source": "https://github.com/phpDocumentor/json-path/tree/0.2.1" + }, + "time": "2025-10-04T13:52:17+00:00" + }, + { + "name": "phpdocumentor/phpdocumentor", + "version": "v3.10.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/phpDocumentor.git", + "reference": "0cd9a39454aadddcdd1e1f32194914e5d0871733" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/phpDocumentor/zipball/0cd9a39454aadddcdd1e1f32194914e5d0871733", + "reference": "0cd9a39454aadddcdd1e1f32194914e5d0871733", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-hash": "*", + "ext-iconv": "*", + "ext-json": "*", + "ext-mbstring": "*", + "ext-simplexml": "*", + "ext-xml": "*", + "jawira/plantuml": "^1.27", + "jean85/pretty-package-versions": "^1.5 || ^2.0.1", + "league/commonmark": "^2.2", + "league/flysystem": "^1.0", + "league/tactician": "^1.0", + "league/uri": "^7.5", + "league/uri-interfaces": "^7.5", + "monolog/monolog": "^3.0", + "nikic/php-parser": "^5.0", + "phar-io/manifest": "^2.0", + "phar-io/version": "^3.2", + "php": "^8.1", + "phpdocumentor/filesystem": "^1.10", + "phpdocumentor/flyfinder": "^1.0", + "phpdocumentor/graphviz": "^2.0", + "phpdocumentor/guides": "^1.10", + "phpdocumentor/guides-graphs": "^1.9", + "phpdocumentor/guides-markdown": "^1.10", + "phpdocumentor/guides-restructured-text": "^1.10", + "phpdocumentor/json-path": "^0.2.1", + "phpdocumentor/reflection": "^6.6.0", + "phpdocumentor/reflection-common": "^2.0", + "phpdocumentor/reflection-docblock": "^5.4", + "phpdocumentor/type-resolver": "^1.8", + "psr/cache": "^2.0|^3.0", + "psr/event-dispatcher": "^1.0", + "psr/log": "^2.0 || ^3.0", + "symfony/cache": "^6.0", + "symfony/config": "^6.0", + "symfony/console": "^6.0", + "symfony/contracts": "^3.3", + "symfony/dependency-injection": "^6.0", + "symfony/dom-crawler": "^6.0", + "symfony/dotenv": "^6.0", + "symfony/event-dispatcher": "^6.0", + "symfony/expression-language": "^6.0", + "symfony/filesystem": "^6.0", + "symfony/finder": "^6.0", + "symfony/polyfill-intl-idn": "^1.22", + "symfony/process": "^6.0", + "symfony/routing": "^6.0", + "symfony/stopwatch": "^6.0", + "symfony/string": "^6.4", + "symfony/yaml": "^6.0", + "twig/string-extra": "^3.17", + "twig/twig": "^3.17", + "webmozart/assert": "^1.3" + }, + "conflict": { + "phpdocumentor/reflection-docblock": "5.4.0", + "symfony/symfony": "*" + }, + "replace": { + "paragonie/random_compat": "2.*", + "symfony/polyfill-iconv": "*", + "symfony/polyfill-php56": "*", + "symfony/polyfill-php70": "*", + "symfony/polyfill-php71": "*", + "symfony/polyfill-php72": "*" + }, + "require-dev": { + "doctrine/coding-standard": "^13.0", + "fakerphp/faker": "^1.21", + "mikey179/vfsstream": "^1.2", + "mockery/mockery": "^1.0", + "phpspec/prophecy-phpunit": "^2.2", + "phpstan/extension-installer": "^1.3", + "phpstan/phpstan": "^1.10", + "phpstan/phpstan-webmozart-assert": "^1.2", + "phpunit/phpunit": "^10.0", + "rector/rector": "^1.2.5", + "symfony/var-dumper": "^6.0", + "vincentlanglet/twig-cs-fixer": "^3.5" + }, + "bin": [ + "bin/phpdoc", + "bin/phpdoc.php" + ], + "type": "library", + "extra": { + "symfony": { + "id": "01C32VS9393M1CP9R8TEJMH62G", + "require": "^6.0", + "allow-contrib": false + }, + "branch-alias": { + "dev-master": "3.4-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\": [ + "src/phpDocumentor/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Documentation Generator for PHP", + "homepage": "https://www.phpdoc.org", + "keywords": [ + "api", + "application", + "dga", + "documentation", + "phpdoc" + ], + "support": { + "issues": "https://github.com/phpDocumentor/phpDocumentor/issues", + "source": "https://github.com/phpDocumentor/phpDocumentor/tree/v3.10.0" + }, + "time": "2026-05-13T19:22:16+00:00" + }, + { + "name": "phpdocumentor/reflection", + "version": "6.6.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/Reflection.git", + "reference": "c8d36446027506a005103d57265ba5ea56beabfc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/Reflection/zipball/c8d36446027506a005103d57265ba5ea56beabfc", + "reference": "c8d36446027506a005103d57265ba5ea56beabfc", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2", + "nikic/php-parser": "~4.18 || ^5.0", + "php": "8.1.*|8.2.*|8.3.*|8.4.*|8.5.*", + "phpdocumentor/reflection-common": "^2.1", + "phpdocumentor/reflection-docblock": "^5", + "phpdocumentor/type-resolver": "^1.4", + "symfony/polyfill-php80": "^1.28", + "webmozart/assert": "^1.7" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "doctrine/coding-standard": "^13.0", + "eliashaeussler/phpunit-attributes": "^1.8", + "mikey179/vfsstream": "~1.2", + "mockery/mockery": "~1.6.0", + "phpspec/prophecy-phpunit": "^2.4", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-webmozart-assert": "^1.2", + "phpunit/phpunit": "^10.5.53", + "psalm/phar": "^6.0", + "rector/rector": "^1.0.0", + "squizlabs/php_codesniffer": "^3.8" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-5.x": "5.3.x-dev", + "dev-6.x": "6.0.x-dev" + } + }, + "autoload": { + "files": [ + "src/php-parser/Modifiers.php" + ], + "psr-4": { + "phpDocumentor\\": "src/phpDocumentor" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Reflection library to do Static Analysis for PHP Projects", + "homepage": "http://www.phpdoc.org", + "keywords": [ + "phpDocumentor", + "phpdoc", + "reflection", + "static analysis" + ], + "support": { + "issues": "https://github.com/phpDocumentor/Reflection/issues", + "source": "https://github.com/phpDocumentor/Reflection/tree/6.6.0" + }, + "time": "2026-04-12T18:07:10+00:00" + }, + { + "name": "phpdocumentor/reflection-common", + "version": "2.2.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionCommon.git", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "Common reflection classes used by phpdocumentor to reflect the code structure", + "homepage": "http://www.phpdoc.org", + "keywords": [ + "FQSEN", + "phpDocumentor", + "phpdoc", + "reflection", + "static analysis" + ], + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues", + "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x" + }, + "time": "2020-06-27T09:03:43+00:00" + }, + { + "name": "phpdocumentor/reflection-docblock", + "version": "5.6.7", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", + "reference": "31a105931bc8ffa3a123383829772e832fd8d903" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/31a105931bc8ffa3a123383829772e832fd8d903", + "reference": "31a105931bc8ffa3a123383829772e832fd8d903", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.1", + "ext-filter": "*", + "php": "^7.4 || ^8.0", + "phpdocumentor/reflection-common": "^2.2", + "phpdocumentor/type-resolver": "^1.7", + "phpstan/phpdoc-parser": "^1.7|^2.0", + "webmozart/assert": "^1.9.1 || ^2" + }, + "require-dev": { + "mockery/mockery": "~1.3.5 || ~1.6.0", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-mockery": "^1.1", + "phpstan/phpstan-webmozart-assert": "^1.2", + "phpunit/phpunit": "^9.5", + "psalm/phar": "^5.26" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + }, + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.7" + }, + "time": "2026-03-18T20:47:46+00:00" + }, + { + "name": "phpdocumentor/type-resolver", + "version": "1.12.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/TypeResolver.git", + "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/92a98ada2b93d9b201a613cb5a33584dde25f195", + "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.0", + "php": "^7.3 || ^8.0", + "phpdocumentor/reflection-common": "^2.0", + "phpstan/phpdoc-parser": "^1.18|^2.0" + }, + "require-dev": { + "ext-tokenizer": "*", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-phpunit": "^1.1", + "phpunit/phpunit": "^9.5", + "rector/rector": "^0.13.9", + "vimeo/psalm": "^4.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-1.x": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + } + ], + "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", + "support": { + "issues": "https://github.com/phpDocumentor/TypeResolver/issues", + "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.12.0" + }, + "time": "2025-11-21T15:09:14+00:00" + }, + { + "name": "phpstan/extension-installer", + "version": "1.4.3", + "source": { + "type": "git", + "url": "https://github.com/phpstan/extension-installer.git", + "reference": "85e90b3942d06b2326fba0403ec24fe912372936" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/extension-installer/zipball/85e90b3942d06b2326fba0403ec24fe912372936", + "reference": "85e90b3942d06b2326fba0403ec24fe912372936", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^2.0", + "php": "^7.2 || ^8.0", + "phpstan/phpstan": "^1.9.0 || ^2.0" + }, + "require-dev": { + "composer/composer": "^2.0", + "php-parallel-lint/php-parallel-lint": "^1.2.0", + "phpstan/phpstan-strict-rules": "^0.11 || ^0.12 || ^1.0" + }, + "type": "composer-plugin", + "extra": { + "class": "PHPStan\\ExtensionInstaller\\Plugin" + }, + "autoload": { + "psr-4": { + "PHPStan\\ExtensionInstaller\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Composer plugin for automatic installation of PHPStan extensions", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "issues": "https://github.com/phpstan/extension-installer/issues", + "source": "https://github.com/phpstan/extension-installer/tree/1.4.3" + }, + "time": "2024-09-04T20:21:43+00:00" + }, + { + "name": "phpstan/phpdoc-parser", + "version": "2.3.3", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpdoc-parser.git", + "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", + "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "doctrine/annotations": "^2.0", + "nikic/php-parser": "^5.3.0", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6", + "symfony/process": "^5.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPStan\\PhpDocParser\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPDoc parser with support for nullable, intersection and generic types", + "support": { + "issues": "https://github.com/phpstan/phpdoc-parser/issues", + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.3" + }, + "time": "2026-07-08T07:01:06+00:00" + }, + { + "name": "phpstan/phpstan", + "version": "2.2.5", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/909c1e5fef7989ac0d0c1c5c42e32a5c4f6198a0", + "reference": "909c1e5fef7989ac0d0c1c5c42e32a5c4f6198a0", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ondřej Mirtes" + }, + { + "name": "Markus Staab" + }, + { + "name": "Vincent Langlet" + } + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + } + ], + "time": "2026-07-05T06:31:06+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "9.2.32", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "85402a822d1ecf1db1096959413d35e1c37cf1a5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/85402a822d1ecf1db1096959413d35e1c37cf1a5", + "reference": "85402a822d1ecf1db1096959413d35e1c37cf1a5", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^4.19.1 || ^5.1.0", + "php": ">=7.3", + "phpunit/php-file-iterator": "^3.0.6", + "phpunit/php-text-template": "^2.0.4", + "sebastian/code-unit-reverse-lookup": "^2.0.3", + "sebastian/complexity": "^2.0.3", + "sebastian/environment": "^5.1.5", + "sebastian/lines-of-code": "^1.0.4", + "sebastian/version": "^3.0.2", + "theseer/tokenizer": "^1.2.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.6" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.2.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.32" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-08-22T04:23:01+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "3.0.6", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf", + "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/3.0.6" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2021-12-02T12:48:52+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "3.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/5a10147d0aaf65b58940a0b72f71c9ac0423cc67", + "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^9.3" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/3.1.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-09-28T05:58:55+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28", + "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T05:33:50+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "5.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2", + "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "source": "https://github.com/sebastianbergmann/php-timer/tree/5.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:16:10+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "9.6.35", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "0edba2f3a0c48df3553cb9b640810b30df60302b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/0edba2f3a0c48df3553cb9b640810b30df60302b", + "reference": "0edba2f3a0c48df3553cb9b640810b30df60302b", + "shasum": "" + }, + "require": { + "doctrine/instantiator": "^1.5.0 || ^2", + "ext-dom": "*", + "ext-filter": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=7.3", + "phpunit/php-code-coverage": "^9.2.32", + "phpunit/php-file-iterator": "^3.0.6", + "phpunit/php-invoker": "^3.1.1", + "phpunit/php-text-template": "^2.0.4", + "phpunit/php-timer": "^5.0.3", + "sebastian/cli-parser": "^1.0.2", + "sebastian/code-unit": "^1.0.8", + "sebastian/comparator": "^4.0.10", + "sebastian/diff": "^4.0.6", + "sebastian/environment": "^5.1.5", + "sebastian/exporter": "^4.0.8", + "sebastian/global-state": "^5.0.8", + "sebastian/object-enumerator": "^4.0.4", + "sebastian/resource-operations": "^3.0.4", + "sebastian/type": "^3.2.1", + "sebastian/version": "^3.0.2" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "9.6-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.35" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-07-06T14:48:07+00:00" + }, + { + "name": "psr/cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/cache.git", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for caching libraries", + "keywords": [ + "cache", + "psr", + "psr-6" + ], + "support": { + "source": "https://github.com/php-fig/cache/tree/3.0.0" + }, + "time": "2021-02-03T23:26:27+00:00" + }, + { + "name": "psr/clock", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/clock.git", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Clock\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for reading the clock.", + "homepage": "https://github.com/php-fig/clock", + "keywords": [ + "clock", + "now", + "psr", + "psr-20", + "time" + ], + "support": { + "issues": "https://github.com/php-fig/clock/issues", + "source": "https://github.com/php-fig/clock/tree/1.0.0" + }, + "time": "2022-11-25T14:36:26+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "2b56bea83a09de3ac06bb18b92f068e60cc6f50b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/2b56bea83a09de3ac06bb18b92f068e60cc6f50b", + "reference": "2b56bea83a09de3ac06bb18b92f068e60cc6f50b", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/1.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T06:27:43+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "1.0.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/1fc9f64c0927627ef78ba436c9b17d967e68e120", + "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "source": "https://github.com/sebastianbergmann/code-unit/tree/1.0.8" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:08:54+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "2.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5", + "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/2.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-09-28T05:30:19+00:00" + }, + { + "name": "sebastian/comparator", + "version": "4.0.10", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/e4df00b9b3571187db2831ae9aada2c6efbd715d", + "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "sebastian/diff": "^4.0", + "sebastian/exporter": "^4.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.10" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-01-24T09:22:56+00:00" + }, + { + "name": "sebastian/complexity", + "version": "2.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/25f207c40d62b8b7aa32f5ab026c53561964053a", + "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.18 || ^5.0", + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "source": "https://github.com/sebastianbergmann/complexity/tree/2.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-12-22T06:19:30+00:00" + }, + { + "name": "sebastian/diff", + "version": "4.0.6", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "ba01945089c3a293b01ba9badc29ad55b106b0bc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/ba01945089c3a293b01ba9badc29ad55b106b0bc", + "reference": "ba01945089c3a293b01ba9badc29ad55b106b0bc", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3", + "symfony/process": "^4.2 || ^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "source": "https://github.com/sebastianbergmann/diff/tree/4.0.6" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T06:30:58+00:00" + }, + { + "name": "sebastian/environment", + "version": "5.1.5", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/830c43a844f1f8d5b7a1f6d6076b784454d8b7ed", + "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "http://www.github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "source": "https://github.com/sebastianbergmann/environment/tree/5.1.5" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:03:51+00:00" + }, + { + "name": "sebastian/exporter", + "version": "4.0.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "14c6ba52f95a36c3d27c835d65efc7123c446e8c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/14c6ba52f95a36c3d27c835d65efc7123c446e8c", + "reference": "14c6ba52f95a36c3d27c835d65efc7123c446e8c", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "sebastian/recursion-context": "^4.0" + }, + "require-dev": { + "ext-mbstring": "*", + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.8" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2025-09-24T06:03:27+00:00" + }, + { + "name": "sebastian/global-state", + "version": "5.0.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/b6781316bdcd28260904e7cc18ec983d0d2ef4f6", + "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "sebastian/object-reflector": "^2.0", + "sebastian/recursion-context": "^4.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^9.3" + }, + "suggest": { + "ext-uopz": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "http://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.8" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state", + "type": "tidelift" + } + ], + "time": "2025-08-10T07:10:35+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "1.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/e1e4a170560925c26d424b6a03aed157e7dcc5c5", + "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.18 || ^5.0", + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/1.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-12-22T06:20:34+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "4.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "5c9eeac41b290a3712d88851518825ad78f45c71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/5c9eeac41b290a3712d88851518825ad78f45c71", + "reference": "5c9eeac41b290a3712d88851518825ad78f45c71", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "sebastian/object-reflector": "^2.0", + "sebastian/recursion-context": "^4.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/4.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:12:34+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", + "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:14:26+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "4.0.6", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "539c6691e0623af6dc6f9c20384c120f963465a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/539c6691e0623af6dc6f9c20384c120f963465a0", + "reference": "539c6691e0623af6dc6f9c20384c120f963465a0", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.6" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-10T06:57:39+00:00" + }, + { + "name": "sebastian/resource-operations", + "version": "3.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/resource-operations.git", + "reference": "05d5692a7993ecccd56a03e40cd7e5b09b1d404e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/05d5692a7993ecccd56a03e40cd7e5b09b1d404e", + "reference": "05d5692a7993ecccd56a03e40cd7e5b09b1d404e", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides a list of PHP built-in functions that operate on resources", + "homepage": "https://www.github.com/sebastianbergmann/resource-operations", + "support": { + "source": "https://github.com/sebastianbergmann/resource-operations/tree/3.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-14T16:00:52+00:00" + }, + { + "name": "sebastian/type", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7" }, - "suggest": { - "paragonie/sodium_compat": "Pure PHP implementation of libsodium", - "symfony/polyfill-php80": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", - "szepeviktor/phpstan-wordpress": "WordPress extensions for PHPStan" + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7", + "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.5" }, "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], - "description": "WordPress function and class declaration stubs for static analysis.", - "homepage": "https://github.com/php-stubs/wordpress-stubs", - "keywords": [ - "PHPStan", - "static analysis", - "wordpress" + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", "support": { - "issues": "https://github.com/php-stubs/wordpress-stubs/issues", - "source": "https://github.com/php-stubs/wordpress-stubs/tree/v7.0.0" + "issues": "https://github.com/sebastianbergmann/type/issues", + "source": "https://github.com/sebastianbergmann/type/tree/3.2.1" }, - "time": "2026-05-21T21:41:34+00:00" + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:13:03+00:00" }, { - "name": "phpcompatibility/php-compatibility", - "version": "9.3.5", + "name": "sebastian/version", + "version": "3.0.2", "source": { "type": "git", - "url": "https://github.com/PHPCompatibility/PHPCompatibility.git", - "reference": "9fb324479acf6f39452e0655d2429cc0d3914243" + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c6c1022351a901512170118436c764e473f6de8c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibility/zipball/9fb324479acf6f39452e0655d2429cc0d3914243", - "reference": "9fb324479acf6f39452e0655d2429cc0d3914243", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c6c1022351a901512170118436c764e473f6de8c", + "reference": "c6c1022351a901512170118436c764e473f6de8c", "shasum": "" }, "require": { - "php": ">=5.3", - "squizlabs/php_codesniffer": "^2.3 || ^3.0.2" - }, - "conflict": { - "squizlabs/php_codesniffer": "2.6.2" + "php": ">=7.3" }, - "require-dev": { - "phpunit/phpunit": "~4.5 || ^5.0 || ^6.0 || ^7.0" + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } }, - "suggest": { - "dealerdirect/phpcodesniffer-composer-installer": "^0.5 || This Composer plugin will sort out the PHPCS 'installed_paths' automatically.", - "roave/security-advisories": "dev-master || Helps prevent installing dependencies with known security issues." + "autoload": { + "classmap": [ + "src/" + ] }, - "type": "phpcodesniffer-standard", "notification-url": "https://packagist.org/downloads/", "license": [ - "LGPL-3.0-or-later" + "BSD-3-Clause" ], "authors": [ { - "name": "Wim Godden", - "homepage": "https://github.com/wimg", + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "source": "https://github.com/sebastianbergmann/version/tree/3.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-09-28T06:39:44+00:00" + }, + { + "name": "squizlabs/php_codesniffer", + "version": "3.13.5", + "source": { + "type": "git", + "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", + "reference": "0ca86845ce43291e8f5692c7356fccf3bcf02bf4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/0ca86845ce43291e8f5692c7356fccf3bcf02bf4", + "reference": "0ca86845ce43291e8f5692c7356fccf3bcf02bf4", + "shasum": "" + }, + "require": { + "ext-simplexml": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": ">=5.4.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4" + }, + "bin": [ + "bin/phpcbf", + "bin/phpcs" + ], + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Greg Sherwood", + "role": "Former lead" }, { "name": "Juliette Reinders Folmer", - "homepage": "https://github.com/jrfnl", - "role": "lead" + "role": "Current lead" }, { "name": "Contributors", - "homepage": "https://github.com/PHPCompatibility/PHPCompatibility/graphs/contributors" + "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer/graphs/contributors" } ], - "description": "A set of sniffs for PHP_CodeSniffer that checks for PHP cross-version compatibility.", - "homepage": "http://techblog.wimgodden.be/tag/codesniffer/", + "description": "PHP_CodeSniffer tokenizes PHP, JavaScript and CSS files and detects violations of a defined set of coding standards.", + "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer", "keywords": [ - "compatibility", "phpcs", - "standards" + "standards", + "static analysis" ], "support": { - "issues": "https://github.com/PHPCompatibility/PHPCompatibility/issues", - "source": "https://github.com/PHPCompatibility/PHPCompatibility" + "issues": "https://github.com/PHPCSStandards/PHP_CodeSniffer/issues", + "security": "https://github.com/PHPCSStandards/PHP_CodeSniffer/security/policy", + "source": "https://github.com/PHPCSStandards/PHP_CodeSniffer", + "wiki": "https://github.com/PHPCSStandards/PHP_CodeSniffer/wiki" }, - "time": "2019-12-27T09:44:58+00:00" + "funding": [ + { + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" + } + ], + "time": "2025-11-04T16:30:35+00:00" }, { - "name": "phpcompatibility/phpcompatibility-paragonie", - "version": "1.3.4", + "name": "symfony/cache", + "version": "v6.4.43", "source": { "type": "git", - "url": "https://github.com/PHPCompatibility/PHPCompatibilityParagonie.git", - "reference": "244d7b04fc4bc2117c15f5abe23eb933b5f02bbf" + "url": "https://github.com/symfony/cache.git", + "reference": "8f0b5de5d9f5131aa99fdab78d7802efef9322dc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibilityParagonie/zipball/244d7b04fc4bc2117c15f5abe23eb933b5f02bbf", - "reference": "244d7b04fc4bc2117c15f5abe23eb933b5f02bbf", + "url": "https://api.github.com/repos/symfony/cache/zipball/8f0b5de5d9f5131aa99fdab78d7802efef9322dc", + "reference": "8f0b5de5d9f5131aa99fdab78d7802efef9322dc", "shasum": "" }, "require": { - "phpcompatibility/php-compatibility": "^9.0" + "php": ">=8.1", + "psr/cache": "^2.0|^3.0", + "psr/log": "^1.1|^2|^3", + "symfony/cache-contracts": "^2.5|^3", + "symfony/service-contracts": "^2.5|^3", + "symfony/var-exporter": "^6.3.6|^7.0" + }, + "conflict": { + "doctrine/dbal": "<2.13.1", + "symfony/dependency-injection": "<5.4", + "symfony/http-kernel": "<5.4", + "symfony/var-dumper": "<5.4" + }, + "provide": { + "psr/cache-implementation": "2.0|3.0", + "psr/simple-cache-implementation": "1.0|2.0|3.0", + "symfony/cache-implementation": "1.1|2.0|3.0" }, "require-dev": { - "dealerdirect/phpcodesniffer-composer-installer": "^1.0", - "paragonie/random_compat": "dev-master", - "paragonie/sodium_compat": "dev-master" + "cache/integration-tests": "dev-master", + "doctrine/dbal": "^2.13.1|^3|^4", + "predis/predis": "^1.1|^2.0", + "psr/simple-cache": "^1.0|^2.0|^3.0", + "symfony/config": "^5.4|^6.0|^7.0", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/filesystem": "^5.4|^6.0|^7.0", + "symfony/http-kernel": "^5.4|^6.0|^7.0", + "symfony/messenger": "^5.4|^6.0|^7.0", + "symfony/var-dumper": "^5.4|^6.0|^7.0" }, - "suggest": { - "dealerdirect/phpcodesniffer-composer-installer": "^1.0 || This Composer plugin will sort out the PHP_CodeSniffer 'installed_paths' automatically.", - "roave/security-advisories": "dev-master || Helps prevent installing dependencies with known security issues." + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Cache\\": "" + }, + "classmap": [ + "Traits/ValueWrapper.php" + ], + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides extended PSR-6, PSR-16 (and tags) implementations", + "homepage": "https://symfony.com", + "keywords": [ + "caching", + "psr6" + ], + "support": { + "source": "https://github.com/symfony/cache/tree/v6.4.43" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-28T21:33:22+00:00" + }, + { + "name": "symfony/clock", + "version": "v8.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/clock.git", + "reference": "b55a638b189a6faa875e0ccdb00908fb87af95b3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/clock/zipball/b55a638b189a6faa875e0ccdb00908fb87af95b3", + "reference": "b55a638b189a6faa875e0ccdb00908fb87af95b3", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "psr/clock": "^1.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/now.php" + ], + "psr-4": { + "Symfony\\Component\\Clock\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] }, - "type": "phpcodesniffer-standard", "notification-url": "https://packagist.org/downloads/", "license": [ - "LGPL-3.0-or-later" + "MIT" ], "authors": [ { - "name": "Wim Godden", - "role": "lead" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { - "name": "Juliette Reinders Folmer", - "role": "lead" + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "A set of rulesets for PHP_CodeSniffer to check for PHP cross-version compatibility issues in projects, while accounting for polyfills provided by the Paragonie polyfill libraries.", - "homepage": "http://phpcompatibility.com/", + "description": "Decouples applications from the system clock", + "homepage": "https://symfony.com", "keywords": [ - "compatibility", - "paragonie", - "phpcs", - "polyfill", - "standards", - "static analysis" + "clock", + "psr20", + "time" ], "support": { - "issues": "https://github.com/PHPCompatibility/PHPCompatibilityParagonie/issues", - "security": "https://github.com/PHPCompatibility/PHPCompatibilityParagonie/security/policy", - "source": "https://github.com/PHPCompatibility/PHPCompatibilityParagonie" + "source": "https://github.com/symfony/clock/tree/v8.0.8" }, "funding": [ { - "url": "https://github.com/PHPCompatibility", - "type": "github" + "url": "https://symfony.com/sponsor", + "type": "custom" }, { - "url": "https://github.com/jrfnl", + "url": "https://github.com/fabpot", "type": "github" }, { - "url": "https://opencollective.com/php_codesniffer", - "type": "open_collective" + "url": "https://github.com/nicolas-grekas", + "type": "github" }, { - "url": "https://thanks.dev/u/gh/phpcompatibility", - "type": "thanks_dev" + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" } ], - "time": "2025-09-19T17:43:28+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { - "name": "phpcompatibility/phpcompatibility-wp", - "version": "2.1.8", + "name": "symfony/config", + "version": "v6.4.43", "source": { "type": "git", - "url": "https://github.com/PHPCompatibility/PHPCompatibilityWP.git", - "reference": "7c8d18b4d90dac9e86b0869a608fa09158e168fa" + "url": "https://github.com/symfony/config.git", + "reference": "e39cdd91b2adf67a117bea29660b73d896937d86" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibilityWP/zipball/7c8d18b4d90dac9e86b0869a608fa09158e168fa", - "reference": "7c8d18b4d90dac9e86b0869a608fa09158e168fa", + "url": "https://api.github.com/repos/symfony/config/zipball/e39cdd91b2adf67a117bea29660b73d896937d86", + "reference": "e39cdd91b2adf67a117bea29660b73d896937d86", "shasum": "" }, "require": { - "phpcompatibility/php-compatibility": "^9.0", - "phpcompatibility/phpcompatibility-paragonie": "^1.0", - "squizlabs/php_codesniffer": "^3.3" + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/filesystem": "^5.4|^6.0|^7.0", + "symfony/polyfill-ctype": "~1.8" + }, + "conflict": { + "symfony/finder": "<5.4", + "symfony/service-contracts": "<2.5" }, "require-dev": { - "dealerdirect/phpcodesniffer-composer-installer": "^1.0" + "symfony/event-dispatcher": "^5.4|^6.0|^7.0", + "symfony/finder": "^5.4|^6.0|^7.0", + "symfony/messenger": "^5.4|^6.0|^7.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^5.4|^6.0|^7.0" }, - "suggest": { - "dealerdirect/phpcodesniffer-composer-installer": "^1.0 || This Composer plugin will sort out the PHP_CodeSniffer 'installed_paths' automatically.", - "roave/security-advisories": "dev-master || Helps prevent installing dependencies with known security issues." + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Config\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] }, - "type": "phpcodesniffer-standard", "notification-url": "https://packagist.org/downloads/", "license": [ - "LGPL-3.0-or-later" + "MIT" ], "authors": [ { - "name": "Wim Godden", - "role": "lead" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { - "name": "Juliette Reinders Folmer", - "role": "lead" + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "A ruleset for PHP_CodeSniffer to check for PHP cross-version compatibility issues in projects, while accounting for polyfills provided by WordPress.", - "homepage": "http://phpcompatibility.com/", - "keywords": [ - "compatibility", - "phpcs", - "standards", - "static analysis", - "wordpress" - ], + "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", + "homepage": "https://symfony.com", "support": { - "issues": "https://github.com/PHPCompatibility/PHPCompatibilityWP/issues", - "security": "https://github.com/PHPCompatibility/PHPCompatibilityWP/security/policy", - "source": "https://github.com/PHPCompatibility/PHPCompatibilityWP" + "source": "https://github.com/symfony/config/tree/v6.4.43" }, "funding": [ { - "url": "https://github.com/PHPCompatibility", - "type": "github" + "url": "https://symfony.com/sponsor", + "type": "custom" }, { - "url": "https://github.com/jrfnl", + "url": "https://github.com/fabpot", "type": "github" }, { - "url": "https://opencollective.com/php_codesniffer", - "type": "open_collective" + "url": "https://github.com/nicolas-grekas", + "type": "github" }, { - "url": "https://thanks.dev/u/gh/phpcompatibility", - "type": "thanks_dev" + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" } ], - "time": "2025-10-18T00:05:59+00:00" + "time": "2026-07-21T09:46:53+00:00" }, { - "name": "phpcsstandards/phpcsextra", - "version": "1.5.0", + "name": "symfony/console", + "version": "v6.4.43", "source": { "type": "git", - "url": "https://github.com/PHPCSStandards/PHPCSExtra.git", - "reference": "b598aa890815b8df16363271b659d73280129101" + "url": "https://github.com/symfony/console.git", + "reference": "3b643aa587acbc42f967a429af088a56ed8f046d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHPCSExtra/zipball/b598aa890815b8df16363271b659d73280129101", - "reference": "b598aa890815b8df16363271b659d73280129101", + "url": "https://api.github.com/repos/symfony/console/zipball/3b643aa587acbc42f967a429af088a56ed8f046d", + "reference": "3b643aa587acbc42f967a429af088a56ed8f046d", "shasum": "" }, "require": { - "php": ">=5.4", - "phpcsstandards/phpcsutils": "^1.2.0", - "squizlabs/php_codesniffer": "^3.13.5 || ^4.0.1" + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^5.4|^6.0|^7.0" + }, + "conflict": { + "symfony/dependency-injection": "<5.4", + "symfony/dotenv": "<5.4", + "symfony/event-dispatcher": "<5.4", + "symfony/lock": "<5.4", + "symfony/process": "<5.4" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" }, "require-dev": { - "php-parallel-lint/php-console-highlighter": "^1.0", - "php-parallel-lint/php-parallel-lint": "^1.4.0", - "phpcsstandards/phpcsdevcs": "^1.2.0", - "phpcsstandards/phpcsdevtools": "^1.2.1", - "phpunit/phpunit": "^4.5 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4" + "psr/log": "^1|^2|^3", + "symfony/config": "^5.4|^6.0|^7.0", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/event-dispatcher": "^5.4|^6.0|^7.0", + "symfony/http-foundation": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/lock": "^5.4|^6.0|^7.0", + "symfony/messenger": "^5.4|^6.0|^7.0", + "symfony/process": "^5.4|^6.0|^7.0", + "symfony/stopwatch": "^5.4|^6.0|^7.0", + "symfony/var-dumper": "^5.4|^6.0|^7.0" }, - "type": "phpcodesniffer-standard", - "extra": { - "branch-alias": { - "dev-stable": "1.x-dev", - "dev-develop": "1.x-dev" - } + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "LGPL-3.0-or-later" + "MIT" ], "authors": [ { - "name": "Juliette Reinders Folmer", - "homepage": "https://github.com/jrfnl", - "role": "lead" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { - "name": "Contributors", - "homepage": "https://github.com/PHPCSStandards/PHPCSExtra/graphs/contributors" + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "A collection of sniffs and standards for use with PHP_CodeSniffer.", + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", "keywords": [ - "PHP_CodeSniffer", - "phpcbf", - "phpcodesniffer-standard", - "phpcs", - "standards", - "static analysis" + "cli", + "command-line", + "console", + "terminal" ], "support": { - "issues": "https://github.com/PHPCSStandards/PHPCSExtra/issues", - "security": "https://github.com/PHPCSStandards/PHPCSExtra/security/policy", - "source": "https://github.com/PHPCSStandards/PHPCSExtra" + "source": "https://github.com/symfony/console/tree/v6.4.43" }, "funding": [ { - "url": "https://github.com/PHPCSStandards", - "type": "github" + "url": "https://symfony.com/sponsor", + "type": "custom" }, { - "url": "https://github.com/jrfnl", + "url": "https://github.com/fabpot", "type": "github" }, { - "url": "https://opencollective.com/php_codesniffer", - "type": "open_collective" + "url": "https://github.com/nicolas-grekas", + "type": "github" }, { - "url": "https://thanks.dev/u/gh/phpcsstandards", - "type": "thanks_dev" + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" } ], - "time": "2025-11-12T23:06:57+00:00" + "time": "2026-07-26T14:44:19+00:00" }, { - "name": "phpcsstandards/phpcsutils", - "version": "1.2.2", + "name": "symfony/contracts", + "version": "v3.7.2", "source": { "type": "git", - "url": "https://github.com/PHPCSStandards/PHPCSUtils.git", - "reference": "c216317e96c8b3f5932808f9b0f1f7a14e3bbf55" + "url": "https://github.com/symfony/contracts.git", + "reference": "08b31f5896b6963278abd0fc80bb39b49bb542dc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHPCSUtils/zipball/c216317e96c8b3f5932808f9b0f1f7a14e3bbf55", - "reference": "c216317e96c8b3f5932808f9b0f1f7a14e3bbf55", + "url": "https://api.github.com/repos/symfony/contracts/zipball/08b31f5896b6963278abd0fc80bb39b49bb542dc", + "reference": "08b31f5896b6963278abd0fc80bb39b49bb542dc", "shasum": "" }, "require": { - "dealerdirect/phpcodesniffer-composer-installer": "^0.4.1 || ^0.5 || ^0.6.2 || ^0.7 || ^1.0", - "php": ">=5.4", - "squizlabs/php_codesniffer": "^3.13.5 || ^4.0.1" + "php": ">=8.1", + "psr/cache": "^3.0", + "psr/container": "^1.1|^2.0", + "psr/event-dispatcher": "^1.0" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "replace": { + "symfony/cache-contracts": "self.version", + "symfony/deprecation-contracts": "self.version", + "symfony/event-dispatcher-contracts": "self.version", + "symfony/http-client-contracts": "self.version", + "symfony/service-contracts": "self.version", + "symfony/translation-contracts": "self.version" }, "require-dev": { - "ext-filter": "*", - "php-parallel-lint/php-console-highlighter": "^1.0", - "php-parallel-lint/php-parallel-lint": "^1.4.0", - "phpcsstandards/phpcsdevcs": "^1.2.0", - "yoast/phpunit-polyfills": "^1.1.0 || ^2.0.0 || ^3.0.0" + "symfony/polyfill-intl-idn": "^1.10" }, - "type": "phpcodesniffer-standard", + "type": "library", "extra": { "branch-alias": { - "dev-stable": "1.x-dev", - "dev-develop": "1.x-dev" + "dev-main": "3.7-dev" } }, "autoload": { - "classmap": [ - "PHPCSUtils/" + "files": [ + "Deprecation/function.php" + ], + "psr-4": { + "Symfony\\Contracts\\": "" + }, + "exclude-from-classmap": [ + "**/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "LGPL-3.0-or-later" + "MIT" ], "authors": [ { - "name": "Juliette Reinders Folmer", - "homepage": "https://github.com/jrfnl", - "role": "lead" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { - "name": "Contributors", - "homepage": "https://github.com/PHPCSStandards/PHPCSUtils/graphs/contributors" + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "A suite of utility functions for use with PHP_CodeSniffer", - "homepage": "https://phpcsutils.com/", + "description": "A set of abstractions extracted out of the Symfony components", + "homepage": "https://symfony.com", "keywords": [ - "PHP_CodeSniffer", - "phpcbf", - "phpcodesniffer-standard", - "phpcs", - "phpcs3", - "phpcs4", - "standards", - "static analysis", - "tokens", - "utility" + "abstractions", + "contracts", + "decoupling", + "dev", + "interfaces", + "interoperability", + "standards" ], "support": { - "docs": "https://phpcsutils.com/", - "issues": "https://github.com/PHPCSStandards/PHPCSUtils/issues", - "security": "https://github.com/PHPCSStandards/PHPCSUtils/security/policy", - "source": "https://github.com/PHPCSStandards/PHPCSUtils" + "source": "https://github.com/symfony/contracts/tree/v3.7.2" }, "funding": [ { - "url": "https://github.com/PHPCSStandards", - "type": "github" + "url": "https://symfony.com/sponsor", + "type": "custom" }, { - "url": "https://github.com/jrfnl", + "url": "https://github.com/fabpot", "type": "github" }, { - "url": "https://opencollective.com/php_codesniffer", - "type": "open_collective" + "url": "https://github.com/nicolas-grekas", + "type": "github" }, { - "url": "https://thanks.dev/u/gh/phpcsstandards", - "type": "thanks_dev" + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" } ], - "time": "2025-12-08T14:27:58+00:00" + "time": "2026-06-27T21:05:00+00:00" }, { - "name": "phpstan/extension-installer", - "version": "1.4.3", + "name": "symfony/dependency-injection", + "version": "v6.4.43", "source": { "type": "git", - "url": "https://github.com/phpstan/extension-installer.git", - "reference": "85e90b3942d06b2326fba0403ec24fe912372936" + "url": "https://github.com/symfony/dependency-injection.git", + "reference": "f11815fd2f5f80ce94bf527b5ecfd4bd0b1cc905" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/extension-installer/zipball/85e90b3942d06b2326fba0403ec24fe912372936", - "reference": "85e90b3942d06b2326fba0403ec24fe912372936", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/f11815fd2f5f80ce94bf527b5ecfd4bd0b1cc905", + "reference": "f11815fd2f5f80ce94bf527b5ecfd4bd0b1cc905", "shasum": "" }, "require": { - "composer-plugin-api": "^2.0", - "php": "^7.2 || ^8.0", - "phpstan/phpstan": "^1.9.0 || ^2.0" + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/service-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^6.4.20|^7.2.5" }, - "require-dev": { - "composer/composer": "^2.0", - "php-parallel-lint/php-parallel-lint": "^1.2.0", - "phpstan/phpstan-strict-rules": "^0.11 || ^0.12 || ^1.0" + "conflict": { + "ext-psr": "<1.1|>=2", + "symfony/config": "<6.1", + "symfony/finder": "<5.4", + "symfony/proxy-manager-bridge": "<6.3", + "symfony/yaml": "<5.4" }, - "type": "composer-plugin", - "extra": { - "class": "PHPStan\\ExtensionInstaller\\Plugin" + "provide": { + "psr/container-implementation": "1.1|2.0", + "symfony/service-implementation": "1.1|2.0|3.0" + }, + "require-dev": { + "symfony/config": "^6.1|^7.0", + "symfony/expression-language": "^5.4|^6.0|^7.0", + "symfony/yaml": "^5.4|^6.0|^7.0" }, + "type": "library", "autoload": { "psr-4": { - "PHPStan\\ExtensionInstaller\\": "src/" - } + "Symfony\\Component\\DependencyInjection\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], - "description": "Composer plugin for automatic installation of PHPStan extensions", - "keywords": [ - "dev", - "static analysis" + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } ], + "description": "Allows you to standardize and centralize the way objects are constructed in your application", + "homepage": "https://symfony.com", "support": { - "issues": "https://github.com/phpstan/extension-installer/issues", - "source": "https://github.com/phpstan/extension-installer/tree/1.4.3" + "source": "https://github.com/symfony/dependency-injection/tree/v6.4.43" }, - "time": "2024-09-04T20:21:43+00:00" + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-20T15:13:12+00:00" }, { - "name": "phpstan/phpstan", - "version": "2.2.5", + "name": "symfony/dom-crawler", + "version": "v6.4.40", + "source": { + "type": "git", + "url": "https://github.com/symfony/dom-crawler.git", + "reference": "7e65f76c28f5ed8d933f2c86698a3e2bf0de1b10" + }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/909c1e5fef7989ac0d0c1c5c42e32a5c4f6198a0", - "reference": "909c1e5fef7989ac0d0c1c5c42e32a5c4f6198a0", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/7e65f76c28f5ed8d933f2c86698a3e2bf0de1b10", + "reference": "7e65f76c28f5ed8d933f2c86698a3e2bf0de1b10", "shasum": "" }, "require": { - "php": "^7.4|^8.0" + "masterminds/html5": "^2.6", + "php": ">=8.1", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.0" }, - "conflict": { - "phpstan/phpstan-shim": "*" + "require-dev": { + "symfony/css-selector": "^5.4|^6.0|^7.0" }, - "bin": [ - "phpstan", - "phpstan.phar" - ], "type": "library", "autoload": { - "files": [ - "bootstrap.php" + "psr-4": { + "Symfony\\Component\\DomCrawler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", @@ -1041,1542 +5759,1877 @@ ], "authors": [ { - "name": "Ondřej Mirtes" - }, - { - "name": "Markus Staab" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { - "name": "Vincent Langlet" + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "PHPStan - PHP Static Analysis Tool", - "keywords": [ - "dev", - "static analysis" - ], + "description": "Eases DOM navigation for HTML and XML documents", + "homepage": "https://symfony.com", "support": { - "docs": "https://phpstan.org/user-guide/getting-started", - "forum": "https://github.com/phpstan/phpstan/discussions", - "issues": "https://github.com/phpstan/phpstan/issues", - "security": "https://github.com/phpstan/phpstan/security/policy", - "source": "https://github.com/phpstan/phpstan-src" + "source": "https://github.com/symfony/dom-crawler/tree/v6.4.40" }, "funding": [ { - "url": "https://github.com/ondrejmirtes", + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", "type": "github" }, { - "url": "https://github.com/phpstan", + "url": "https://github.com/nicolas-grekas", "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" } ], - "time": "2026-07-05T06:31:06+00:00" + "time": "2026-05-19T20:33:22+00:00" }, { - "name": "phpunit/php-code-coverage", - "version": "9.2.32", + "name": "symfony/dotenv", + "version": "v6.4.43", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "85402a822d1ecf1db1096959413d35e1c37cf1a5" + "url": "https://github.com/symfony/dotenv.git", + "reference": "9b827002b54f89a5ac605c21c349a42161996afb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/85402a822d1ecf1db1096959413d35e1c37cf1a5", - "reference": "85402a822d1ecf1db1096959413d35e1c37cf1a5", + "url": "https://api.github.com/repos/symfony/dotenv/zipball/9b827002b54f89a5ac605c21c349a42161996afb", + "reference": "9b827002b54f89a5ac605c21c349a42161996afb", "shasum": "" }, "require": { - "ext-dom": "*", - "ext-libxml": "*", - "ext-xmlwriter": "*", - "nikic/php-parser": "^4.19.1 || ^5.1.0", - "php": ">=7.3", - "phpunit/php-file-iterator": "^3.0.6", - "phpunit/php-text-template": "^2.0.4", - "sebastian/code-unit-reverse-lookup": "^2.0.3", - "sebastian/complexity": "^2.0.3", - "sebastian/environment": "^5.1.5", - "sebastian/lines-of-code": "^1.0.4", - "sebastian/version": "^3.0.2", - "theseer/tokenizer": "^1.2.3" + "php": ">=8.1" }, - "require-dev": { - "phpunit/phpunit": "^9.6" + "conflict": { + "symfony/console": "<5.4", + "symfony/process": "<5.4" }, - "suggest": { - "ext-pcov": "PHP extension that provides line coverage", - "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + "require-dev": { + "symfony/console": "^5.4|^6.0|^7.0", + "symfony/process": "^5.4|^6.0|^7.0" }, "type": "library", - "extra": { - "branch-alias": { - "dev-main": "9.2.x-dev" - } - }, "autoload": { - "classmap": [ - "src/" + "psr-4": { + "Symfony\\Component\\Dotenv\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", - "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "description": "Registers environment variables from a .env file", + "homepage": "https://symfony.com", "keywords": [ - "coverage", - "testing", - "xunit" + "dotenv", + "env", + "environment" ], "support": { - "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", - "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.32" + "source": "https://github.com/symfony/dotenv/tree/v6.4.43" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" } ], - "time": "2024-08-22T04:23:01+00:00" + "time": "2026-07-26T10:10:07+00:00" }, { - "name": "phpunit/php-file-iterator", - "version": "3.0.6", + "name": "symfony/event-dispatcher", + "version": "v6.4.43", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf" + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "ac405d324c10ebbbde6a6e58379bf81db10f1dbf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf", - "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/ac405d324c10ebbbde6a6e58379bf81db10f1dbf", + "reference": "ac405d324c10ebbbde6a6e58379bf81db10f1dbf", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.1", + "symfony/event-dispatcher-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/dependency-injection": "<5.4", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "2.0|3.0" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "psr/log": "^1|^2|^3", + "symfony/config": "^5.4|^6.0|^7.0", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/error-handler": "^5.4|^6.0|^7.0", + "symfony/expression-language": "^5.4|^6.0|^7.0", + "symfony/http-foundation": "^5.4|^6.0|^7.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^5.4|^6.0|^7.0" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.0-dev" - } - }, "autoload": { - "classmap": [ - "src/" + "psr-4": { + "Symfony\\Component\\EventDispatcher\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "FilterIterator implementation that filters files based on a list of suffixes.", - "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", - "keywords": [ - "filesystem", - "iterator" - ], + "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", + "homepage": "https://symfony.com", "support": { - "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", - "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/3.0.6" + "source": "https://github.com/symfony/event-dispatcher/tree/v6.4.43" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" } ], - "time": "2021-12-02T12:48:52+00:00" + "time": "2026-07-21T14:00:19+00:00" }, { - "name": "phpunit/php-invoker", - "version": "3.1.1", + "name": "symfony/expression-language", + "version": "v6.4.42", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/php-invoker.git", - "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67" + "url": "https://github.com/symfony/expression-language.git", + "reference": "728f0c48560a851e83681dc03efefa65d5fc076e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/5a10147d0aaf65b58940a0b72f71c9ac0423cc67", - "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67", + "url": "https://api.github.com/repos/symfony/expression-language/zipball/728f0c48560a851e83681dc03efefa65d5fc076e", + "reference": "728f0c48560a851e83681dc03efefa65d5fc076e", "shasum": "" }, "require": { - "php": ">=7.3" - }, - "require-dev": { - "ext-pcntl": "*", - "phpunit/phpunit": "^9.3" - }, - "suggest": { - "ext-pcntl": "*" + "php": ">=8.1", + "symfony/cache": "^5.4|^6.0|^7.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/service-contracts": "^2.5|^3" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.1-dev" - } - }, "autoload": { - "classmap": [ - "src/" + "psr-4": { + "Symfony\\Component\\ExpressionLanguage\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "Invoke callables with a timeout", - "homepage": "https://github.com/sebastianbergmann/php-invoker/", - "keywords": [ - "process" - ], + "description": "Provides an engine that can compile and evaluate expressions", + "homepage": "https://symfony.com", "support": { - "issues": "https://github.com/sebastianbergmann/php-invoker/issues", - "source": "https://github.com/sebastianbergmann/php-invoker/tree/3.1.1" + "source": "https://github.com/symfony/expression-language/tree/v6.4.42" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" } ], - "time": "2020-09-28T05:58:55+00:00" + "time": "2026-06-08T07:06:12+00:00" }, { - "name": "phpunit/php-text-template", - "version": "2.0.4", + "name": "symfony/filesystem", + "version": "v6.4.43", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/php-text-template.git", - "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28" + "url": "https://github.com/symfony/filesystem.git", + "reference": "9ff03da12d67649fbd1f34ca95951554624d0a16" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28", - "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/9ff03da12d67649fbd1f34ca95951554624d0a16", + "reference": "9ff03da12d67649fbd1f34ca95951554624d0a16", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.1", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "symfony/process": "^5.4|^6.4|^7.0" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.0-dev" - } - }, "autoload": { - "classmap": [ - "src/" + "psr-4": { + "Symfony\\Component\\Filesystem\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "Simple template engine.", - "homepage": "https://github.com/sebastianbergmann/php-text-template/", - "keywords": [ - "template" - ], + "description": "Provides basic utilities for the filesystem", + "homepage": "https://symfony.com", "support": { - "issues": "https://github.com/sebastianbergmann/php-text-template/issues", - "source": "https://github.com/sebastianbergmann/php-text-template/tree/2.0.4" + "source": "https://github.com/symfony/filesystem/tree/v6.4.43" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" } ], - "time": "2020-10-26T05:33:50+00:00" + "time": "2026-06-27T10:13:35+00:00" }, { - "name": "phpunit/php-timer", - "version": "5.0.3", + "name": "symfony/finder", + "version": "v6.4.42", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/php-timer.git", - "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2" + "url": "https://github.com/symfony/finder.git", + "reference": "0b73dac42493acbadbba644207a715b254e9b029" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2", - "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2", + "url": "https://api.github.com/repos/symfony/finder/zipball/0b73dac42493acbadbba644207a715b254e9b029", + "reference": "0b73dac42493acbadbba644207a715b254e9b029", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.1" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "symfony/filesystem": "^6.0|^7.0" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "5.0-dev" - } - }, "autoload": { - "classmap": [ - "src/" + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "Utility class for timing", - "homepage": "https://github.com/sebastianbergmann/php-timer/", - "keywords": [ - "timer" - ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", "support": { - "issues": "https://github.com/sebastianbergmann/php-timer/issues", - "source": "https://github.com/sebastianbergmann/php-timer/tree/5.0.3" + "source": "https://github.com/symfony/finder/tree/v6.4.42" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" } ], - "time": "2020-10-26T13:16:10+00:00" + "time": "2026-06-26T15:18:24+00:00" }, { - "name": "phpunit/phpunit", - "version": "9.6.35", + "name": "symfony/html-sanitizer", + "version": "v8.0.14", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "0edba2f3a0c48df3553cb9b640810b30df60302b" + "url": "https://github.com/symfony/html-sanitizer.git", + "reference": "122d0dfae08f7363ebc16aa6dd14f6b1f954b297" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/0edba2f3a0c48df3553cb9b640810b30df60302b", - "reference": "0edba2f3a0c48df3553cb9b640810b30df60302b", + "url": "https://api.github.com/repos/symfony/html-sanitizer/zipball/122d0dfae08f7363ebc16aa6dd14f6b1f954b297", + "reference": "122d0dfae08f7363ebc16aa6dd14f6b1f954b297", "shasum": "" }, "require": { - "doctrine/instantiator": "^1.5.0 || ^2", "ext-dom": "*", - "ext-filter": "*", - "ext-json": "*", - "ext-libxml": "*", - "ext-mbstring": "*", - "ext-xmlwriter": "*", - "myclabs/deep-copy": "^1.13.4", - "phar-io/manifest": "^2.0.4", - "phar-io/version": "^3.2.1", - "php": ">=7.3", - "phpunit/php-code-coverage": "^9.2.32", - "phpunit/php-file-iterator": "^3.0.6", - "phpunit/php-invoker": "^3.1.1", - "phpunit/php-text-template": "^2.0.4", - "phpunit/php-timer": "^5.0.3", - "sebastian/cli-parser": "^1.0.2", - "sebastian/code-unit": "^1.0.8", - "sebastian/comparator": "^4.0.10", - "sebastian/diff": "^4.0.6", - "sebastian/environment": "^5.1.5", - "sebastian/exporter": "^4.0.8", - "sebastian/global-state": "^5.0.8", - "sebastian/object-enumerator": "^4.0.4", - "sebastian/resource-operations": "^3.0.4", - "sebastian/type": "^3.2.1", - "sebastian/version": "^3.0.2" - }, - "suggest": { - "ext-soap": "To be able to generate mocks based on WSDL files", - "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + "league/uri": "^6.5|^7.0", + "php": ">=8.4" }, - "bin": [ - "phpunit" - ], "type": "library", - "extra": { - "branch-alias": { - "dev-master": "9.6-dev" - } - }, "autoload": { - "files": [ - "src/Framework/Assert/Functions.php" - ], - "classmap": [ - "src/" + "psr-4": { + "Symfony\\Component\\HtmlSanitizer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "name": "Titouan Galopin", + "email": "galopintitouan@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "The PHP Unit Testing framework.", - "homepage": "https://phpunit.de/", + "description": "Provides an object-oriented API to sanitize untrusted HTML input for safe insertion into a document's DOM.", + "homepage": "https://symfony.com", "keywords": [ - "phpunit", - "testing", - "xunit" + "Purifier", + "html", + "sanitizer" ], "support": { - "issues": "https://github.com/sebastianbergmann/phpunit/issues", - "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.35" + "source": "https://github.com/symfony/html-sanitizer/tree/v8.0.14" }, "funding": [ { - "url": "https://phpunit.de/sponsoring.html", - "type": "other" + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" } ], - "time": "2026-07-06T14:48:07+00:00" + "time": "2026-06-06T11:11:34+00:00" }, { - "name": "sebastian/cli-parser", - "version": "1.0.2", + "name": "symfony/http-client", + "version": "v8.0.16", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/cli-parser.git", - "reference": "2b56bea83a09de3ac06bb18b92f068e60cc6f50b" + "url": "https://github.com/symfony/http-client.git", + "reference": "3cc1f7586323cf514a97c139e3f475de4291c209" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/2b56bea83a09de3ac06bb18b92f068e60cc6f50b", - "reference": "2b56bea83a09de3ac06bb18b92f068e60cc6f50b", + "url": "https://api.github.com/repos/symfony/http-client/zipball/3cc1f7586323cf514a97c139e3f475de4291c209", + "reference": "3cc1f7586323cf514a97c139e3f475de4291c209", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.4", + "psr/log": "^1|^2|^3", + "symfony/http-client-contracts": "~3.4.4|^3.5.2", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "amphp/amp": "<3", + "php-http/discovery": "<1.15" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "1.0", + "symfony/http-client-implementation": "3.0" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "amphp/http-client": "^5.3.2", + "amphp/http-tunnel": "^2.0", + "guzzlehttp/promises": "^1.4|^2.0", + "nyholm/psr7": "^1.0", + "php-http/httplug": "^1.0|^2.0", + "psr/http-client": "^1.0", + "symfony/cache": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/rate-limiter": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0-dev" - } - }, "autoload": { - "classmap": [ - "src/" + "psr-4": { + "Symfony\\Component\\HttpClient\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "Library for parsing CLI options", - "homepage": "https://github.com/sebastianbergmann/cli-parser", + "description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously", + "homepage": "https://symfony.com", + "keywords": [ + "http" + ], "support": { - "issues": "https://github.com/sebastianbergmann/cli-parser/issues", - "source": "https://github.com/sebastianbergmann/cli-parser/tree/1.0.2" + "source": "https://github.com/symfony/http-client/tree/v8.0.16" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" } ], - "time": "2024-03-02T06:27:43+00:00" + "time": "2026-07-29T16:27:17+00:00" }, { - "name": "sebastian/code-unit", - "version": "1.0.8", + "name": "symfony/polyfill-ctype", + "version": "v1.37.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/code-unit.git", - "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120" + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/1fc9f64c0927627ef78ba436c9b17d967e68e120", - "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=7.2" }, - "require-dev": { - "phpunit/phpunit": "^9.3" + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" }, "type": "library", "extra": { - "branch-alias": { - "dev-master": "1.0-dev" + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { - "classmap": [ - "src/" - ] + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "Collection of value objects that represent the PHP code units", - "homepage": "https://github.com/sebastianbergmann/code-unit", + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], "support": { - "issues": "https://github.com/sebastianbergmann/code-unit/issues", - "source": "https://github.com/sebastianbergmann/code-unit/tree/1.0.8" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" } ], - "time": "2020-10-26T13:08:54+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { - "name": "sebastian/code-unit-reverse-lookup", - "version": "2.0.3", + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.41.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", - "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5" + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5", - "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=7.2" }, - "require-dev": { - "phpunit/phpunit": "^9.3" + "suggest": { + "ext-intl": "For best performance" }, "type": "library", "extra": { - "branch-alias": { - "dev-master": "2.0-dev" + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { - "classmap": [ - "src/" - ] + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "Looks up which function or method a line of code belongs to", - "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], "support": { - "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", - "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/2.0.3" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.41.0" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" } ], - "time": "2020-09-28T05:30:19+00:00" + "time": "2026-07-28T08:25:59+00:00" }, { - "name": "sebastian/comparator", - "version": "4.0.10", + "name": "symfony/polyfill-intl-idn", + "version": "v1.38.1", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d" + "url": "https://github.com/symfony/polyfill-intl-idn.git", + "reference": "dc21118016c039a66235cf93d96b435ffb282412" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/e4df00b9b3571187db2831ae9aada2c6efbd715d", - "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/dc21118016c039a66235cf93d96b435ffb282412", + "reference": "dc21118016c039a66235cf93d96b435ffb282412", "shasum": "" }, "require": { - "php": ">=7.3", - "sebastian/diff": "^4.0", - "sebastian/exporter": "^4.0" + "php": ">=7.2", + "symfony/polyfill-intl-normalizer": "^1.10" }, - "require-dev": { - "phpunit/phpunit": "^9.3" + "suggest": { + "ext-intl": "For best performance" }, "type": "library", "extra": { - "branch-alias": { - "dev-master": "4.0-dev" + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { - "classmap": [ - "src/" - ] + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Idn\\": "" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - }, - { - "name": "Jeff Welch", - "email": "whatthejeff@gmail.com" + "name": "Laurent Bassin", + "email": "laurent@bassin.info" }, { - "name": "Volker Dusch", - "email": "github@wallbash.com" + "name": "Trevor Rowbotham", + "email": "trevor.rowbotham@pm.me" }, { - "name": "Bernhard Schussek", - "email": "bschussek@2bepublished.at" + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "Provides the functionality to compare PHP values for equality", - "homepage": "https://github.com/sebastianbergmann/comparator", + "description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions", + "homepage": "https://symfony.com", "keywords": [ - "comparator", - "compare", - "equality" + "compatibility", + "idn", + "intl", + "polyfill", + "portable", + "shim" ], "support": { - "issues": "https://github.com/sebastianbergmann/comparator/issues", - "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.10" + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.38.1" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", - "type": "github" + "url": "https://symfony.com/sponsor", + "type": "custom" }, { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" + "url": "https://github.com/fabpot", + "type": "github" }, { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" + "url": "https://github.com/nicolas-grekas", + "type": "github" }, { - "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2026-01-24T09:22:56+00:00" + "time": "2026-05-25T15:22:23+00:00" }, { - "name": "sebastian/complexity", - "version": "2.0.3", + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.38.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/complexity.git", - "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a" + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/25f207c40d62b8b7aa32f5ab026c53561964053a", - "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b", "shasum": "" }, "require": { - "nikic/php-parser": "^4.18 || ^5.0", - "php": ">=7.3" + "php": ">=7.2" }, - "require-dev": { - "phpunit/phpunit": "^9.3" + "suggest": { + "ext-intl": "For best performance" }, "type": "library", "extra": { - "branch-alias": { - "dev-master": "2.0-dev" + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, "classmap": [ - "src/" + "Resources/stubs" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "Library for calculating the complexity of PHP code units", - "homepage": "https://github.com/sebastianbergmann/complexity", + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], "support": { - "issues": "https://github.com/sebastianbergmann/complexity/issues", - "source": "https://github.com/sebastianbergmann/complexity/tree/2.0.3" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" } ], - "time": "2023-12-22T06:19:30+00:00" + "time": "2026-05-25T13:48:31+00:00" }, { - "name": "sebastian/diff", - "version": "4.0.6", + "name": "symfony/polyfill-mbstring", + "version": "v1.38.2", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "ba01945089c3a293b01ba9badc29ad55b106b0bc" + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/ba01945089c3a293b01ba9badc29ad55b106b0bc", - "reference": "ba01945089c3a293b01ba9badc29ad55b106b0bc", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", "shasum": "" }, "require": { - "php": ">=7.3" + "ext-iconv": "*", + "php": ">=7.2" }, - "require-dev": { - "phpunit/phpunit": "^9.3", - "symfony/process": "^4.2 || ^5" + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" }, "type": "library", "extra": { - "branch-alias": { - "dev-master": "4.0-dev" + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { - "classmap": [ - "src/" - ] + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { - "name": "Kore Nordmann", - "email": "mail@kore-nordmann.de" + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "Diff implementation", - "homepage": "https://github.com/sebastianbergmann/diff", + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", "keywords": [ - "diff", - "udiff", - "unidiff", - "unified diff" + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" ], "support": { - "issues": "https://github.com/sebastianbergmann/diff/issues", - "source": "https://github.com/sebastianbergmann/diff/tree/4.0.6" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" } ], - "time": "2024-03-02T06:30:58+00:00" + "time": "2026-05-27T06:59:30+00:00" }, { - "name": "sebastian/environment", - "version": "5.1.5", + "name": "symfony/polyfill-php80", + "version": "v1.37.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed" + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/830c43a844f1f8d5b7a1f6d6076b784454d8b7ed", - "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", "shasum": "" }, "require": { - "php": ">=7.3" - }, - "require-dev": { - "phpunit/phpunit": "^9.3" - }, - "suggest": { - "ext-posix": "*" + "php": ">=7.2" }, "type": "library", "extra": { - "branch-alias": { - "dev-master": "5.1-dev" + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, "classmap": [ - "src/" + "Resources/stubs" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "Provides functionality to handle HHVM/PHP environments", - "homepage": "http://www.github.com/sebastianbergmann/environment", + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", "keywords": [ - "Xdebug", - "environment", - "hhvm" + "compatibility", + "polyfill", + "portable", + "shim" ], "support": { - "issues": "https://github.com/sebastianbergmann/environment/issues", - "source": "https://github.com/sebastianbergmann/environment/tree/5.1.5" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" } ], - "time": "2023-02-03T06:03:51+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { - "name": "sebastian/exporter", - "version": "4.0.8", + "name": "symfony/polyfill-php84", + "version": "v1.38.1", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "14c6ba52f95a36c3d27c835d65efc7123c446e8c" + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/14c6ba52f95a36c3d27c835d65efc7123c446e8c", - "reference": "14c6ba52f95a36c3d27c835d65efc7123c446e8c", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", "shasum": "" }, "require": { - "php": ">=7.3", - "sebastian/recursion-context": "^4.0" - }, - "require-dev": { - "ext-mbstring": "*", - "phpunit/phpunit": "^9.3" + "php": ">=7.2" }, "type": "library", "extra": { - "branch-alias": { - "dev-master": "4.0-dev" + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php84\\": "" + }, "classmap": [ - "src/" + "Resources/stubs" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - }, - { - "name": "Jeff Welch", - "email": "whatthejeff@gmail.com" - }, - { - "name": "Volker Dusch", - "email": "github@wallbash.com" - }, - { - "name": "Adam Harvey", - "email": "aharvey@php.net" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { - "name": "Bernhard Schussek", - "email": "bschussek@gmail.com" + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "Provides the functionality to export PHP variables for visualization", - "homepage": "https://www.github.com/sebastianbergmann/exporter", + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", + "homepage": "https://symfony.com", "keywords": [ - "export", - "exporter" + "compatibility", + "polyfill", + "portable", + "shim" ], "support": { - "issues": "https://github.com/sebastianbergmann/exporter/issues", - "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.8" + "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.1" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", - "type": "github" + "url": "https://symfony.com/sponsor", + "type": "custom" }, { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" + "url": "https://github.com/fabpot", + "type": "github" }, { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" + "url": "https://github.com/nicolas-grekas", + "type": "github" }, { - "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-09-24T06:03:27+00:00" + "time": "2026-05-26T12:51:13+00:00" }, { - "name": "sebastian/global-state", - "version": "5.0.8", + "name": "symfony/process", + "version": "v6.4.41", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/global-state.git", - "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6" + "url": "https://github.com/symfony/process.git", + "reference": "c8fc09bdfe9fde9aaa89b415a4477feaccec16a7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/b6781316bdcd28260904e7cc18ec983d0d2ef4f6", - "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6", + "url": "https://api.github.com/repos/symfony/process/zipball/c8fc09bdfe9fde9aaa89b415a4477feaccec16a7", + "reference": "c8fc09bdfe9fde9aaa89b415a4477feaccec16a7", "shasum": "" }, "require": { - "php": ">=7.3", - "sebastian/object-reflector": "^2.0", - "sebastian/recursion-context": "^4.0" - }, - "require-dev": { - "ext-dom": "*", - "phpunit/phpunit": "^9.3" - }, - "suggest": { - "ext-uopz": "*" + "php": ">=8.1" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "5.0-dev" - } - }, "autoload": { - "classmap": [ - "src/" + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "Snapshotting of global state", - "homepage": "http://www.github.com/sebastianbergmann/global-state", - "keywords": [ - "global state" - ], + "description": "Executes commands in sub-processes", + "homepage": "https://symfony.com", "support": { - "issues": "https://github.com/sebastianbergmann/global-state/issues", - "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.8" + "source": "https://github.com/symfony/process/tree/v6.4.41" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", - "type": "github" + "url": "https://symfony.com/sponsor", + "type": "custom" }, { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" + "url": "https://github.com/fabpot", + "type": "github" }, { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" + "url": "https://github.com/nicolas-grekas", + "type": "github" }, { - "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state", + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-08-10T07:10:35+00:00" + "time": "2026-05-23T13:47:21+00:00" }, { - "name": "sebastian/lines-of-code", - "version": "1.0.4", + "name": "symfony/property-access", + "version": "v7.4.8", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/lines-of-code.git", - "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5" + "url": "https://github.com/symfony/property-access.git", + "reference": "b7dad9dae8b8a47ef7ecc76c8569e7d8c7d90cfc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/e1e4a170560925c26d424b6a03aed157e7dcc5c5", - "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5", + "url": "https://api.github.com/repos/symfony/property-access/zipball/b7dad9dae8b8a47ef7ecc76c8569e7d8c7d90cfc", + "reference": "b7dad9dae8b8a47ef7ecc76c8569e7d8c7d90cfc", "shasum": "" }, "require": { - "nikic/php-parser": "^4.18 || ^5.0", - "php": ">=7.3" + "php": ">=8.2", + "symfony/property-info": "^6.4.32|~7.3.10|^7.4.4|^8.0.4" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "symfony/cache": "^6.4|^7.0|^8.0", + "symfony/var-exporter": "^6.4.1|^7.0.1|^8.0" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0-dev" - } - }, "autoload": { - "classmap": [ - "src/" + "psr-4": { + "Symfony\\Component\\PropertyAccess\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "Library for counting the lines of code in PHP source code", - "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "description": "Provides functions to read and write from/to an object or array using a simple string notation", + "homepage": "https://symfony.com", + "keywords": [ + "access", + "array", + "extraction", + "index", + "injection", + "object", + "property", + "property-path", + "reflection" + ], "support": { - "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", - "source": "https://github.com/sebastianbergmann/lines-of-code/tree/1.0.4" + "source": "https://github.com/symfony/property-access/tree/v7.4.8" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" } ], - "time": "2023-12-22T06:20:34+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { - "name": "sebastian/object-enumerator", - "version": "4.0.4", + "name": "symfony/property-info", + "version": "v7.4.15", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/object-enumerator.git", - "reference": "5c9eeac41b290a3712d88851518825ad78f45c71" + "url": "https://github.com/symfony/property-info.git", + "reference": "fce3f4d9cfeb4ddc674c357b5a4c3a23ccf408ad" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/5c9eeac41b290a3712d88851518825ad78f45c71", - "reference": "5c9eeac41b290a3712d88851518825ad78f45c71", + "url": "https://api.github.com/repos/symfony/property-info/zipball/fce3f4d9cfeb4ddc674c357b5a4c3a23ccf408ad", + "reference": "fce3f4d9cfeb4ddc674c357b5a4c3a23ccf408ad", "shasum": "" }, "require": { - "php": ">=7.3", - "sebastian/object-reflector": "^2.0", - "sebastian/recursion-context": "^4.0" + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/string": "^6.4|^7.0|^8.0", + "symfony/type-info": "^7.4.7|^8.0.7" + }, + "conflict": { + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/cache": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/serializer": "<6.4" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "phpstan/phpdoc-parser": "^1.0|^2.0", + "symfony/cache": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "4.0-dev" - } - }, "autoload": { - "classmap": [ - "src/" + "psr-4": { + "Symfony\\Component\\PropertyInfo\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" + "name": "Kévin Dunglas", + "email": "dunglas@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "Traverses array structures and object graphs to enumerate all referenced objects", - "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "description": "Extracts information about PHP class' properties using metadata of popular sources", + "homepage": "https://symfony.com", + "keywords": [ + "doctrine", + "phpdoc", + "property", + "symfony", + "type", + "validator" + ], "support": { - "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", - "source": "https://github.com/sebastianbergmann/object-enumerator/tree/4.0.4" + "source": "https://github.com/symfony/property-info/tree/v7.4.15" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" } ], - "time": "2020-10-26T13:12:34+00:00" + "time": "2026-07-28T07:09:44+00:00" }, { - "name": "sebastian/object-reflector", - "version": "2.0.4", + "name": "symfony/routing", + "version": "v6.4.43", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/object-reflector.git", - "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7" + "url": "https://github.com/symfony/routing.git", + "reference": "f8dedd42e7a511906aa54be6cebffbbe826ff20e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", - "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", + "url": "https://api.github.com/repos/symfony/routing/zipball/f8dedd42e7a511906aa54be6cebffbbe826ff20e", + "reference": "f8dedd42e7a511906aa54be6cebffbbe826ff20e", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "doctrine/annotations": "<1.12", + "symfony/config": "<6.2", + "symfony/dependency-injection": "<5.4", + "symfony/yaml": "<5.4" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "doctrine/annotations": "^1.12|^2", + "psr/log": "^1|^2|^3", + "symfony/config": "^6.2|^7.0", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/expression-language": "^5.4|^6.0|^7.0", + "symfony/http-foundation": "^5.4|^6.0|^7.0", + "symfony/yaml": "^5.4|^6.0|^7.0" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.0-dev" - } - }, "autoload": { - "classmap": [ - "src/" + "psr-4": { + "Symfony\\Component\\Routing\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "Allows reflection of object attributes, including inherited and non-public ones", - "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "description": "Maps an HTTP request to a set of configuration variables", + "homepage": "https://symfony.com", + "keywords": [ + "router", + "routing", + "uri", + "url" + ], "support": { - "issues": "https://github.com/sebastianbergmann/object-reflector/issues", - "source": "https://github.com/sebastianbergmann/object-reflector/tree/2.0.4" + "source": "https://github.com/symfony/routing/tree/v6.4.43" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" } ], - "time": "2020-10-26T13:14:26+00:00" + "time": "2026-07-21T13:51:56+00:00" }, { - "name": "sebastian/recursion-context", - "version": "4.0.6", + "name": "symfony/stopwatch", + "version": "v6.4.24", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/recursion-context.git", - "reference": "539c6691e0623af6dc6f9c20384c120f963465a0" + "url": "https://github.com/symfony/stopwatch.git", + "reference": "b67e94e06a05d9572c2fa354483b3e13e3cb1898" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/539c6691e0623af6dc6f9c20384c120f963465a0", - "reference": "539c6691e0623af6dc6f9c20384c120f963465a0", + "url": "https://api.github.com/repos/symfony/stopwatch/zipball/b67e94e06a05d9572c2fa354483b3e13e3cb1898", + "reference": "b67e94e06a05d9572c2fa354483b3e13e3cb1898", "shasum": "" }, "require": { - "php": ">=7.3" - }, - "require-dev": { - "phpunit/phpunit": "^9.3" + "php": ">=8.1", + "symfony/service-contracts": "^2.5|^3" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "4.0-dev" - } - }, "autoload": { - "classmap": [ - "src/" + "psr-4": { + "Symfony\\Component\\Stopwatch\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - }, - { - "name": "Jeff Welch", - "email": "whatthejeff@gmail.com" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { - "name": "Adam Harvey", - "email": "aharvey@php.net" + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "Provides functionality to recursively process PHP variables", - "homepage": "https://github.com/sebastianbergmann/recursion-context", + "description": "Provides a way to profile code", + "homepage": "https://symfony.com", "support": { - "issues": "https://github.com/sebastianbergmann/recursion-context/issues", - "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.6" + "source": "https://github.com/symfony/stopwatch/tree/v6.4.24" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", - "type": "github" + "url": "https://symfony.com/sponsor", + "type": "custom" }, { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" + "url": "https://github.com/fabpot", + "type": "github" }, { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" + "url": "https://github.com/nicolas-grekas", + "type": "github" }, { - "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-08-10T06:57:39+00:00" + "time": "2025-07-10T08:14:14+00:00" }, { - "name": "sebastian/resource-operations", - "version": "3.0.4", + "name": "symfony/string", + "version": "v6.4.43", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/resource-operations.git", - "reference": "05d5692a7993ecccd56a03e40cd7e5b09b1d404e" + "url": "https://github.com/symfony/string.git", + "reference": "2a8d515c3eaa5d33cf76d5fa277cdadd0a4e5b49" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/05d5692a7993ecccd56a03e40cd7e5b09b1d404e", - "reference": "05d5692a7993ecccd56a03e40cd7e5b09b1d404e", + "url": "https://api.github.com/repos/symfony/string/zipball/2a8d515c3eaa5d33cf76d5fa277cdadd0a4e5b49", + "reference": "2a8d515c3eaa5d33cf76d5fa277cdadd0a4e5b49", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.1", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-intl-grapheme": "~1.0", + "symfony/polyfill-intl-normalizer": "~1.0", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" }, "require-dev": { - "phpunit/phpunit": "^9.0" + "symfony/http-client": "^5.4|^6.0|^7.0", + "symfony/intl": "^6.2|^7.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^5.4|^6.0|^7.0" }, "type": "library", - "extra": { - "branch-alias": { - "dev-main": "3.0-dev" - } - }, "autoload": { - "classmap": [ - "src/" + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "Provides a list of PHP built-in functions that operate on resources", - "homepage": "https://www.github.com/sebastianbergmann/resource-operations", + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], "support": { - "source": "https://github.com/sebastianbergmann/resource-operations/tree/3.0.4" + "source": "https://github.com/symfony/string/tree/v6.4.43" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" } ], - "time": "2024-03-14T16:00:52+00:00" + "time": "2026-07-28T07:28:15+00:00" }, { - "name": "sebastian/type", - "version": "3.2.1", + "name": "symfony/type-info", + "version": "v8.0.9", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/type.git", - "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7" + "url": "https://github.com/symfony/type-info.git", + "reference": "08723aceb8c3271e8cb3db8b2565728b0c88e866" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7", - "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7", + "url": "https://api.github.com/repos/symfony/type-info/zipball/08723aceb8c3271e8cb3db8b2565728b0c88e866", + "reference": "08723aceb8c3271e8cb3db8b2565728b0c88e866", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.4", + "psr/container": "^1.1|^2.0" + }, + "conflict": { + "phpstan/phpdoc-parser": "<1.30" }, "require-dev": { - "phpunit/phpunit": "^9.5" + "phpstan/phpdoc-parser": "^1.30|^2.0" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.2-dev" - } - }, "autoload": { - "classmap": [ - "src/" + "psr-4": { + "Symfony\\Component\\TypeInfo\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "name": "Mathias Arlaud", + "email": "mathias.arlaud@gmail.com" + }, + { + "name": "Baptiste LEDUC", + "email": "baptiste.leduc@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "Collection of value objects that represent the types of the PHP type system", - "homepage": "https://github.com/sebastianbergmann/type", + "description": "Extracts PHP types information.", + "homepage": "https://symfony.com", + "keywords": [ + "PHPStan", + "phpdoc", + "symfony", + "type" + ], "support": { - "issues": "https://github.com/sebastianbergmann/type/issues", - "source": "https://github.com/sebastianbergmann/type/tree/3.2.1" + "source": "https://github.com/symfony/type-info/tree/v8.0.9" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" } ], - "time": "2023-02-03T06:13:03+00:00" + "time": "2026-04-29T15:02:55+00:00" }, { - "name": "sebastian/version", - "version": "3.0.2", + "name": "symfony/var-exporter", + "version": "v7.4.14", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/version.git", - "reference": "c6c1022351a901512170118436c764e473f6de8c" + "url": "https://github.com/symfony/var-exporter.git", + "reference": "0118811b1d59f323bf131250b3fb919febfece28" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c6c1022351a901512170118436c764e473f6de8c", - "reference": "c6c1022351a901512170118436c764e473f6de8c", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/0118811b1d59f323bf131250b3fb919febfece28", + "reference": "0118811b1d59f323bf131250b3fb919febfece28", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.0-dev" - } + "require-dev": { + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" }, + "type": "library", "autoload": { - "classmap": [ - "src/" + "psr-4": { + "Symfony\\Component\\VarExporter\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "Library that helps with managing the version number of Git-hosted PHP projects", - "homepage": "https://github.com/sebastianbergmann/version", + "description": "Allows exporting any serializable PHP data structure to plain PHP code", + "homepage": "https://symfony.com", + "keywords": [ + "clone", + "construct", + "export", + "hydrate", + "instantiate", + "lazy-loading", + "proxy", + "serialize" + ], "support": { - "issues": "https://github.com/sebastianbergmann/version/issues", - "source": "https://github.com/sebastianbergmann/version/tree/3.0.2" + "source": "https://github.com/symfony/var-exporter/tree/v7.4.14" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" } ], - "time": "2020-09-28T06:39:44+00:00" + "time": "2026-06-27T08:41:53+00:00" }, { - "name": "squizlabs/php_codesniffer", - "version": "3.13.5", + "name": "symfony/yaml", + "version": "v6.4.43", "source": { "type": "git", - "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", - "reference": "0ca86845ce43291e8f5692c7356fccf3bcf02bf4" + "url": "https://github.com/symfony/yaml.git", + "reference": "d2ee2daa59be8cd0864835ea918ea209149796a3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/0ca86845ce43291e8f5692c7356fccf3bcf02bf4", - "reference": "0ca86845ce43291e8f5692c7356fccf3bcf02bf4", + "url": "https://api.github.com/repos/symfony/yaml/zipball/d2ee2daa59be8cd0864835ea918ea209149796a3", + "reference": "d2ee2daa59be8cd0864835ea918ea209149796a3", "shasum": "" }, "require": { - "ext-simplexml": "*", - "ext-tokenizer": "*", - "ext-xmlwriter": "*", - "php": ">=5.4.0" + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/console": "<5.4" }, "require-dev": { - "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4" + "symfony/console": "^5.4|^6.0|^7.0" }, "bin": [ - "bin/phpcbf", - "bin/phpcs" + "Resources/bin/yaml-lint" ], "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Yaml\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Greg Sherwood", - "role": "Former lead" - }, - { - "name": "Juliette Reinders Folmer", - "role": "Current lead" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { - "name": "Contributors", - "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer/graphs/contributors" + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "PHP_CodeSniffer tokenizes PHP, JavaScript and CSS files and detects violations of a defined set of coding standards.", - "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer", - "keywords": [ - "phpcs", - "standards", - "static analysis" - ], + "description": "Loads and dumps YAML files", + "homepage": "https://symfony.com", "support": { - "issues": "https://github.com/PHPCSStandards/PHP_CodeSniffer/issues", - "security": "https://github.com/PHPCSStandards/PHP_CodeSniffer/security/policy", - "source": "https://github.com/PHPCSStandards/PHP_CodeSniffer", - "wiki": "https://github.com/PHPCSStandards/PHP_CodeSniffer/wiki" + "source": "https://github.com/symfony/yaml/tree/v6.4.43" }, "funding": [ { - "url": "https://github.com/PHPCSStandards", - "type": "github" + "url": "https://symfony.com/sponsor", + "type": "custom" }, { - "url": "https://github.com/jrfnl", + "url": "https://github.com/fabpot", "type": "github" }, { - "url": "https://opencollective.com/php_codesniffer", - "type": "open_collective" + "url": "https://github.com/nicolas-grekas", + "type": "github" }, { - "url": "https://thanks.dev/u/gh/phpcsstandards", - "type": "thanks_dev" + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" } ], - "time": "2025-11-04T16:30:35+00:00" + "time": "2026-07-20T15:18:49+00:00" }, { "name": "szepeviktor/phpstan-wordpress", @@ -2692,6 +7745,211 @@ ], "time": "2025-11-17T20:03:58+00:00" }, + { + "name": "twig/string-extra", + "version": "v3.24.0", + "source": { + "type": "git", + "url": "https://github.com/twigphp/string-extra.git", + "reference": "6ec8f2e8ca9b2193221a02cb599dc92c36384368" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/twigphp/string-extra/zipball/6ec8f2e8ca9b2193221a02cb599dc92c36384368", + "reference": "6ec8f2e8ca9b2193221a02cb599dc92c36384368", + "shasum": "" + }, + "require": { + "php": ">=8.1.0", + "symfony/string": "^5.4|^6.4|^7.0|^8.0", + "symfony/translation-contracts": "^1.1|^2|^3", + "twig/twig": "^3.13|^4.0" + }, + "require-dev": { + "symfony/phpunit-bridge": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Twig\\Extra\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com", + "homepage": "http://fabien.potencier.org", + "role": "Lead Developer" + } + ], + "description": "A Twig extension for Symfony String", + "homepage": "https://twig.symfony.com", + "keywords": [ + "html", + "string", + "twig", + "unicode" + ], + "support": { + "source": "https://github.com/twigphp/string-extra/tree/v3.24.0" + }, + "funding": [ + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/twig/twig", + "type": "tidelift" + } + ], + "time": "2025-12-02T14:45:16+00:00" + }, + { + "name": "twig/twig", + "version": "v3.28.0", + "source": { + "type": "git", + "url": "https://github.com/twigphp/Twig.git", + "reference": "597c12ed286fb9d1701a36684ce6e0cbe28ebc8b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/597c12ed286fb9d1701a36684ce6e0cbe28ebc8b", + "reference": "597c12ed286fb9d1701a36684ce6e0cbe28ebc8b", + "shasum": "" + }, + "require": { + "php": ">=8.1.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-mbstring": "^1.3" + }, + "require-dev": { + "php-cs-fixer/shim": "^3.0@stable", + "phpstan/phpstan": "^2.0@stable", + "psr/container": "^1.0|^2.0", + "symfony/phpunit-bridge": "^5.4.9|^6.4|^7.0" + }, + "type": "library", + "autoload": { + "files": [ + "src/Resources/core.php", + "src/Resources/debug.php", + "src/Resources/escaper.php", + "src/Resources/string_loader.php" + ], + "psr-4": { + "Twig\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com", + "homepage": "http://fabien.potencier.org", + "role": "Lead Developer" + }, + { + "name": "Twig Team", + "role": "Contributors" + }, + { + "name": "Armin Ronacher", + "email": "armin.ronacher@active-4.com", + "role": "Project Founder" + } + ], + "description": "Twig, the flexible, fast, and secure template language for PHP", + "homepage": "https://twig.symfony.com", + "keywords": [ + "templating" + ], + "support": { + "issues": "https://github.com/twigphp/Twig/issues", + "source": "https://github.com/twigphp/Twig/tree/v3.28.0" + }, + "funding": [ + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/twig/twig", + "type": "tidelift" + } + ], + "time": "2026-07-03T20:44:34+00:00" + }, + { + "name": "webmozart/assert", + "version": "1.12.1", + "source": { + "type": "git", + "url": "https://github.com/webmozarts/assert.git", + "reference": "9be6926d8b485f55b9229203f962b51ed377ba68" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/9be6926d8b485f55b9229203f962b51ed377ba68", + "reference": "9be6926d8b485f55b9229203f962b51ed377ba68", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-date": "*", + "ext-filter": "*", + "php": "^7.2 || ^8.0" + }, + "suggest": { + "ext-intl": "", + "ext-simplexml": "", + "ext-spl": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.10-dev" + } + }, + "autoload": { + "psr-4": { + "Webmozart\\Assert\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Assertions to validate method input/output with nice error messages.", + "keywords": [ + "assert", + "check", + "validate" + ], + "support": { + "issues": "https://github.com/webmozarts/assert/issues", + "source": "https://github.com/webmozarts/assert/tree/1.12.1" + }, + "time": "2025-10-29T15:56:20+00:00" + }, { "name": "wp-coding-standards/wpcs", "version": "3.3.0", @@ -2832,7 +8090,7 @@ }, "platform-dev": {}, "platform-overrides": { - "php": "7.4" + "php": "8.4" }, "plugin-api-version": "2.9.0" } diff --git a/docs/.vitepress/config.mjs b/docs/.vitepress/config.mjs new file mode 100644 index 00000000..99bad72a --- /dev/null +++ b/docs/.vitepress/config.mjs @@ -0,0 +1,96 @@ +import { defineConfig } from 'vitepress' + +export default defineConfig({ + title: 'Saltus Framework', + description: 'WordPress plugin development framework for Custom Post Types', + lang: 'en-US', + base: '/', + + ignoreDeadLinks: [ + /^\/roadmap/, + /^\/current/, + /^\/downloads\//, + ], + + head: [ + ['link', { rel: 'icon', href: '/favicon.ico' }], + ], + + themeConfig: { + logo: '/logo.png', + logoLink: 'https://saltus.dev', + + nav: [ + { text: 'Getting Started', link: '/getting-started' }, + { + text: 'Guides', + items: [ + { text: 'Features', link: '/guides/features' }, + { text: 'Blocks', link: '/guides/blocks' }, + { text: 'WP-CLI', link: '/guides/wp-cli' }, + { text: 'Architecture', link: '/guides/architecture' }, + { text: 'Build & Setup', link: '/guides/build' }, + ], + }, + { + text: 'MCP/Abilities', + link: '/mcp/index', + }, + { + text: 'API Reference', + link: '/api/index', + }, + ], + + sidebar: { + '/getting-started': [ + { + text: 'Getting Started', + items: [ + { text: 'Quick Start', link: '/getting-started' }, + ], + }, + ], + '/guides/': [ + { + text: 'Guides', + items: [ + { text: 'Features', link: '/guides/features' }, + { text: 'Blocks', link: '/guides/blocks' }, + { text: 'WP-CLI', link: '/guides/wp-cli' }, + { text: 'Architecture', link: '/guides/architecture' }, + { text: 'Build & Setup', link: '/guides/build' }, + ], + }, + ], + '/mcp/': [ + { + text: 'MCP/Abilities', + items: [ + { text: 'Overview', link: '/mcp/index' }, + { text: 'Abilities Reference', link: '/mcp/abilities' }, + { text: 'Client Integration', link: '/mcp/clients' }, + { text: 'Saltus MCP Skill', link: '/mcp/skill' }, + ], + }, + ], + }, + + editLink: { + pattern: 'https://github.com/SaltusDev/saltus-framework/edit/main/docs/:path', + }, + + socialLinks: [ + { icon: 'github', link: 'https://github.com/SaltusDev/saltus-framework' }, + ], + + footer: { + message: 'GPL-3.0 License', + copyright: 'Copyright Saltus Plugin Framework', + }, + + search: { + provider: 'local', + }, + }, +}) diff --git a/docs/.vitepress/theme/components/VPNavBarTitle.vue b/docs/.vitepress/theme/components/VPNavBarTitle.vue new file mode 100644 index 00000000..738e3dcd --- /dev/null +++ b/docs/.vitepress/theme/components/VPNavBarTitle.vue @@ -0,0 +1,107 @@ + + + + + diff --git a/docs/.vitepress/theme/index.js b/docs/.vitepress/theme/index.js new file mode 100644 index 00000000..b84e8a8b --- /dev/null +++ b/docs/.vitepress/theme/index.js @@ -0,0 +1,9 @@ +import DefaultTheme from 'vitepress/theme' +import VPNavBarTitle from './components/VPNavBarTitle.vue' + +export default { + extends: DefaultTheme, + enhanceApp({ app }) { + app.component('VPNavBarTitle', VPNavBarTitle) + } +} diff --git a/docs/BUILD.md b/docs/BUILD.md index 96391fcd..9b77b66f 100644 --- a/docs/BUILD.md +++ b/docs/BUILD.md @@ -5,7 +5,7 @@ - Composer ## Installation -Run `composer install` to install dependencies. +Run `composer install` to install dependencies. The composer dev platform is pinned to PHP 8.4 (required by `phpdocumentor/phpdocumentor` for API docs generation); runtime code still targets PHP 7.4+. ## Running Tests and Linting The project defines several Composer scripts for quality assurance: @@ -17,6 +17,11 @@ The project defines several Composer scripts for quality assurance: Run via `composer phpstan` - **Linting (PHPCS):** `./vendor/bin/phpcs --standard=phpcs.xml` Run via `composer phpcs` +- **API docs (phpDocumentor):** `composer docs:api` (uses `phpDocumentor.phar -c phpdoc.dist.xml`) +- **MCP ability docs:** `composer docs:mcp` +- **WP-CLI command docs:** `composer docs:wpcli` +- **All generated docs:** `composer docs:all` +- **VitePress site:** `npm run docs:build` ## Patching Codestar Framework If the Codestar Framework is updated, re-apply the custom patches: diff --git a/docs/CONTEXT.md b/docs/CONTEXT.md index 4fb8a544..c49d893b 100644 --- a/docs/CONTEXT.md +++ b/docs/CONTEXT.md @@ -49,6 +49,15 @@ if ( class_exists( \Saltus\WP\Framework\Core::class ) ) { - **Decision:** Refactoring these paths is debt reduction around runtime-critical behavior, not cosmetic cleanup. The goal is to reduce regression risk, improve standards compliance, and prepare the code for focused unit and integration tests. - **Compatibility:** Existing Saltus plugins should continue working while internals become safer to maintain, easier to type-check, and easier to test. +### 7. Model-Driven Blocks +- **Purpose:** Provide editor-native list and single views without duplicating post data into block content. +- **Decision:** Blocks use runtime Block API v3 metadata arrays and one shared unbundled editor script. Model metadata supplies selectable field paths; dynamic PHP rendering reads current post values. +- **Templates:** Resolution order is consuming-plugin config, active theme override, then framework defaults under `templates/blocks/`. + +### 8. WP-CLI Parity +- **Purpose:** Make the WordPress-native ability surface available to operators and scripts without HTTP dispatch. +- **Decision:** Eight `wp saltus` command groups call WordPress APIs and shared framework services directly. `CommandCatalog` is the authoritative 19-command parity map and generates `docs/guides/wp-cli.md`. + ## Naming & Standards - **Quality Assurance:** PHP CodeSniffer (PHPCS) ensures adherence to WordPress coding standards, while PHPStan handles static analysis to catch type errors and logical bugs early. - **Testing:** Automated tests are powered by PHPUnit, ensuring framework stability across different WordPress and PHP versions. diff --git a/docs/CURRENT.md b/docs/CURRENT.md index 2ce332a9..5c1147b2 100644 --- a/docs/CURRENT.md +++ b/docs/CURRENT.md @@ -1,18 +1,23 @@ # Current: Live Working State ## Working -- Phase 5A: Block Editor integration — Blocks feature service, per-CPT block registration, default templates @since 2026-07-06 -- Phase 5D: Documentation — fill README placeholders, add model examples @since 2026-07-05 +- Phase 5C: Frontend rendering — shortcodes, templates, meta field exposure @since 2026-07-31 ## Next -- Phase 5B: WP-CLI tools — 7 grouped command classes mapping every MCP tool -- Phase 5C: Frontend rendering — shortcodes, templates, meta field exposure -- Phase 5D: New doc files (BLOCKS.md, WPCLI.md, FRONTEND.md, FEATURES.md) +- Phase 6A: Context Control Center — ai_context schema, AiContextProvider, get_context tool +- Phase 6B: Editorial Review Queue — proposals, review UI, audit integration +- Phase 6C: Inside-Admin AI Assistants — meta box assistants, REST endpoints +- Phase 7: Advanced DI container hardening — ReflectionInstantiator autowiring ## Blocked - None ## Recent Changes +- Phase 5A delivered: runtime Block API v3 list/single blocks, shared editor assets, dynamic render templates, REST discovery, and `list_block_models` MCP ability @since 2026-07-31 +- Phase 5B delivered: eight `wp saltus` command groups provide parity with all 19 abilities, direct shared-service execution, table/JSON/YAML output, JSON file input, tests, and generated command docs @since 2026-07-31 +- Phase 5D delivered for implemented features: README placeholders replaced, model/feature references corrected, Blocks and WP-CLI guides added to VitePress, and MCP/WP-CLI docs regenerated for 19 tools; frontend guide remains deferred with 5C @since 2026-07-31 +- Docs infrastructure: VitePress site scaffolded at `docs/.vitepress/`, phpDocumentor config at `phpdoc.dist.xml`, `.github/workflows/docs.yml` for auto-build + GH Pages deploy to `docs.saltus.dev`, `docs/public/CNAME` for custom domain, 6 doc content pages (getting-started, features, architecture, build, MCP overview, API index), `composer docs:all` script (`docs:mcp` + `docs:api`), @api annotations on 84 public classes/interfaces across all namespaces, README updated with docs.saltus.dev links, ROADMAP.md Phase 5D progress tracked @since 2026-07-21 +- AbilityRuntime middleware pipeline integration: backward-compatible `execute_via_pipeline()` delegated from `execute()`, `execute_legacy()` preserved as fallback, optional `MiddlewarePipeline` constructor injection — resolves Phase 4F/4G strip-cache/strip-rate-limit items @since 2026-07-21 - Code review feedback: replaced wp_die with RuntimeException in single_export_query to avoid HTML death pages in REST/MCP contexts; added catch clause in export_post to return structured WP_Error; replaced unsafe property_exists with get_object_vars in ModelRestPolicy to avoid fatal errors on non-public properties; added explicit edit_posts permission checks for list_models/get_model/list_meta_fields/get_meta_fields in AbilityDefinitionFactory; changed AuditLogger created_at column from varchar(32) to datetime(3) — 2 commits @since 2026-07-04 - Export query hardening: replaced fragile string-equality check in single_export_query with structural regex detection via is_fake_date_export_query; added wp_die fallback for unrecognized fake-date query shapes; added esc_html__ and wp_die test stubs — 1 commit @since 2026-07-04 - Code review feedback: deferred RestServer instantiation inside rest_api_init to avoid overhead on non-REST requests; replaced wp_next_scheduled/wp_unschedule_event with wp_clear_scheduled_hook in MCP deactivation; gated ensure_table() behind DB version option to avoid unnecessary CREATE TABLE queries on every audit write; added wp_clear_scheduled_hook test stub — 3 commits @since 2026-07-04 @@ -111,11 +116,15 @@ - MetaController PUT route: `update_item` + `update_item_permissions_check` at `PUT /saltus-framework/v1/meta/{post_type}/{post_id}` with serialized meta merging; UpdateMetaFields MCP tool and MetaControllerTest + UpdateMetaFieldsTest added — 1 commit @since 2026-07-07 - Test suite: 226 tests, 639 assertions (bumped from 214/605 by +12 tests, +34 assertions for new MCP tool, MetaController PUT, and count updates) @since 2026-07-07 - ModelsController: use `check_method` for description property access to prevent PHP 8.2+ dynamic property deprecation notices @since 2026-07-07 +- MCP namespace config filterability: added MCPConfig utility class with 3 WordPress filters (saltus/framework/mcp/namespace, saltus/framework/mcp/ability_category, saltus/framework/mcp/ability_prefix); added mcp_route() helper to RestTool base; refactored 21 source files from hardcoded strings to MCPConfig calls; added MCPConfigTest with 13 test cases — 6 commits, 236 tests, 655 assertions @since 2026-07-08 +- MCP/REST capability gating refactored: added get_config() to Model interface; added McpPolicy class with 14 test cases for MCP-specific mcp_tools/show_in_mcp gating; refactored ModelRestPolicy from saltus_rest array to per-feature config-section model; updated REST controller error hints; updated all existing tests for new config-section pattern — 7 commits, 250 tests, 669 assertions @since 2026-07-08 + +- Version bump to v1.1.0: `phpdocumentor/phpdocumentor` added to require-dev, composer dev platform pinned to PHP 8.4 (runtime still targets 7.4+), brand assets added to `docs/assets/`, BUILD.md notes the docs tooling @since 2026-07-31 ## Known Issues - `composer test` passes; Composer still prints a dependency deprecation notice from `justinrainbow/json-schema` under PHP 8.5.4. - `composer phpcs` passes. -- PHPStan: Level 7 clean across the configured analysis set (new service classes ReorderPostsService, MetaFieldProvider, SettingsManager added). +- PHPStan: Level 7 clean across the configured analysis set; use `--debug` when the sandbox prevents PHPStan's local TCP worker server from binding. ## Handoff - WP7 Abilities is the MCP direction. Local stdio server was removed; SSE transport and standalone packaging are skipped. @@ -124,4 +133,4 @@ - `list_meta_fields` calls `GET /saltus-framework/v1/meta` and returns `post_types`. - `get_meta_fields` calls `GET /saltus-framework/v1/meta/{post_type}` and returns one CPT's raw `meta` plus normalized field paths and REST meta keys. - Service extraction completed 2026-07-03: SaltusSingleExport, MetaFieldProvider, ReorderPostsService, and SettingsManager are now shared between REST controllers and MCP tools via constructor injection. Feature classes (DragAndDrop, Meta, Settings, SingleExport) own the service instances and pass them to both paths, eliminating code duplication. -- Current verification: full `composer test` (208 tests, 598 assertions), `composer phpstan`, `composer phpcs`, and `git diff --check` pass after the service extraction pass. +- Current verification: full `composer test` (319 tests, 842 assertions), PHPStan Level 7 (`--debug` in the sandbox), `composer test:phpcs`, `npm run docs:build`, and `git diff --check` pass for Phase 5A/5B/5D. diff --git a/docs/MCP-CLIENTS.md b/docs/MCP-CLIENTS.md index 3c5e7f8c..5edda409 100644 --- a/docs/MCP-CLIENTS.md +++ b/docs/MCP-CLIENTS.md @@ -237,7 +237,7 @@ Use these heuristics when planning autonomous workflows: 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. +5. Confirm required parameters match the [Abilities Reference](/mcp/abilities) or the discovered input schema. 6. Stop if the error is permission-related. ## Editor And VS Code Guidance @@ -286,4 +286,4 @@ Avoid these client behaviors: ## Reference - Main MCP docs: [MCP.md](MCP.md) -- Generated ability reference: [MCP-ABILITIES.md](MCP-ABILITIES.md) +- Generated ability reference: [Abilities Reference](/mcp/abilities) diff --git a/docs/MCP.md b/docs/MCP.md index 3abc6e2e..1c44f15c 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -4,14 +4,14 @@ Saltus Framework exposes its AI-facing tool surface through the WordPress-native This document is written as the source page for the future Saltus documentation site. -For client implementation guidance, see [MCP-CLIENTS.md](MCP-CLIENTS.md). For the generated ability reference, see [MCP-ABILITIES.md](MCP-ABILITIES.md). +For client implementation guidance, see [MCP-CLIENTS.md](MCP-CLIENTS.md). For the generated ability reference, see [Abilities Reference](/mcp/abilities). ## Status - 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,43 +161,39 @@ 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 -| 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_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 | - -For full generated parameter details, see [MCP-ABILITIES.md](MCP-ABILITIES.md). +Saltus Framework exposes 19 WordPress-native MCP/Abilities tools. + +For full details including parameters, see [Abilities Reference](/mcp/abilities). ## Metadata Discovery @@ -204,7 +229,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 +301,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 +310,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/PROJECT.md b/docs/PROJECT.md index df6844dc..cd1f66e9 100644 --- a/docs/PROJECT.md +++ b/docs/PROJECT.md @@ -29,6 +29,8 @@ Saltus Framework is designed to make things easier and faster for developers wit - Single entry export functionality - Built-in drag-and-drop reordering - **Extensibility**: Provides a robust set of hooks (`actions` and `filters`) to customize the framework's behavior (e.g., duplicate post data, admin filter queries, modeler priorities). +- **Block Editor**: Generates dynamic list and single blocks from CPT model configuration and metadata. +- **Operational Tooling**: Exposes all 19 WordPress-native abilities through equivalent `wp saltus` commands. ## Core Concepts diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 214c9092..8df1a40f 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -3,14 +3,16 @@ ## Current Status - Version: 2.0.0 (released 2026-06-30) - Features implemented: CPT creation, taxonomies, settings pages, metaboxes, cloning, export, drag&drop reordering. -- WordPress-native MCP/Abilities surface with 18 tools (9 Phase 1 + 8 Phase 2 + health) +- WordPress-native MCP/Abilities surface with 19 tools - Phase 2 REST API complete: 9 routes registered in `saltus-framework/v1/` - 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 | @@ -170,7 +172,7 @@ Expose Saltus Framework capabilities through WordPress-native MCP/Abilities. Sal --- -#### 5A — Block Editor Integration (block.json tied to models) +#### 5A — Block Editor Integration (runtime metadata tied to models) **Goal:** Auto-register Gutenberg blocks from CPT model config, using the model's meta fields as block attributes. One named block per CPT (e.g., `saltus/movie-list`, `saltus/book-list`). @@ -190,14 +192,14 @@ blocks: | `src/Features/Blocks/Blocks.php` | Service class (Service, Conditional, Assembly, ToolContributor) | | `src/Features/Blocks/SaltusBlocks.php` | Processable — iterates models, register_block_type() per CPT | | `src/Features/Blocks/BlockRenderer.php` | Shared render_callback for list and single blocks | -| `templates/block-list.php` | Default list block template | -| `templates/block-single.php` | Default single block template | +| `templates/blocks/list.php` | Default list block template | +| `templates/blocks/single.php` | Default single block template | | `assets/Feature/Blocks/editor.js` | Editor script (InspectorControls) | | `assets/Feature/Blocks/style.css` | Block styles | **How it wires in:** - `Core::get_service_classes()` adds `'blocks' => Blocks::class` -- `ModelFactory::process_services()` reads `config['blocks']` → `Blocks::make()` → `process()` +- `Blocks` reads all post type models from `Modeler` and registers enabled definitions on `init` - Each call to `register_block_type()` uses a metadata array (no static block.json needed) - Meta fields from model `meta` config auto-mapped as block attributes via `MetaFieldProvider` - Dedicated MCP tool `list_block_models` contributed by `Blocks::get_mcp_tools()` @@ -205,13 +207,13 @@ blocks: | Item | Status | |------|--------| -| Blocks feature service + SaltusBlocks implementation | ○ Pending | -| BlockRenderer with default render callbacks | ○ Pending | -| Default list/single block templates | ○ Pending | -| Editor script and styles | ○ Pending | -| MCP tool for block model discovery | ○ Pending | -| PHPUnit tests for block registration | ○ Pending | -| Integration with existing ModelRestPolicy | ○ Pending | +| Blocks feature service + SaltusBlocks implementation | ✓ Done | +| BlockRenderer with default render callbacks | ✓ Done | +| Default list/single block templates | ✓ Done | +| Editor script and styles | ✓ Done | +| MCP tool for block model discovery | ✓ Done | +| PHPUnit tests for block registration | ✓ Done | +| Integration with existing ModelRestPolicy | ✓ Done | **Exit criteria:** `saltus/{cpt_name}-list` and `saltus/{cpt_name}-single` blocks are registered for every CPT with `blocks: true`. Block attributes reflect the model's meta field config. List block queries and renders posts; single block renders a post with all meta. @@ -221,7 +223,7 @@ blocks: **Goal:** `wp saltus ` mapping every MCP tool to a WP-CLI command, using the same shared service classes. -**Command tree (7 grouped command classes):** +**Command tree (8 grouped command classes):** | Command | MCP Tool | Shared Service | |---------|----------|----------------| @@ -241,6 +243,8 @@ blocks: | `wp saltus settings update ` | `update_settings` | `SettingsManager` | | `wp saltus reorder ` | `reorder_posts` | `ReorderPostsService` | | `wp saltus meta list []` | `list_meta_fields` / `get_meta_fields` | `MetaFieldProvider` | +| `wp saltus meta update ` | `update_meta_fields` | `MetaFieldProvider` | +| `wp saltus block list` | `list_block_models` | `SaltusBlocks` | **Files:** @@ -254,6 +258,7 @@ blocks: | `src/Features/WpCli/Commands/SettingsCommand.php` | `wp saltus settings {get\|update}` | | `src/Features/WpCli/Commands/MetaCommand.php` | `wp saltus meta {list\|get}` | | `src/Features/WpCli/Commands/ReorderCommand.php` | `wp saltus reorder` | +| `src/Features/WpCli/Commands/BlockCommand.php` | `wp saltus block list` | **Output formatting:** `--format=table|json|yaml` flag on all list/detail commands. Table is default for interactive, JSON for scripting. @@ -261,16 +266,17 @@ blocks: | Item | Status | |------|--------| -| WpCli feature service | ○ Pending | -| SaltusCommand (health + help) | ○ Pending | -| ModelCommand (list, get) | ○ Pending | -| PostCommand (list, get, create, update, delete, duplicate, export) | ○ Pending | -| TermCommand (list, create) | ○ Pending | -| SettingsCommand (get, update) | ○ Pending | -| MetaCommand (list, get) | ○ Pending | -| ReorderCommand | ○ Pending | -| `composer docs:wpcli` script | ○ Pending | -| PHPUnit tests with WP_CLI stubs | ○ Pending | +| WpCli feature service | ✓ Done | +| SaltusCommand (health + help) | ✓ Done | +| ModelCommand (list, get) | ✓ Done | +| PostCommand (list, get, create, update, delete, duplicate, export) | ✓ Done | +| TermCommand (list, create) | ✓ Done | +| SettingsCommand (get, update) | ✓ Done | +| MetaCommand (list, get, update) | ✓ Done | +| ReorderCommand | ✓ Done | +| BlockCommand | ✓ Done | +| `composer docs:wpcli` script | ✓ Done | +| PHPUnit tests with WP_CLI stubs | ✓ Done | **Exit criteria:** Every MCP tool has a corresponding `wp saltus` subcommand. Commands use shared service classes (not REST dispatch). Output formatting supports `--format=table|json|yaml`. Test suite covers all command groups. @@ -338,43 +344,51 @@ frontend: | Section | Current State | Target | |---------|---------------|--------| -| `features` parameter table | `(More Info Soon)` | Complete table of all 7 features with config keys and one-liners | -| `labels` parameter table | `(More Info Soon)` | Full reference: `has_one`, `has_many`, `text_domain`, `featured_image`, `overrides.ui`, `overrides.messages`, `overrides.bulk_messages`, `overrides.labels` | -| `meta` parameter table | `(More Info Soon)` | Metabox structure: sections, fields, Codestar field types, REST API registration | -| `settings` parameter table | `(More Info Soon)` | Settings page structure: page args, sections, fields, parent menu, tabs | -| CPT example file | `(Soon)` | Complete YAML + PHP model with all common parameters | -| Taxonomy example file | `(Soon)` | Complete YAML + PHP taxonomy model with associations | +| `features` parameter table | Complete | Table of all 7 features with config keys and one-liners | +| `labels` parameter table | Complete | Full reference: `has_one`, `has_many`, `text_domain`, `featured_image`, and overrides | +| `meta` parameter table | Complete | Metabox structure: sections, fields, Codestar field types, REST API registration | +| `settings` parameter table | Complete | Settings page structure: page args, sections, fields, parent menu, tabs | +| CPT example file | Complete | Complete PHP model with all common parameters | +| Taxonomy example file | Complete | Complete PHP taxonomy model with associations | **New doc files:** | File | Content | |------|---------| -| `docs/BLOCKS.md` | Block config reference, template customization, attributes guide, editor integration | -| `docs/WPCLI.md` | Full command reference with examples (auto-generated marker from `composer docs:wpcli`) | -| `docs/FRONTEND.md` | Shortcode API, template variables, customization guide, attribute reference | -| `docs/FEATURES.md` | Deep feature reference extracted from README: admin_cols, admin_filters, draganddrop, duplicate, quick_edit, remember_tabs, single_export | +| `docs/guides/blocks.md` | Block config reference, template customization, attributes guide, editor integration | +| `docs/guides/wp-cli.md` | Full command reference with examples, auto-generated by `composer docs:wpcli` | +| `docs/guides/frontend.md` | Deferred with 5C: shortcode API, template variables, customization guide | +| `docs/guides/features.md` | Deep feature and model reference | **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 | |------|--------| -| README features table | ○ Pending | -| README labels reference | ○ Pending | -| README meta structure | ○ Pending | -| README settings structure | ○ Pending | -| README CPT example | ○ Pending | -| README taxonomy example | ○ Pending | -| docs/BLOCKS.md | ○ Pending | -| docs/WPCLI.md (auto-generated) | ○ Pending | -| docs/FRONTEND.md | ○ Pending | -| 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. +| 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 + WP-CLI + API) | ✓ Done | +| README features table | ✓ Done | +| README labels reference | ✓ Done | +| README meta structure | ✓ Done | +| README settings structure | ✓ Done | +| README CPT example | ✓ Done | +| README taxonomy example | ✓ Done | +| docs/guides/blocks.md | ✓ Done | +| docs/guides/wp-cli.md (auto-generated) | ✓ Done | +| docs/guides/frontend.md | ○ Deferred with 5C | +| docs/guides/features.md | ✓ Done | +| bin/generate-wpcli-docs.php | ✓ Done | + +**Exit criteria:** README placeholders filled, docs.saltus.dev backed by VitePress + API docs and GitHub Actions deployment, Blocks and Features guides published, and WP-CLI docs auto-generated. Frontend documentation ships with 5C. --- -*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 +409,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/assets/branding/saltus-icon.jpg b/docs/assets/branding/saltus-icon.jpg new file mode 100644 index 00000000..19b6f6a2 Binary files /dev/null and b/docs/assets/branding/saltus-icon.jpg differ diff --git a/docs/assets/manifest.json b/docs/assets/manifest.json new file mode 100644 index 00000000..c9112e5e --- /dev/null +++ b/docs/assets/manifest.json @@ -0,0 +1,49 @@ +{ + "version": 1, + "brand": { + "logoLight": { + "id": "brand:logoLight", + "filename": "saltus-icon.jpg", + "relativePath": "branding/saltus-icon.jpg", + "mimeType": "image/jpeg", + "size": 67834, + "modifiedAt": "1784687769" + }, + "logoDark": null, + "wordmarkLight": null, + "wordmarkDark": null + }, + "palette": [ + { + "id": "auto-1-200040", + "name": "Auto 1", + "hex": "#200040", + "source": "auto" + }, + { + "id": "auto-2-204080", + "name": "Auto 2", + "hex": "#204080", + "source": "auto" + }, + { + "id": "auto-3-202060", + "name": "Auto 3", + "hex": "#202060", + "source": "auto" + }, + { + "id": "auto-4-202040", + "name": "Auto 4", + "hex": "#202040", + "source": "auto" + }, + { + "id": "auto-5-204060", + "name": "Auto 5", + "hex": "#204060", + "source": "auto" + } + ], + "extras": [] +} \ No newline at end of file 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..72501e18 --- /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 19 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/blocks.md b/docs/guides/blocks.md new file mode 100644 index 00000000..684f1c80 --- /dev/null +++ b/docs/guides/blocks.md @@ -0,0 +1,82 @@ +# Model-Driven Blocks + +Saltus can generate dynamic Gutenberg blocks for any custom post type model. Blocks are registered at runtime with Block API version 3 and rendered on the server, so saved block content stays current when posts or meta values change. + +## Enable Blocks + +Enable both views with a boolean: + +```yaml +type: cpt +name: movie +blocks: true +``` + +Or enable views independently and configure project templates: + +```yaml +type: cpt +name: movie +blocks: + list: true + single: true + templates: + list: templates/movie-list.php + single: templates/movie-single.php +``` + +This registers `saltus/movie-list` and `saltus/movie-single`. A false or omitted view is not registered. Block definitions are generated from model metadata; static `block.json` files are not required. + +## List Block + +The list block queries published posts from the model's post type. Its inspector controls support: + +| Attribute | Type | Default | Notes | +|---|---|---|---| +| `postsToShow` | number | `10` | Clamped to 1-100 | +| `orderBy` | string | `date` | `date`, `title`, `modified`, `menu_order`, or `ID` | +| `order` | string | `DESC` | `ASC` or `DESC` | +| `taxonomy` | string | empty | Must belong to the model post type | +| `terms` | string[] | empty | Term slugs for the selected taxonomy | +| `showExcerpt` | boolean | `true` | Show each post excerpt | +| `showDate` | boolean | `true` | Show each post date | +| `metaFields` | string[] | model fields | Normalized model field paths to render | + +## Single Block + +The single block renders one published post from the model's post type. + +| Attribute | Type | Default | Notes | +|---|---|---|---| +| `postId` | number | `0` | Selected post ID; invalid or unpublished posts render nothing | +| `showTitle` | boolean | `true` | Show the post title | +| `showContent` | boolean | `true` | Show the post content | +| `metaFields` | string[] | model fields | Normalized model field paths to render | + +The editor fetches post records through the WordPress REST API. Keep `show_in_rest` enabled for the post type, which is the Saltus default. + +## Meta Fields + +Selectable fields come directly from the model's `meta` definitions through `MetaFieldProvider`. Nested serialized fields use dotted paths such as `movie_details.cast.director`. Block attributes store only selected field paths, never copies of post values. + +For REST-writable meta, set `register_rest_api: true` on the metabox. This is separate from block display: blocks can read configured meta whether or not external REST writes are enabled. + +## Template Overrides + +Saltus resolves a block template in this order: + +1. Model path from `blocks.templates.list` or `blocks.templates.single`, relative to the consuming plugin root. +2. Active theme file at `saltus/{post_type}/block-list.php` or `saltus/{post_type}/block-single.php`. +3. Framework default at `templates/blocks/list.php` or `templates/blocks/single.php`. + +List templates receive `$posts`, `$meta_fields`, `$meta_by_post`, `$model`, and `$attributes`. Single templates receive `$post`, `$meta`, `$meta_fields`, `$model`, and `$attributes`. Saltus wraps the result with block wrapper attributes. + +## Extension Hooks + +| Filter | Purpose | +|---|---| +| `saltus/framework/blocks/attributes` | Change the runtime block registration arguments for a post type and view | +| `saltus/framework/blocks/query_args` | Change the list block's `WP_Query` arguments | +| `saltus/framework/blocks/template` | Replace the resolved template path | + +Block definitions are also available from `GET /saltus-framework/v1/blocks`, the `saltus/list-block-models` ability, and `wp saltus block list`. 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..06d1356d --- /dev/null +++ b/docs/guides/features.md @@ -0,0 +1,209 @@ +# Features and Model Reference + +Saltus model files return one model array or a list of model arrays. Models may be PHP, JSON, or YAML and are loaded from `src/models/` by default. + +## Feature Summary + +Features are configured under the CPT model's `features` key. Each value must be truthy to enable the feature. Configuration arrays are passed directly to the feature; do not wrap them in `enabled` or `columns` keys. + +| Key | Purpose | Common configuration | +|---|---|---| +| `admin_cols` | Customize the post list columns | Column IDs mapped to native columns or custom column definitions | +| `admin_filters` | Filter the post list | Filter IDs mapped to taxonomy, meta, date, or author definitions | +| `draganddrop` | Reorder posts by `menu_order` | `true`; `show_in_rest: true` also opts into Saltus reorder routes | +| `duplicate` | Duplicate a post from its row actions | `label`, `attr_title`, and optional `show_in_rest` | +| `quick_edit` | Edit text meta from Quick Edit | Meta keys mapped to `title` and `column_name` | +| `remember_tabs` | Restore the active Codestar tab | `true` | +| `single_export` | Export one post as WXR | `label` and optional `show_in_rest` | + +## Admin Columns + +Each key is the resulting column ID. A string keeps a native WordPress column. Custom definitions can display a `meta_key`, `taxonomy`, `post_field`, `featured_image` size, or `function` callback. Set `sortable: false` to disable automatic sorting for supported values. + +```yaml +features: + admin_cols: + title: title + poster: + title: Poster + featured_image: thumbnail + sortable: false + release_year: + title: Year + meta_key: release_year + genre: + title: Genres + taxonomy: genre +``` + +Other useful keys include `cap` to hide a column unless the user has a capability, `prefix`/`suffix` around values, `date_format`, `number_format`, `link`, `title_icon`, and `title_cb`. + +## Admin Filters + +Filters are selected by their defining key. Supported shapes include `taxonomy`, `meta_key`, `meta_search_key`, `meta_exists`/`meta_key_exists`, `post_date`, and `post_author`. + +```yaml +features: + admin_filters: + genre: + taxonomy: genre + label: All genres + rating: + meta_key: rating + label: All ratings + options: + G: G + PG: PG + R: R +``` + +Meta filter options may be an array or callable. Without explicit options, Saltus builds the list from stored values. Use the `saltus/framework/admin_filters/{post_type}/filter_query/{filter_id}` filter for custom query logic. + +## Drag And Drop + +Enable ordering with a boolean. The admin list becomes sortable and the main query defaults to ascending `menu_order` for that post type. + +```yaml +features: + draganddrop: true +``` + +To expose batch reordering through Saltus REST/MCP, use an array with `show_in_rest: true` and allow the corresponding MCP tool if model-level MCP policy is restricted. + +## Duplicate Post + +```yaml +features: + duplicate: + label: Duplicate movie + attr_title: Create a draft copy + show_in_rest: true +``` + +`label` and `attr_title` customize the row action. `show_in_rest` opts the feature into the Saltus duplicate endpoint; WordPress capability checks still apply. + +## Quick Edit + +Quick Edit fields are text inputs backed by post meta. The referenced admin column must be present so Saltus can transfer its current value into the form. + +```yaml +features: + admin_cols: + release_year: + title: Year + meta_key: release_year + quick_edit: + release_year: + title: Release year + column_name: release_year +``` + +## Remember Tabs + +```yaml +features: + remember_tabs: true +``` + +This loads the tab persistence script on post edit and new-post screens. + +## Single Export + +```yaml +features: + single_export: + label: Export this movie + show_in_rest: true +``` + +Users need WordPress's `export` capability. `show_in_rest` also opts the model into Saltus REST/MCP export handling. + +## Labels + +Saltus generates the standard WordPress labels from singular and plural names and lets models override specific layers. + +| Key | Purpose | +|---|---| +| `has_one` | Singular display name; defaults to the capitalized model name | +| `has_many` | Plural display name; defaults to the model name plus `s` | +| `text_domain` | Model translation domain; defaults to `saltus` | +| `featured_image` | Replaces featured image, set, remove, and use labels | +| `overrides.ui.enter_title_here` | Changes the post title input placeholder | +| `overrides.labels` | Replaces keys in the generated `register_post_type()` or `register_taxonomy()` labels array | +| `overrides.messages` | Replaces individual post update messages | +| `overrides.bulk_messages` | Replaces singular/plural bulk action messages | + +```yaml +labels: + has_one: Movie + has_many: Movies + text_domain: my-plugin + featured_image: Poster + overrides: + ui: + enter_title_here: Enter movie title + labels: + all_items: Movie library + messages: + post_published: 'Movie published. View movie' + post_saved: Movie saved. + bulk_messages: + updated_singular: Movie updated. + updated_plural: '%1$s movies updated.' +``` + +Post message keys are `post_updated`, `custom_field_updated`, `custom_field_deleted`, `post_updated_short`, `post_restored`, `post_published`, `post_saved`, `post_submitted`, `post_scheduled`, and `post_draft_updated`. Templates may use `{permalink}`, `{date}`, and `{preview_url}`. Bulk prefixes are `updated`, `locked`, `deleted`, `trashed`, and `untrashed`, each with `_singular` and `_plural` variants. + +## Meta Boxes + +The `meta` array is keyed by metabox ID. Each metabox accepts Codestar metabox arguments plus either a flat `fields` map/list or multiple `sections`. Saltus supplies defaults for `post_type`, `priority`, `context`, `theme`, and `data_type`. + +```yaml +meta: + movie_details: + title: Movie Details + context: normal + priority: high + register_rest_api: true + fields: + release_year: + type: number + title: Release year + rating: + type: select + title: Rating + options: + G: G + PG: PG + R: R +``` + +Field map keys become IDs when `id` is omitted. Nested `fields` are normalized recursively. Set `data_type: serialize` to store the metabox under its box ID; otherwise fields are stored as separate meta keys. `register_rest_api: true` registers declared fields for REST access and marks them writable in Saltus metadata discovery. + +All [Codestar field types](https://codestarframework.com/documentation/#/fields) supported by the bundled version may be used. Saltus maps common field types into JSON-schema-like metadata for REST, MCP, blocks, and WP-CLI discovery. + +## Settings Pages + +The `settings` array is keyed by the settings ID, which is also the default option name and menu slug. Each page accepts Codestar options arguments and either `fields` or `sections`. + +```yaml +settings: + movie_settings: + title: Movie Settings + menu_title: Settings + menu_parent: edit.php?post_type=movie + menu_type: submenu + fields: + items_per_page: + type: number + title: Items per page + default: 10 +``` + +Saltus defaults `menu_parent` to the CPT menu, `menu_type` to `submenu`, `theme` to `light`, and `menu_slug` to the settings ID. For tabbed pages, replace `fields` with `sections`; each section provides its own `title` and `fields`. + +## Related Guides + +- [Model-Driven Blocks](./blocks.md) +- [WP-CLI Reference](./wp-cli.md) +- [MCP/Abilities Overview](../mcp/index.md) diff --git a/docs/guides/wp-cli.md b/docs/guides/wp-cli.md new file mode 100644 index 00000000..c706a0d6 --- /dev/null +++ b/docs/guides/wp-cli.md @@ -0,0 +1,46 @@ +# WP-CLI Reference + + + +Saltus exposes every WordPress-native MCP/Ability through an equivalent `wp saltus` command. Commands execute WordPress APIs and shared framework services directly; they do not make HTTP requests. + +## Output and JSON input + +List and detail commands support `--format=table|json|yaml`; table is the default. Commands accepting JSON support either an inline value or `@path/to/file.json`. + +## Commands + +| Ability | Command | Description | +|---|---|---| +| `get_health` | `wp saltus health` | Show framework health and runtime metrics. | +| `list_models` | `wp saltus model list` | List loaded Saltus models. | +| `get_model` | `wp saltus model get ` | Show one loaded model. | +| `list_posts` | `wp saltus post list ` | List posts for a post type. | +| `get_post` | `wp saltus post get ` | Show one post. | +| `create_post` | `wp saltus post create ` | Create a post. | +| `update_post` | `wp saltus post update <id>` | Update a post. | +| `delete_post` | `wp saltus post delete <id>` | Delete or trash a post. | +| `duplicate_post` | `wp saltus post duplicate <id>` | Duplicate a post as a draft. | +| `export_post` | `wp saltus post export <id>` | Export one post as WXR. | +| `list_terms` | `wp saltus term list <taxonomy>` | List taxonomy terms. | +| `create_term` | `wp saltus term create <taxonomy> <name>` | Create a taxonomy term. | +| `get_settings` | `wp saltus settings get <post-type>` | Read model settings. | +| `update_settings` | `wp saltus settings update <post-type> <json\|@file>` | Update model settings. | +| `list_meta_fields` | `wp saltus meta list` | List model meta fields. | +| `get_meta_fields` | `wp saltus meta get <post-type>` | Show one model meta schema. | +| `update_meta_fields` | `wp saltus meta update <post-type> <post-id> <json\|@file>` | Update registered post meta. | +| `reorder_posts` | `wp saltus reorder <json\|@file>` | Update post menu order. | +| `list_block_models` | `wp saltus block list` | List model-driven blocks. | + +## Examples + +```bash +wp saltus model list --format=json +wp saltus post create book "The Left Hand of Darkness" --status=draft --meta='{"isbn":"9780441478125"}' +wp saltus settings update book @settings.json +wp saltus meta update book 42 @meta.json +wp saltus reorder @order.json +wp saltus post export 42 --file=book-42.xml +``` + +WP-CLI shell access is the authorization boundary. REST and MCP visibility gates do not hide CLI commands. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..751d4f7c --- /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 19 tools - models, posts, terms, settings, meta, blocks, 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 similarity index 56% rename from docs/MCP-ABILITIES.md rename to docs/mcp/abilities.md index d70030d5..96329818 100644 --- a/docs/MCP-ABILITIES.md +++ b/docs/mcp/abilities.md @@ -2,37 +2,15 @@ <!-- This file is auto-generated by `composer docs:mcp`. Do not edit by hand. --> -Saltus Framework exposes 17 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_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 | +Saltus Framework exposes 19 WordPress-native MCP/Abilities tools. ## `create_post` -Create a new post in any registered Custom Post Type +**Ability:** <code style="color:#00bc7d">saltus/create-post</code> + +**REST request:** <code style="color:#00b8db">POST /wp/v2/posts</code> -- Ability: `saltus/create-post` -- REST request: `POST /wp/v2/posts` -- REST capability: `none` -- Cacheable: `no` -- Cache TTL: `n/a` +Create a new post in any registered Custom Post Type ### Parameters @@ -49,13 +27,11 @@ Create a new post in any registered Custom Post Type ## `create_term` -Create a new term in a taxonomy +**Ability:** <code style="color:#00bc7d">saltus/create-term</code> -- Ability: `saltus/create-term` -- REST request: `POST /wp/v2/{taxonomy_rest_base}` -- REST capability: `none` -- Cacheable: `no` -- Cache TTL: `n/a` +**REST request:** <code style="color:#00b8db">POST /wp/v2/{taxonomy_rest_base}</code> + +Create a new term in a taxonomy ### Parameters @@ -69,13 +45,11 @@ Create a new term in a taxonomy ## `delete_post` -Delete (trash or force delete) a post by ID +**Ability:** <code style="color:#00bc7d">saltus/delete-post</code> -- Ability: `saltus/delete-post` -- REST request: `DELETE /wp/v2/posts/123` -- REST capability: `none` -- Cacheable: `no` -- Cache TTL: `n/a` +**REST request:** <code style="color:#00b8db">DELETE /wp/v2/posts/123</code> + +Delete (trash or force delete) a post by ID ### Parameters @@ -87,13 +61,11 @@ Delete (trash or force delete) a post by ID ## `duplicate_post` -Duplicate a WordPress post, creating a copy with "(Copy)" appended to the title +**Ability:** <code style="color:#00bc7d">saltus/duplicate-post</code> + +**REST request:** <code style="color:#00b8db">POST /saltus-framework/v1/duplicate/123</code> -- Ability: `saltus/duplicate-post` -- REST request: `POST /saltus-framework/v1/duplicate/123` -- REST capability: `duplicate (post_type)` -- Cacheable: `no` -- Cache TTL: `n/a` +Duplicate a WordPress post, creating a copy with "(Copy)" appended to the title ### Parameters @@ -103,13 +75,11 @@ Duplicate a WordPress post, creating a copy with "(Copy)" appended to the title ## `export_post` -Export a WordPress post as WXR (WordPress eXtended RSS) for import into another site +**Ability:** <code style="color:#00bc7d">saltus/export-post</code> -- Ability: `saltus/export-post` -- REST request: `GET /saltus-framework/v1/export/123` -- REST capability: `export (post_type)` -- Cacheable: `no` -- Cache TTL: `n/a` +**REST request:** <code style="color:#00b8db">GET /saltus-framework/v1/export/123</code> + +Export a WordPress post as WXR (WordPress eXtended RSS) for import into another site ### Parameters @@ -119,13 +89,11 @@ Export a WordPress post as WXR (WordPress eXtended RSS) for import into another ## `get_health` -Get Saltus Framework health, version, audit error rate, latency, cache, and rate limit status +**Ability:** <code style="color:#00bc7d">saltus/get-health</code> + +**REST request:** <code style="color:#00b8db">GET /saltus-framework/v1/health</code> -- Ability: `saltus/get-health` -- REST request: `GET /saltus-framework/v1/health` -- REST capability: `health` -- Cacheable: `yes` -- Cache TTL: `60s` +Get Saltus Framework health, version, audit error rate, latency, cache, and rate limit status ### Parameters @@ -133,13 +101,11 @@ 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:** <code style="color:#00bc7d">saltus/get-meta-fields</code> -- Ability: `saltus/get-meta-fields` -- REST request: `GET /saltus-framework/v1/meta/{post_type}` -- REST capability: `meta (post_type)` -- Cacheable: `yes` -- Cache TTL: `600s` +**REST request:** <code style="color:#00b8db">GET /saltus-framework/v1/meta/{post_type}</code> + +Get the meta field definitions for a post type as configured in the Saltus Framework model ### Parameters @@ -149,13 +115,11 @@ Get the meta field definitions for a post type as configured in the Saltus Frame ## `get_model` -Get details of a specific Custom Post Type or Taxonomy by slug +**Ability:** <code style="color:#00bc7d">saltus/get-model</code> -- Ability: `saltus/get-model` -- REST request: `GET /saltus-framework/v1/models/{slug}` -- REST capability: `models` -- Cacheable: `yes` -- Cache TTL: `600s` +**REST request:** <code style="color:#00b8db">GET /saltus-framework/v1/models/{slug}</code> + +Get details of a specific Custom Post Type or Taxonomy by slug ### Parameters @@ -165,13 +129,11 @@ Get details of a specific Custom Post Type or Taxonomy by slug ## `get_post` -Get a single post by ID with all fields and meta data +**Ability:** <code style="color:#00bc7d">saltus/get-post</code> -- Ability: `saltus/get-post` -- REST request: `GET /wp/v2/posts/123` -- REST capability: `none` -- Cacheable: `yes` -- Cache TTL: `300s` +**REST request:** <code style="color:#00b8db">GET /wp/v2/posts/123</code> + +Get a single post by ID with all fields and meta data ### Parameters @@ -182,13 +144,11 @@ Get a single post by ID with all fields and meta data ## `get_settings` -Get the Saltus Framework settings for a specific post type +**Ability:** <code style="color:#00bc7d">saltus/get-settings</code> -- Ability: `saltus/get-settings` -- REST request: `GET /saltus-framework/v1/settings/{post_type}` -- REST capability: `settings (post_type)` -- Cacheable: `yes` -- Cache TTL: `300s` +**REST request:** <code style="color:#00b8db">GET /saltus-framework/v1/settings/{post_type}</code> + +Get the Saltus Framework settings for a specific post type ### Parameters @@ -196,15 +156,25 @@ Get the Saltus Framework settings for a specific post type |-----------|------|----------|---------|-------------| | `post_type` | `string` | yes | | The post type slug to get settings for | +## `list_block_models` + +**Ability:** <code style="color:#00bc7d">saltus/list-block-models</code> + +**REST request:** <code style="color:#00b8db">GET /saltus-framework/v1/blocks</code> + +List Saltus post type models with their registered list and single blocks + +### Parameters + +This tool does not accept parameters. + ## `list_meta_fields` -List model-defined meta field definitions for all registered Saltus post types +**Ability:** <code style="color:#00bc7d">saltus/list-meta-fields</code> -- Ability: `saltus/list-meta-fields` -- REST request: `GET /saltus-framework/v1/meta` -- REST capability: `meta (post_type)` -- Cacheable: `yes` -- Cache TTL: `600s` +**REST request:** <code style="color:#00b8db">GET /saltus-framework/v1/meta</code> + +List model-defined meta field definitions for all registered Saltus post types ### Parameters @@ -212,13 +182,11 @@ This tool does not accept parameters. ## `list_models` -List all registered Custom Post Types and Taxonomies on the WordPress site +**Ability:** <code style="color:#00bc7d">saltus/list-models</code> -- Ability: `saltus/list-models` -- REST request: `GET /saltus-framework/v1/models` -- REST capability: `models` -- Cacheable: `yes` -- Cache TTL: `600s` +**REST request:** <code style="color:#00b8db">GET /saltus-framework/v1/models</code> + +List all registered Custom Post Types and Taxonomies on the WordPress site ### Parameters @@ -228,13 +196,11 @@ List all registered Custom Post Types and Taxonomies on the WordPress site ## `list_posts` -Query posts from a Custom Post Type with optional filters +**Ability:** <code style="color:#00bc7d">saltus/list-posts</code> -- Ability: `saltus/list-posts` -- REST request: `GET /wp/v2/posts` -- REST capability: `none` -- Cacheable: `yes` -- Cache TTL: `300s` +**REST request:** <code style="color:#00b8db">GET /wp/v2/posts</code> + +Query posts from a Custom Post Type with optional filters ### Parameters @@ -251,13 +217,11 @@ Query posts from a Custom Post Type with optional filters ## `list_terms` -List terms from a taxonomy (categories, tags, or custom taxonomies) +**Ability:** <code style="color:#00bc7d">saltus/list-terms</code> + +**REST request:** <code style="color:#00b8db">GET /wp/v2/{taxonomy_rest_base}</code> -- Ability: `saltus/list-terms` -- REST request: `GET /wp/v2/{taxonomy_rest_base}` -- REST capability: `none` -- Cacheable: `yes` -- Cache TTL: `300s` +List terms from a taxonomy (categories, tags, or custom taxonomies) ### Parameters @@ -270,13 +234,11 @@ List terms from a taxonomy (categories, tags, or custom taxonomies) ## `reorder_posts` -Reorder multiple posts by updating their menu_order values in a single batch operation +**Ability:** <code style="color:#00bc7d">saltus/reorder-posts</code> -- Ability: `saltus/reorder-posts` -- REST request: `POST /saltus-framework/v1/reorder` -- REST capability: `reorder (post_type)` -- Cacheable: `no` -- Cache TTL: `n/a` +**REST request:** <code style="color:#00b8db">POST /saltus-framework/v1/reorder</code> + +Reorder multiple posts by updating their menu_order values in a single batch operation ### Parameters @@ -284,15 +246,29 @@ 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` + +**Ability:** <code style="color:#00bc7d">saltus/update-meta-fields</code> + +**REST request:** <code style="color:#00b8db">PUT /saltus-framework/v1/meta/{post_type}/123</code> + +Update meta fields for a specific post of a registered Saltus post type + +### 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:** <code style="color:#00bc7d">saltus/update-post</code> + +**REST request:** <code style="color:#00b8db">PUT /wp/v2/posts/123</code> -- Ability: `saltus/update-post` -- REST request: `PUT /wp/v2/posts/123` -- REST capability: `none` -- Cacheable: `no` -- Cache TTL: `n/a` +Update an existing post's fields and meta data ### Parameters @@ -309,13 +285,11 @@ Update an existing post's fields and meta data ## `update_settings` -Update the Saltus Framework settings for a specific post type +**Ability:** <code style="color:#00bc7d">saltus/update-settings</code> -- Ability: `saltus/update-settings` -- REST request: `PUT /saltus-framework/v1/settings/{post_type}` -- REST capability: `settings (post_type)` -- Cacheable: `no` -- Cache TTL: `n/a` +**REST request:** <code style="color:#00b8db">PUT /saltus-framework/v1/settings/{post_type}</code> + +Update the Saltus Framework settings for a specific post type ### Parameters diff --git a/docs/mcp/clients.md b/docs/mcp/clients.md new file mode 100644 index 00000000..84722f1c --- /dev/null +++ b/docs/mcp/clients.md @@ -0,0 +1,290 @@ +# 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 the [Abilities Reference](../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) +- Agent skill: [Saltus MCP Skill](../mcp/skill.md) — why, how, and example for the ready-to-use agent skill diff --git a/docs/mcp/index.md b/docs/mcp/index.md new file mode 100644 index 00000000..24ada04f --- /dev/null +++ b/docs/mcp/index.md @@ -0,0 +1,47 @@ +--- +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 +- [Saltus MCP Skill](/mcp/skill) — why, how, and example for the ready-to-use agent skill +- [Download the Saltus MCP Skill](/downloads/saltus-mcp/SKILL.md) — agent skill for connecting AI clients to `saltus/*` abilities diff --git a/docs/mcp/skill.md b/docs/mcp/skill.md new file mode 100644 index 00000000..f01ac8a3 --- /dev/null +++ b/docs/mcp/skill.md @@ -0,0 +1,92 @@ +--- +title: Saltus MCP Skill +--- + +# Saltus MCP Skill + +A ready-to-use agent skill for connecting AI clients to the `saltus/*` MCP/Abilities surface. Drop it into your agent's skills directory and it will guide the agent through health checks, discovery, safe reads, and confirmed writes — without you repeating the integration rules. + +## Download + +[Download `SKILL.md`](/downloads/saltus-mcp/SKILL.md) + +Or fetch it directly: + +```bash +curl -L -o saltus-mcp-SKILL.md \ + https://raw.githubusercontent.com/SaltusDev/saltus-framework/main/.agents/skills/saltus-mcp/SKILL.md +``` + +## Why use it + +Saltus exposes 18 `saltus/*` abilities over WordPress-native MCP/Abilities. Clients that treat them as raw REST calls tend to: + +- guess post type slugs instead of discovering them with `list_models` +- write meta before checking the normalized field paths from `list_meta_fields` +- retry permission failures that will not resolve until roles change +- fire broad list queries as a discovery shortcut, tripping rate limits +- force-delete without explicit confirmation + +The skill encodes the guardrails the framework already expects — the health-first call flow, normalized metadata reasoning, read-confirm-write confirmation, and permission/rate-limit handling — so any agent that loads it behaves correctly out of the box. + +## How to install + +Copy `SKILL.md` into your agent's skills folder. For example, for a filesystem-based agent: + +```bash +mkdir -p ~/.agents/skills/saltus-mcp +curl -L -o ~/.agents/skills/saltus-mcp/SKILL.md \ + https://raw.githubusercontent.com/SaltusDev/saltus-framework/main/.agents/skills/saltus-mcp/SKILL.md +``` + +The skill front matter declares when it applies: + +```yaml +name: saltus-mcp +description: >- + Use when connecting an AI agent/client to Saltus Framework MCP/Abilities + (saltus/*) on an active WordPress site — discovery, health-first flow, + model/meta inspection, safe read/write patterns, permission and rate-limit + handling, and write confirmation flows. +``` + +Agents that auto-load skills by description will pick it up whenever a task mentions connecting to Saltus MCP or working with `saltus/*` tools. + +## How it works + +The skill is a single self-contained `SKILL.md` organized around the client integration guide: + +1. **Discovery** — filter abilities for the `saltus/` prefix and trust the ability metadata (`meta.mcp_tool`, `meta.namespace`, `meta.transport`). +2. **Health first** — call `saltus/get-health` at session start and branch on `status`, `error_rate`, and latency. +3. **Model and metadata discovery** — `saltus/list-models` and `saltus/list-meta-fields` before touching content; prefer `normalized.fields` paths. +4. **Safe reads** — list, confirm, then single-get with small page sizes. +5. **Safe writes** — read model/meta, build a minimal patch, summarize to the user, execute, then re-read to confirm. +6. **Destructive actions** — require explicit confirmation for force deletes, bulk deletes, reordering, settings updates, and serialized-meta writes. +7. **Failure handling** — never retry permission failures; respect `retry_after` on rate limits; confirm mutations against the post-write cache clear. +8. **Planning heuristics** — an intent-to-first-tools table and copy-paste prompt guidance for agents. +9. **Anti-patterns** — the behaviors to avoid. + +## Example + +A task like "update the director of the movie with the slug interstellar" resolves through the skill's flow: + +1. Call `saltus/get-health` → `status: ok`. +2. Call `saltus/list-models` → find the `movie` post type. +3. Call `saltus/get-meta-fields` with `post_type: "movie"` → normalized path `director` maps to the writable meta root. +4. Call `saltus/list-posts` with `post_type: "movie"`, `search: "interstellar"` → confirm the post ID with the user. +5. Call `saltus/get-post` → read current `director` value. +6. Summarize: "Update `director` on post 42 (Interstellar) from 'Nolan' to 'Christopher Nolan'?" +7. Call `saltus/update-post` with `post_id: 42`, `meta: { director: "Christopher Nolan" }`. +8. Call `saltus/get-post` again → report the confirmed value. + +## Keeping it in sync + +- The authoritative copy lives in the repository at `.agents/skills/saltus-mcp/SKILL.md`. +- The docs site serves a copy at `/downloads/saltus-mcp/SKILL.md` for direct download. +- The skill mirrors [Client Integration](/mcp/clients); when the client guidance changes, update both. + +## Further Reading + +- [MCP/Abilities Overview](/mcp/index) +- [Abilities Reference](/mcp/abilities) +- [Client Integration](/mcp/clients) 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/docs/public/downloads/saltus-mcp/SKILL.md b/docs/public/downloads/saltus-mcp/SKILL.md new file mode 100644 index 00000000..9a476cc4 --- /dev/null +++ b/docs/public/downloads/saltus-mcp/SKILL.md @@ -0,0 +1,199 @@ +--- +name: saltus-mcp +description: "Use when connecting an AI agent/client to Saltus Framework MCP/Abilities (saltus/*) on an active WordPress site — discovery, health-first flow, model/meta inspection, safe read/write patterns, permission and rate-limit handling, and write confirmation flows." +compatibility: "Requires WordPress 7.0+ with the Abilities API and an active plugin built on Saltus Framework. Client-side skill; no PHP required on the agent side." +--- + +# Saltus MCP + +Connect an AI client to the WordPress-native MCP/Abilities surface exposed by Saltus Framework. + +## When to use + +Use this skill when the task involves: + +- consuming `saltus/*` abilities from an AI client or editor agent, +- discovering what post types, taxonomies, meta fields, and tools a Saltus site exposes, +- reading or writing WordPress content through Saltus tools, +- diagnosing `saltus/*` call failures (permissions, params, rate limits, model visibility). + +## Prerequisites + +- A running WordPress site (7.0+) with the Abilities API. +- An active plugin using Saltus Framework (registers `saltus/*` abilities on `wp_abilities_api_init`). +- Client access to the abilities (discovery list, or a configured tool list). +- The site URL and a user with the needed WordPress capabilities. + +## Discovery + +- Discover abilities from WordPress and filter for the `saltus/` prefix. +- Treat the ability definitions as the source of truth for tool names, input schemas, permissions, and transport metadata. +- Each Saltus ability carries metadata like: + +```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 (e.g. `saltus/list-models`) for native execution. + +## Recommended call flow + +1. `saltus/get-health` +2. `saltus/list-models` +3. `saltus/list-meta-fields` +4. Choose the relevant model +5. `saltus/get-model` or `saltus/get-meta-fields` for 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 target model, fields, and permissions are clear + +For narrow workflows that already know the post type, skip the aggregate metadata call and use `saltus/get-meta-fields` directly. + +## Health first + +Call `saltus/get-health` at session start. + +| Health signal | Client behavior | +|---------------|-----------------| +| `status: ok` | Continue normally | +| `status: degraded` | Prefer read-only planning; explain the degraded state before writes | +| High `error_rate` | Avoid repeated retries; inspect permission and input errors | +| High latency | Reduce broad listing calls; keep page sizes modest | +| Rate limit enabled | Respect rate-limit errors and `retry_after` data | + +Health is framework-scoped; it does not prove a specific model or write is available. + +## Model discovery + +- Use `saltus/list-models` to discover post types and taxonomies. Never assume a model exists from a user phrase. +- Map user language to model names after reading labels and slugs; prefer exact slugs. +- If multiple models are plausible, ask the user to choose. +- Treat missing models as configuration or permission issues, not empty content. + +## Metadata discovery + +- Use `saltus/list-meta-fields` for site-wide discovery; `saltus/get-meta-fields` for one post type. +- Saltus returns raw config in `meta` and normalized data in `normalized.fields` (flattened paths) plus `normalized.rest_meta_keys`. +- Prefer `normalized.fields` for reasoning. Example paths: + +```text +points_info.coordinates.latitude +points_info.coordinates.longitude +points_info.tooltipContent +``` + +- When writing post meta, map the field path back to its writable REST meta root. Update the containing serialized structure carefully; do not send only the leaf path. + +## Safe read patterns + +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 task. For terms: discover the taxonomy with `saltus/list-models`, call `saltus/list-terms`, and use returned term IDs in create/update calls. + +## Safe write patterns + +Before creating or updating, know: target post type, target post ID (for updates/deletes), writable meta roots, expected field shape, current user intent, and the relevant capability outcome. + +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 (many posts from one instruction) unless the exact target list is shown and confirmed. + +## Destructive actions + +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; never bypass them. + +| Failure | Likely cause | Client response | +|---------|--------------|-----------------| +| `rest_forbidden` | Current user lacks capability | Explain required access and stop | +| `invalid_params` | Missing or malformed arguments | Fix arguments once, then retry | +| model not found | Model not registered / not REST-enabled / not visible | Ask for a different model or admin config | +| write denied | User can read but not mutate | Offer a read-only summary instead | + +Do not retry permission failures repeatedly; they are stable until role or model config changes. + +## Rate limits + +- 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 responses in WordPress transients. Repeated reads may return cached data. To confirm a mutation: execute the write, let Saltus clear its MCP cache, then read the object again. Mutating tools clear the cache after execution. + +## Planning heuristics + +| 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` | + +## Prompt guidance + +Ground 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 + +- 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 docs: https://docs.saltus.dev/mcp/ +- Abilities reference: https://docs.saltus.dev/mcp/abilities.html +- Client integration guide: https://docs.saltus.dev/mcp/clients.html diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..813dbd74 --- /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.92", + "resolved": "https://registry.npmjs.org/@iconify-json/simple-icons/-/simple-icons-1.2.92.tgz", + "integrity": "sha512-hR0ozxR97t1dzWw+esoxFijZ15gagt7EIgF3CNifu2yICXhS7gnun4Y+j+odJQtNSl7wvqMdoLbViIShwe/fdw==", + "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.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.3.tgz", + "integrity": "sha512-c0wdcekXtQvvn5Tsrk/+op/gUArrbWaFduBnTLP2l1cKLSQs4diMWjJw3m6A0DdzT8dAAX95KpkJ3qynCePbmw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.3.tgz", + "integrity": "sha512-3YjElDdWN+qXAFbJ/CzPV+0wspLqh54k/I6GfdYtEJRqg7buSgc1yPM3B+93j1M4neobtkATHZTmxK2AMVGfnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.3.tgz", + "integrity": "sha512-Pch2pFNOxxz1hTjypIdPyRTR6riiwRl84+VcN9djS680fw+Co1nAJINrdpqp7KV0NvyuU8ilZXZCjd7ykJl1GQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.3.tgz", + "integrity": "sha512-LEuncFUHFiF8t4yZVZvvZA1wk0pjAscRnsrn1EfTEmN4HXotBi2YtcnLRyaK6UbuczW7xZS5ES+81Rdz8Z0T6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.3.tgz", + "integrity": "sha512-zvBUvsQUpOWALdDsk6qbS8bXf2VxmPisuudNDrY7x0p0jBdsoZl8HsHczIOgkQiZldmcacMKtBzpoGVNeIe2bQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.3.tgz", + "integrity": "sha512-C2KmNrcSem/AMg984H/dev+si0lieQGdXdR/lYGJnuumXnFb9Y7QdiI62obFdLlxRYLBv4P0eUVIDbD4c1vVvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.3.tgz", + "integrity": "sha512-ggXnsTAEzNQx74XpunRsiZ9aBZDsI7XIa0hm2nzR9f4WzH5/f/d73ZSDaC5ejJ8YLY4NW+V3wr0tjOaeCq8hqA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.3.tgz", + "integrity": "sha512-2vng+FlzNUhKZxtej3IUqJgbZoQk2M/dwQM20+ULV0R/E/8tr9/P6uEf2iiGIk4HL0zMKh5Jry7mUHdUOvyGgA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.3.tgz", + "integrity": "sha512-LLLFZKt4/Nraf9rxDkhiU8QVgLF4WmCkfr0L4fj0fPfIZFBib0DeiFk1hhaYKd03LFAFJcxHslhDFlNJLylf5Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.3.tgz", + "integrity": "sha512-WJkdQCvS9sWNOUBJZfQRKpZGFBztRzcowI+nndmflKgU4XY+3a420FgTOSKTsVqJbnzSxeT4vaJalpOaPo2YCQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.3.tgz", + "integrity": "sha512-PwHXCCS2n64/1Ot6rP1YEYA02MGYBcQlr8CSZZyrUG2O7NH6NklYmvr9v3Jy+5e/eDeNchc/ukmKJi9LuflMIQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.3.tgz", + "integrity": "sha512-vUjxINQu3RC8NZS3ykk1gN65gIz8pAopOq2HXuZhiIxHdx7TFvDG+jgrdSgInu1Eza4/Rfi2VzZgyIgEH4WOaw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.3.tgz", + "integrity": "sha512-wzko4aJ13+0G3kGnviCg5gnXFKd40izKsrf2uOw12US4XqprkDrmwOpeW14aSNa37V8bfPcz5Fkob6LZ3BAPmA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.3.tgz", + "integrity": "sha512-8120ue0JUMSwy11stlwnfdX3pPd+WZYGCDBwEHWtIHi6pOpZmsEF5QKB7a/UN+XFdqvobxz98kv8RTqikyCEBw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.3.tgz", + "integrity": "sha512-XLFHnR3tXMjbOCh2vtVJHmxt+995uJsTERQyseFDRA0xxMxyTZPLa3OIUlyFaO4mF/Lu0FjmWHCuPXJT1n/IOg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.3.tgz", + "integrity": "sha512-se6yXvNGMIl0f+RQzyh7XAmia8/9kplQx424wnG2w0C1oi6XgO6Y8otKhdXFHbHs88Ihavzmvh1NWjuovE76BQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.3.tgz", + "integrity": "sha512-gNoxRefktVIiGflpONuxWWXZAzIQG++z9qHO3xKwk4WdDMuQja3JHGfE1u0i3PfPDyvhypdk+WrgIJqLhGG7sg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.3.tgz", + "integrity": "sha512-V4KtWtQfAFMU7+9/A/VDps/VI8CHd3cYz0L8sgJzz8qK7eY7wI4ruFD82UYIYvW9Z4DtlTfhQcsl4XyPHW5uSg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.3.tgz", + "integrity": "sha512-LBx9LYXvj2CBkMkjLdNAWLwH0MLMin7do2VcVo9kVPibGLkY0BQQut2fv7NVqkXqZ/CrAu9LqDHVV1xHCMpCPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.3.tgz", + "integrity": "sha512-ABVf3Q0RCu7NcyCCOZQI0pJ3GuSdfSl8EXcy88QtdceIMIoCUdfhsJChZ64L9zVM2aJHjde1Bhn5uqSRcX9ySA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.3.tgz", + "integrity": "sha512-+2Cy/ldweGBLlPIKsQLF8U5N44a0KDdbrk1rAjHOM9M2K+kGdIVjHLmmrZIcx+9Ny3ke/1JomCsDI1ocb11+sg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.3.tgz", + "integrity": "sha512-dtZvzc8BedpSaFNy75x6uiWwAGTH+aZHDtdrqP6qk+WcLJrfti6sGje1ZJ9UxyzDLF23d/mV+PaMwuC0hL7UVA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.3.tgz", + "integrity": "sha512-Rj8Ra4noo+aYy7sKBggCx0407mws34kAb1ySyWuq5DAtFBQdkSwnsjCgPrhPe9cvgBKZIukpE+CVHvORCS93kQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.3.tgz", + "integrity": "sha512-vp7N084ew/odXn2gi/mzm9mUkQu9l6AiN6dt4IeUM2Uvm9o+cVmP+YkqbMOteLbiGgqBBlJZjIMYVCfOOIVbVQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.3.tgz", + "integrity": "sha512-MOG/3gTOn4Fwf574RVOaY61I5o6P90legkFADiTyn1hyjNydT+cerU2rLUwPdZkKKyJ+iT+K9p7WXK4LM1Ka6g==", + "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.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "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.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.3.tgz", + "integrity": "sha512-Gu0c0iH9FzgX1L1t7ByIbbS3Vmdz+6KHm/EsqmmC71gUQ82yvZRkTK6XzrFObSka91WUVdynqp6nsfilzr5k6Q==", + "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.3", + "@rollup/rollup-android-arm64": "4.62.3", + "@rollup/rollup-darwin-arm64": "4.62.3", + "@rollup/rollup-darwin-x64": "4.62.3", + "@rollup/rollup-freebsd-arm64": "4.62.3", + "@rollup/rollup-freebsd-x64": "4.62.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.3", + "@rollup/rollup-linux-arm-musleabihf": "4.62.3", + "@rollup/rollup-linux-arm64-gnu": "4.62.3", + "@rollup/rollup-linux-arm64-musl": "4.62.3", + "@rollup/rollup-linux-loong64-gnu": "4.62.3", + "@rollup/rollup-linux-loong64-musl": "4.62.3", + "@rollup/rollup-linux-ppc64-gnu": "4.62.3", + "@rollup/rollup-linux-ppc64-musl": "4.62.3", + "@rollup/rollup-linux-riscv64-gnu": "4.62.3", + "@rollup/rollup-linux-riscv64-musl": "4.62.3", + "@rollup/rollup-linux-s390x-gnu": "4.62.3", + "@rollup/rollup-linux-x64-gnu": "4.62.3", + "@rollup/rollup-linux-x64-musl": "4.62.3", + "@rollup/rollup-openbsd-x64": "4.62.3", + "@rollup/rollup-openharmony-arm64": "4.62.3", + "@rollup/rollup-win32-arm64-msvc": "4.62.3", + "@rollup/rollup-win32-ia32-msvc": "4.62.3", + "@rollup/rollup-win32-x64-gnu": "4.62.3", + "@rollup/rollup-win32-x64-msvc": "4.62.3", + "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..6f159126 --- /dev/null +++ b/package.json @@ -0,0 +1,31 @@ +{ + "name": "saltus-framework-git", + "version": "1.2.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/phpDocumentor.phar b/phpDocumentor.phar new file mode 100755 index 00000000..a5619683 Binary files /dev/null and b/phpDocumentor.phar differ 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 @@ +<?xml version="1.0" encoding="UTF-8" ?> +<phpdocumentor + configVersion="3" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xmlns="https://www.phpdoc.org" +> + <title>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..5fc0b531 100644 --- a/src/Core.php +++ b/src/Core.php @@ -3,6 +3,7 @@ * Saltus Framework * * @version 2.0.0 + * @api */ namespace Saltus\WP\Framework; @@ -31,6 +32,8 @@ use Saltus\WP\Framework\Features\Settings\Settings; use Saltus\WP\Framework\Features\SingleExport\SingleExport; use Saltus\WP\Framework\Features\MCP\MCP; +use Saltus\WP\Framework\Features\Blocks\Blocks; +use Saltus\WP\Framework\Features\WpCli\WpCli; use Saltus\WP\Framework\MCP\Tools\ToolContributor; use Saltus\WP\Framework\Rest\HealthController; use Saltus\WP\Framework\Rest\ModelRestPolicy; @@ -346,6 +349,7 @@ protected function get_service_classes(): array { return [ 'admin_cols' => AdminCols::class, 'admin_filters' => AdminFilters::class, + 'blocks' => Blocks::class, 'draganddrop' => DragAndDrop::class, 'duplicate' => Duplicate::class, 'meta' => Meta::class, @@ -354,6 +358,7 @@ protected function get_service_classes(): array { 'remember_tabs' => RememberTabs::class, 'settings' => Settings::class, 'single_export' => SingleExport::class, + 'wp_cli' => WpCli::class, ]; } 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/Blocks/BlockRenderer.php b/src/Features/Blocks/BlockRenderer.php new file mode 100644 index 00000000..280a122d --- /dev/null +++ b/src/Features/Blocks/BlockRenderer.php @@ -0,0 +1,208 @@ + */ + private array $project; + + /** @param array $project Project paths. */ + public function __construct( array $project = [] ) { + $this->project = $project; + } + + /** + * @param array $definition Block/model definition. + * @param array $attributes Block attributes. + */ + public function render( string $view, array $definition, array $attributes ): string { + if ( $view === 'list' ) { + return $this->render_list( $definition, $attributes ); + } + if ( $view === 'single' ) { + return $this->render_single( $definition, $attributes ); + } + return ''; + } + + /** + * @param array $definition Block definition. + * @param array $attributes Block attributes. + */ + private function render_list( array $definition, array $attributes ): string { + $attributes = $this->normalize_list_attributes( $attributes ); + $query_args = [ + 'post_type' => $definition['post_type'], + 'post_status' => 'publish', + 'posts_per_page' => $attributes['postsToShow'], + 'orderby' => $attributes['orderBy'], + 'order' => $attributes['order'], + ]; + + if ( $attributes['taxonomy'] !== '' && $attributes['terms'] !== [] && $this->taxonomy_is_valid( $attributes['taxonomy'], (string) $definition['post_type'] ) ) { + $query_args['tax_query'] = [ + [ + 'taxonomy' => $attributes['taxonomy'], + 'field' => 'slug', + 'terms' => $attributes['terms'], + ], + ]; + } + + $query_args = apply_filters( 'saltus/framework/blocks/query_args', $query_args, $definition, $attributes ); + $query = new \WP_Query( is_array( $query_args ) ? $query_args : [] ); + $posts = $query->posts; + $meta_fields = $this->selected_meta_fields( $definition, $attributes ); + $meta_by_post = []; + foreach ( $posts as $post ) { + if ( $post instanceof \WP_Post ) { + $meta_by_post[ $post->ID ] = $this->meta_values( $post->ID, $meta_fields ); + } + } + + return $this->render_template( 'list', $definition, $attributes, compact( 'posts', 'meta_fields', 'meta_by_post' ) ); + } + + /** + * @param array $definition Block definition. + * @param array $attributes Block attributes. + */ + private function render_single( array $definition, array $attributes ): string { + $post_id = max( 0, (int) ( $attributes['postId'] ?? 0 ) ); + $post = $post_id > 0 ? get_post( $post_id ) : null; + if ( ! $post instanceof \WP_Post || $post->post_type !== $definition['post_type'] || $post->post_status !== 'publish' ) { + return ''; + } + + $attributes = [ + 'postId' => $post_id, + 'showTitle' => ! array_key_exists( 'showTitle', $attributes ) || (bool) $attributes['showTitle'], + 'showContent' => ! array_key_exists( 'showContent', $attributes ) || (bool) $attributes['showContent'], + 'metaFields' => is_array( $attributes['metaFields'] ?? null ) ? $attributes['metaFields'] : [], + ]; + $meta_fields = $this->selected_meta_fields( $definition, $attributes ); + $meta = $this->meta_values( $post_id, $meta_fields ); + + return $this->render_template( 'single', $definition, $attributes, compact( 'post', 'meta', 'meta_fields' ) ); + } + + /** + * @param array $attributes Block attributes. + * @return array + */ + // phpcs:ignore Generic.Metrics.CyclomaticComplexity.TooHigh -- Attribute normalization validates independent inputs. + private function normalize_list_attributes( array $attributes ): array { + $allowed_orderby = [ 'date', 'title', 'modified', 'menu_order', 'ID' ]; + $orderby = (string) ( $attributes['orderBy'] ?? 'date' ); + $order = strtoupper( (string) ( $attributes['order'] ?? 'DESC' ) ); + $terms = is_array( $attributes['terms'] ?? null ) ? $attributes['terms'] : []; + + return [ + 'postsToShow' => max( 1, min( 100, (int) ( $attributes['postsToShow'] ?? 10 ) ) ), + 'orderBy' => in_array( $orderby, $allowed_orderby, true ) ? $orderby : 'date', + 'order' => in_array( $order, [ 'ASC', 'DESC' ], true ) ? $order : 'DESC', + 'taxonomy' => sanitize_key( (string) ( $attributes['taxonomy'] ?? '' ) ), + 'terms' => array_values( array_filter( array_map( 'sanitize_key', $terms ) ) ), + 'showExcerpt' => ! array_key_exists( 'showExcerpt', $attributes ) || (bool) $attributes['showExcerpt'], + 'showDate' => ! array_key_exists( 'showDate', $attributes ) || (bool) $attributes['showDate'], + 'metaFields' => is_array( $attributes['metaFields'] ?? null ) ? $attributes['metaFields'] : [], + ]; + } + + private function taxonomy_is_valid( string $taxonomy, string $post_type ): bool { + return function_exists( 'is_object_in_taxonomy' ) && is_object_in_taxonomy( $post_type, $taxonomy ); + } + + /** + * @param array $definition Block definition. + * @param array $attributes Block attributes. + * @return list> + */ + private function selected_meta_fields( array $definition, array $attributes ): array { + $selected = is_array( $attributes['metaFields'] ?? null ) ? array_map( 'strval', $attributes['metaFields'] ) : []; + $fields = is_array( $definition['meta_fields'] ?? null ) ? $definition['meta_fields'] : []; + return array_values( + array_filter( + $fields, + static function ( $field ) use ( $selected ): bool { + return is_array( $field ) && isset( $field['path'] ) && in_array( (string) $field['path'], $selected, true ); + } + ) + ); + } + + /** + * @param list> $fields Normalized meta fields. + * @return array + */ + private function meta_values( int $post_id, array $fields ): array { + $values = []; + foreach ( $fields as $field ) { + $path = (string) ( $field['path'] ?? '' ); + $meta_key = (string) ( $field['meta_key'] ?? $path ); + if ( $path === '' || $meta_key === '' ) { + continue; + } + $value = get_post_meta( $post_id, $meta_key, true ); + if ( ! empty( $field['serialized'] ) && $path !== $meta_key ) { + $segments = explode( '.', substr( $path, strlen( $meta_key ) + 1 ) ); + foreach ( $segments as $segment ) { + if ( ! is_array( $value ) || ! array_key_exists( $segment, $value ) ) { + $value = null; + break; + } + $value = $value[ $segment ]; + } + } + $values[ $path ] = $value; + } + return $values; + } + + /** + * @param array $definition + * @param array $attributes + * @param array $variables + */ + private function render_template( string $view, array $definition, array $attributes, array $variables ): string { + $template = $this->template_path( $view, $definition ); + $template = apply_filters( 'saltus/framework/blocks/template', $template, $view, $definition ); + if ( ! is_string( $template ) || ! is_file( $template ) ) { + return ''; + } + + $model = $definition['model']; + extract( $variables, EXTR_SKIP ); // phpcs:ignore WordPress.PHP.DontExtract.extract_extract + ob_start(); + include $template; + $content = (string) ob_get_clean(); + $wrapper = function_exists( 'get_block_wrapper_attributes' ) ? get_block_wrapper_attributes( [ 'class' => 'saltus-block saltus-block--' . $view ] ) : 'class="saltus-block saltus-block--' . esc_attr( $view ) . '"'; + return '
' . $content . '
'; + } + + /** @param array $definition */ + private function template_path( string $view, array $definition ): string { + $configured = (string) ( $definition['templates'][ $view ] ?? '' ); + $project_path = (string) ( $this->project['path'] ?? '' ); + if ( $configured !== '' && $project_path !== '' ) { + $root = realpath( $project_path ); + $candidate = realpath( rtrim( $project_path, '/\\' ) . '/' . ltrim( $configured, '/\\' ) ); + if ( $root !== false && $candidate !== false && strpos( $candidate, $root . DIRECTORY_SEPARATOR ) === 0 && is_file( $candidate ) ) { + return $candidate; + } + } + + if ( function_exists( 'locate_template' ) ) { + $theme = locate_template( [ 'saltus/' . $definition['post_type'] . '/block-' . $view . '.php' ], false, false ); + if ( $theme !== '' ) { + return $theme; + } + } + + return dirname( __DIR__, 3 ) . '/templates/blocks/' . $view . '.php'; + } +} diff --git a/src/Features/Blocks/Blocks.php b/src/Features/Blocks/Blocks.php new file mode 100644 index 00000000..ae5528ac --- /dev/null +++ b/src/Features/Blocks/Blocks.php @@ -0,0 +1,71 @@ + */ + private array $project; + + /** + * @param array $dependencies Framework dependencies. + */ + public function __construct( array $dependencies = [] ) { + $this->modeler_resolver = is_callable( $dependencies['modeler_resolver'] ?? null ) ? $dependencies['modeler_resolver'] : null; + $this->project = is_array( $dependencies['project'] ?? null ) ? $dependencies['project'] : []; + } + + public function register(): void { + add_action( + 'init', + function (): void { + $modeler = $this->resolve_modeler(); + if ( $modeler instanceof Modeler ) { + ( new SaltusBlocks( $modeler, $this->project ) )->register(); + } + }, + 20 + ); + } + + /** @return list */ + public function get_rest_routes( Modeler $modeler, ModelRestPolicy $policy ): array { + return [ + new RestRouteDefinition( + ModelRestPolicy::CAPABILITY_BLOCKS, + new BlocksController( $modeler, $policy ), + 'post_type' + ), + ]; + } + + /** @return list */ + public function get_mcp_tools( Modeler $modeler, ?ModelRestPolicy $policy = null ): array { + return [ new ListBlockModels() ]; + } + + private function resolve_modeler(): ?Modeler { + if ( ! is_callable( $this->modeler_resolver ) ) { + return null; + } + + $modeler = ( $this->modeler_resolver )(); + return $modeler instanceof Modeler ? $modeler : null; + } +} diff --git a/src/Features/Blocks/SaltusBlocks.php b/src/Features/Blocks/SaltusBlocks.php new file mode 100644 index 00000000..102e5d2c --- /dev/null +++ b/src/Features/Blocks/SaltusBlocks.php @@ -0,0 +1,269 @@ + */ + private array $project; + private MetaFieldProvider $meta_field_provider; + private BlockRenderer $renderer; + + /** + * @param array $project Project paths and URLs. + */ + public function __construct( Modeler $modeler, array $project = [], ?MetaFieldProvider $meta_field_provider = null, ?BlockRenderer $renderer = null ) { + $this->modeler = $modeler; + $this->project = $project; + $this->meta_field_provider = $meta_field_provider ?? new MetaFieldProvider(); + $this->renderer = $renderer ?? new BlockRenderer( $project ); + } + + public function register(): void { + $definitions = $this->definitions(); + if ( $definitions === [] ) { + return; + } + + $this->register_assets( $definitions ); + foreach ( $definitions as $definition ) { + foreach ( $definition['blocks'] as $view => $block ) { + $args = [ + 'api_version' => 3, + 'title' => $block['title'], + 'description' => $block['description'], + 'category' => 'widgets', + 'icon' => $view === 'list' ? 'list-view' : 'media-document', + 'attributes' => $block['attributes'], + 'editor_script' => self::EDITOR_HANDLE, + 'style' => self::STYLE_HANDLE, + 'render_callback' => function ( array $attributes ) use ( $definition, $view ): string { + return $this->renderer->render( $view, $definition, $attributes ); + }, + ]; + $args = apply_filters( 'saltus/framework/blocks/attributes', $args, $definition['post_type'], $view ); + register_block_type( $block['name'], is_array( $args ) ? $args : [] ); + } + } + } + + /** + * @return list> + */ + public function definitions(): array { + $definitions = []; + foreach ( $this->modeler->get_models() as $model ) { + if ( $model->get_type() !== 'post_type' ) { + continue; + } + + $config = self::normalize_config( $model->get_config()['blocks'] ?? null ); + if ( ! $config['list'] && ! $config['single'] ) { + continue; + } + + $definitions[] = $this->definition( $model, $config ); + } + + return $definitions; + } + + /** + * @param mixed $value Raw blocks config. + * @return array{list: bool, single: bool, templates: array} + */ + public static function normalize_config( $value ): array { + if ( $value === true ) { + return [ + 'list' => true, + 'single' => true, + 'templates' => [], + ]; + } + + if ( ! is_array( $value ) ) { + return [ + 'list' => false, + 'single' => false, + 'templates' => [], + ]; + } + + $templates = is_array( $value['templates'] ?? null ) ? $value['templates'] : []; + return [ + 'list' => ! empty( $value['list'] ), + 'single' => ! empty( $value['single'] ), + 'templates' => array_filter( $templates, 'is_string' ), + ]; + } + + /** + * @param array{list: bool, single: bool, templates: array} $config + * @return array + */ + private function definition( Model $model, array $config ): array { + $args = $model->get_args(); + $post_type = $model->get_name(); + $label = (string) ( $args['labels']['singular_name'] ?? $post_type ); + $plural = (string) ( $args['labels']['name'] ?? $label ); + $meta = is_array( $args['meta'] ?? null ) ? $args['meta'] : []; + $normalized = $this->meta_field_provider->normalize_meta_fields( $meta ); + $field_paths = []; + foreach ( $normalized['fields'] as $field ) { + if ( isset( $field['path'] ) ) { + $field_paths[] = (string) $field['path']; + } + } + + $blocks = []; + if ( $config['list'] ) { + /* translators: %s: plural post type label. */ + $list_title = sprintf( __( '%s List', 'saltus-framework' ), $plural ); + /* translators: %s: plural post type label. */ + $list_description = sprintf( __( 'Display a list of %s.', 'saltus-framework' ), $plural ); + $blocks['list'] = [ + 'name' => 'saltus/' . sanitize_key( $post_type ) . '-list', + 'title' => $list_title, + 'description' => $list_description, + 'attributes' => $this->list_attributes( $field_paths ), + ]; + } + if ( $config['single'] ) { + /* translators: %s: singular post type label. */ + $single_title = sprintf( __( '%s Single', 'saltus-framework' ), $label ); + /* translators: %s: singular post type label. */ + $single_description = sprintf( __( 'Display one %s.', 'saltus-framework' ), $label ); + $blocks['single'] = [ + 'name' => 'saltus/' . sanitize_key( $post_type ) . '-single', + 'title' => $single_title, + 'description' => $single_description, + 'attributes' => $this->single_attributes( $field_paths ), + ]; + } + + return [ + 'post_type' => $post_type, + 'label' => $label, + 'label_plural' => $plural, + 'blocks' => $blocks, + 'meta_fields' => $normalized['fields'], + 'templates' => $config['templates'], + 'model' => $model, + ]; + } + + /** + * @param list $field_paths Normalized field paths. + * @return array + */ + private function list_attributes( array $field_paths ): array { + return [ + 'postsToShow' => [ + 'type' => 'number', + 'default' => 10, + ], + 'order' => [ + 'type' => 'string', + 'default' => 'DESC', + ], + 'orderBy' => [ + 'type' => 'string', + 'default' => 'date', + ], + 'taxonomy' => [ + 'type' => 'string', + 'default' => '', + ], + 'terms' => [ + 'type' => 'array', + 'default' => [], + 'items' => [ 'type' => 'string' ], + ], + 'showExcerpt' => [ + 'type' => 'boolean', + 'default' => true, + ], + 'showDate' => [ + 'type' => 'boolean', + 'default' => true, + ], + 'metaFields' => [ + 'type' => 'array', + 'default' => $field_paths, + 'items' => [ 'type' => 'string' ], + ], + ]; + } + + /** + * @param list $field_paths Normalized field paths. + * @return array + */ + private function single_attributes( array $field_paths ): array { + return [ + 'postId' => [ + 'type' => 'number', + 'default' => 0, + ], + 'showTitle' => [ + 'type' => 'boolean', + 'default' => true, + ], + 'showContent' => [ + 'type' => 'boolean', + 'default' => true, + ], + 'metaFields' => [ + 'type' => 'array', + 'default' => $field_paths, + 'items' => [ 'type' => 'string' ], + ], + ]; + } + + /** @param list> $definitions Block definitions. */ + private function register_assets( array $definitions ): void { + $root_url = rtrim( (string) ( $this->project['root_url'] ?? '' ), '/' ); + wp_enqueue_script( + self::EDITOR_HANDLE, + $root_url . '/Feature/Blocks/editor.js', + [ 'wp-blocks', 'wp-block-editor', 'wp-components', 'wp-element', 'wp-i18n', 'wp-server-side-render' ], + \Saltus\WP\Framework\Core::VERSION, + true + ); + wp_enqueue_style( self::STYLE_HANDLE, $root_url . '/Feature/Blocks/style.css', [], \Saltus\WP\Framework\Core::VERSION ); + wp_localize_script( self::EDITOR_HANDLE, 'saltusBlockDefinitions', [ 'items' => $this->editor_definitions( $definitions ) ] ); + } + + /** + * @param list> $definitions Block definitions. + * @return list> + */ + private function editor_definitions( array $definitions ): array { + $output = []; + foreach ( $definitions as $definition ) { + foreach ( $definition['blocks'] as $view => $block ) { + $output[] = [ + 'name' => $block['name'], + 'title' => $block['title'], + 'description' => $block['description'], + 'view' => $view, + 'attributes' => $block['attributes'], + 'metaFields' => $definition['meta_fields'], + ]; + } + } + return $output; + } +} 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/Features/WpCli/CliGateway.php b/src/Features/WpCli/CliGateway.php new file mode 100644 index 00000000..0b637e45 --- /dev/null +++ b/src/Features/WpCli/CliGateway.php @@ -0,0 +1,17 @@ +> $items Rows to format. + * @param list $fields Fields to display. + */ + public function format_items( string $format, array $items, array $fields ): void; + + public function line( string $message ): void; + public function success( string $message ): void; + public function error( string $message ): void; +} diff --git a/src/Features/WpCli/CommandCatalog.php b/src/Features/WpCli/CommandCatalog.php new file mode 100644 index 00000000..1f6acbb4 --- /dev/null +++ b/src/Features/WpCli/CommandCatalog.php @@ -0,0 +1,111 @@ + */ + public static function all(): array { + return [ + [ + 'ability' => 'get_health', + 'command' => 'wp saltus health', + 'description' => 'Show framework health and runtime metrics.', + ], + [ + 'ability' => 'list_models', + 'command' => 'wp saltus model list', + 'description' => 'List loaded Saltus models.', + ], + [ + 'ability' => 'get_model', + 'command' => 'wp saltus model get ', + 'description' => 'Show one loaded model.', + ], + [ + 'ability' => 'list_posts', + 'command' => 'wp saltus post list ', + 'description' => 'List posts for a post type.', + ], + [ + 'ability' => 'get_post', + 'command' => 'wp saltus post get ', + 'description' => 'Show one post.', + ], + [ + 'ability' => 'create_post', + 'command' => 'wp saltus post create ', + 'description' => 'Create a post.', + ], + [ + 'ability' => 'update_post', + 'command' => 'wp saltus post update <id>', + 'description' => 'Update a post.', + ], + [ + 'ability' => 'delete_post', + 'command' => 'wp saltus post delete <id>', + 'description' => 'Delete or trash a post.', + ], + [ + 'ability' => 'duplicate_post', + 'command' => 'wp saltus post duplicate <id>', + 'description' => 'Duplicate a post as a draft.', + ], + [ + 'ability' => 'export_post', + 'command' => 'wp saltus post export <id>', + 'description' => 'Export one post as WXR.', + ], + [ + 'ability' => 'list_terms', + 'command' => 'wp saltus term list <taxonomy>', + 'description' => 'List taxonomy terms.', + ], + [ + 'ability' => 'create_term', + 'command' => 'wp saltus term create <taxonomy> <name>', + 'description' => 'Create a taxonomy term.', + ], + [ + 'ability' => 'get_settings', + 'command' => 'wp saltus settings get <post-type>', + 'description' => 'Read model settings.', + ], + [ + 'ability' => 'update_settings', + 'command' => 'wp saltus settings update <post-type> <json|@file>', + 'description' => 'Update model settings.', + ], + [ + 'ability' => 'list_meta_fields', + 'command' => 'wp saltus meta list', + 'description' => 'List model meta fields.', + ], + [ + 'ability' => 'get_meta_fields', + 'command' => 'wp saltus meta get <post-type>', + 'description' => 'Show one model meta schema.', + ], + [ + 'ability' => 'update_meta_fields', + 'command' => 'wp saltus meta update <post-type> <post-id> <json|@file>', + 'description' => 'Update registered post meta.', + ], + [ + 'ability' => 'reorder_posts', + 'command' => 'wp saltus reorder <json|@file>', + 'description' => 'Update post menu order.', + ], + [ + 'ability' => 'list_block_models', + 'command' => 'wp saltus block list', + 'description' => 'List model-driven blocks.', + ], + ]; + } + + /** @return list<string> */ + public static function abilities(): array { + return array_column( self::all(), 'ability' ); + } +} diff --git a/src/Features/WpCli/Commands/AbstractCommand.php b/src/Features/WpCli/Commands/AbstractCommand.php new file mode 100644 index 00000000..7b8244b6 --- /dev/null +++ b/src/Features/WpCli/Commands/AbstractCommand.php @@ -0,0 +1,97 @@ +<?php +namespace Saltus\WP\Framework\Features\WpCli\Commands; + +use Saltus\WP\Framework\Features\WpCli\CliGateway; +use Saltus\WP\Framework\Modeler; + +abstract class AbstractCommand { + protected CliGateway $cli; + /** @var callable */ + private $modeler_resolver; + + public function __construct( CliGateway $cli, callable $modeler_resolver ) { + $this->cli = $cli; + $this->modeler_resolver = $modeler_resolver; + } + + protected function modeler(): Modeler { + $modeler = ( $this->modeler_resolver )(); + if ( ! $modeler instanceof Modeler ) { + $this->fail( 'Saltus models are not loaded.' ); + } + return $modeler; + } + + /** @param array<string, mixed> $assoc_args */ + protected function format( array $assoc_args ): string { + $format = strtolower( (string) ( $assoc_args['format'] ?? 'table' ) ); + return in_array( $format, [ 'table', 'json', 'yaml' ], true ) ? $format : 'table'; + } + + /** @return array<mixed> */ + private function decode_json( string $input ): array { + if ( strpos( $input, '@' ) === 0 ) { + $path = substr( $input, 1 ); + if ( $path === '' || ! is_readable( $path ) ) { + $this->fail( 'JSON file is not readable: ' . $path ); + } + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Explicit local CLI input. + $contents = file_get_contents( $path ); + $input = is_string( $contents ) ? $contents : ''; + } + + $data = json_decode( $input, true ); + if ( ! is_array( $data ) || json_last_error() !== JSON_ERROR_NONE ) { + $this->fail( 'Expected valid JSON object or array.' ); + } + return $data; + } + + /** @return array<string, mixed> */ + protected function json_object( string $input ): array { + $data = $this->decode_json( $input ); + $object = []; + foreach ( $data as $key => $value ) { + if ( ! is_string( $key ) ) { + $this->fail( 'Expected a JSON object.' ); + continue; + } + $object[ $key ] = $value; + } + return $object; + } + + /** @return array<int, mixed> */ + protected function json_list( string $input ): array { + $data = $this->decode_json( $input ); + if ( array_keys( $data ) !== range( 0, count( $data ) - 1 ) && $data !== [] ) { + $this->fail( 'Expected a JSON array.' ); + } + return array_values( $data ); + } + + /** + * @param mixed $result Operation result. + * @return mixed + */ + protected function ensure_result( $result ) { + if ( is_wp_error( $result ) ) { + $this->fail( $result->get_error_message() ); + } + return $result; + } + + /** @param list<string> $args */ + protected function argument( array $args, int $index, string $name ): string { + $value = isset( $args[ $index ] ) ? trim( (string) $args[ $index ] ) : ''; + if ( $value === '' ) { + $this->fail( 'Missing required argument: ' . $name ); + } + return $value; + } + + protected function fail( string $message ): void { + $this->cli->error( $message ); + throw new \RuntimeException( $message ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal CLI fallback. + } +} diff --git a/src/Features/WpCli/Commands/BlockCommand.php b/src/Features/WpCli/Commands/BlockCommand.php new file mode 100644 index 00000000..4c2a0d1e --- /dev/null +++ b/src/Features/WpCli/Commands/BlockCommand.php @@ -0,0 +1,26 @@ +<?php +namespace Saltus\WP\Framework\Features\WpCli\Commands; + +use Saltus\WP\Framework\Features\Blocks\SaltusBlocks; + +final class BlockCommand extends AbstractCommand { + /** + * @param list<string> $args Positional arguments. + * @param array<string, mixed> $assoc_args Associative arguments. + */ + public function list( array $args, array $assoc_args ): void { + $definitions = ( new SaltusBlocks( $this->modeler() ) )->definitions(); + $rows = []; + foreach ( $definitions as $definition ) { + foreach ( $definition['blocks'] as $view => $block ) { + $rows[] = [ + 'post_type' => $definition['post_type'], + 'view' => $view, + 'block' => $block['name'], + 'fields' => count( $definition['meta_fields'] ), + ]; + } + } + $this->cli->format_items( $this->format( $assoc_args ), $rows, [ 'post_type', 'view', 'block', 'fields' ] ); + } +} diff --git a/src/Features/WpCli/Commands/MetaCommand.php b/src/Features/WpCli/Commands/MetaCommand.php new file mode 100644 index 00000000..f48c026b --- /dev/null +++ b/src/Features/WpCli/Commands/MetaCommand.php @@ -0,0 +1,67 @@ +<?php +namespace Saltus\WP\Framework\Features\WpCli\Commands; + +use Saltus\WP\Framework\Features\Meta\MetaFieldProvider; +use Saltus\WP\Framework\MCP\Tools\UpdateMetaFields; +use Saltus\WP\Framework\Rest\ModelRestPolicy; + +final class MetaCommand extends AbstractCommand { + private MetaFieldProvider $provider; + + public function __construct( \Saltus\WP\Framework\Features\WpCli\CliGateway $cli, callable $modeler_resolver, ?MetaFieldProvider $provider = null ) { + parent::__construct( $cli, $modeler_resolver ); + $this->provider = $provider ?? new MetaFieldProvider(); + } + + /** + * @param list<string> $args Positional arguments. + * @param array<string, mixed> $assoc_args Associative arguments. + */ + public function list( array $args, array $assoc_args ): void { + $modeler = $this->modeler(); + $items = $this->provider->all_post_type_meta( + $modeler, + null, + static function (): bool { + return true; + } + ); + $rows = []; + foreach ( $items as $item ) { + $rows[] = [ + 'post_type' => $item['post_type'], + 'fields' => count( $item['normalized']['fields'] ?? [] ), + 'writable' => count( $item['normalized']['rest_meta_keys'] ?? [] ), + ]; + } + $this->cli->format_items( $this->format( $assoc_args ), $rows, [ 'post_type', 'fields', 'writable' ] ); + } + + /** + * @param list<string> $args Positional arguments. + * @param array<string, mixed> $assoc_args Associative arguments. + */ + public function get( array $args, array $assoc_args ): void { + $post_type = sanitize_key( $this->argument( $args, 0, 'post-type' ) ); + $result = $this->ensure_result( $this->provider->post_type_meta( $this->modeler(), null, $post_type ) ); + $row = [ + 'post_type' => $post_type, + 'meta' => wp_json_encode( $result ), + ]; + $this->cli->format_items( $this->format( $assoc_args ), [ $row ], [ 'post_type', 'meta' ] ); + } + + /** + * @param list<string> $args Positional arguments. + * @param array<string, mixed> $assoc_args Associative arguments. + */ + public function update( array $args, array $assoc_args ): void { + $post_type = sanitize_key( $this->argument( $args, 0, 'post-type' ) ); + $post_id = (int) $this->argument( $args, 1, 'post-id' ); + $payload = $this->json_object( $this->argument( $args, 2, 'json|@file' ) ); + $modeler = $this->modeler(); + $result = ( new UpdateMetaFields( $this->provider ) )->update_meta_fields( $modeler, new ModelRestPolicy( $modeler ), $post_type, $post_id, $payload ); + $this->ensure_result( $result ); + $this->cli->success( 'Updated meta for post ' . $post_id . '.' ); + } +} diff --git a/src/Features/WpCli/Commands/ModelCommand.php b/src/Features/WpCli/Commands/ModelCommand.php new file mode 100644 index 00000000..e4219298 --- /dev/null +++ b/src/Features/WpCli/Commands/ModelCommand.php @@ -0,0 +1,44 @@ +<?php +namespace Saltus\WP\Framework\Features\WpCli\Commands; + +final class ModelCommand extends AbstractCommand { + /** + * @param list<string> $args Positional arguments. + * @param array<string, mixed> $assoc_args Associative arguments. + */ + public function list( array $args, array $assoc_args ): void { + $type = (string) ( $assoc_args['type'] ?? '' ); + $rows = []; + foreach ( $this->modeler()->get_models() as $model ) { + if ( $type !== '' && $model->get_type() !== $type ) { + continue; + } + $model_args = $model->get_args(); + $rows[] = [ + 'name' => $model->get_name(), + 'type' => $model->get_type(), + 'label' => $model_args['labels']['name'] ?? $model->get_name(), + ]; + } + $this->cli->format_items( $this->format( $assoc_args ), $rows, [ 'name', 'type', 'label' ] ); + } + + /** + * @param list<string> $args Positional arguments. + * @param array<string, mixed> $assoc_args Associative arguments. + */ + public function get( array $args, array $assoc_args ): void { + $name = $this->argument( $args, 0, 'slug' ); + $model = $this->modeler()->get_models()[ $name ] ?? null; + if ( $model === null ) { + $this->fail( 'Model not found: ' . $name ); + } + $row = [ + 'name' => $model->get_name(), + 'type' => $model->get_type(), + 'options' => wp_json_encode( $model->get_options() ), + 'config' => wp_json_encode( $model->get_config() ), + ]; + $this->cli->format_items( $this->format( $assoc_args ), [ $row ], array_keys( $row ) ); + } +} diff --git a/src/Features/WpCli/Commands/PostCommand.php b/src/Features/WpCli/Commands/PostCommand.php new file mode 100644 index 00000000..d010a4de --- /dev/null +++ b/src/Features/WpCli/Commands/PostCommand.php @@ -0,0 +1,180 @@ +<?php +namespace Saltus\WP\Framework\Features\WpCli\Commands; + +use Saltus\WP\Framework\Features\Duplicate\SaltusDuplicate; +use Saltus\WP\Framework\Features\SingleExport\SaltusSingleExport; + +final class PostCommand extends AbstractCommand { + /** + * @param list<string> $args Positional arguments. + * @param array<string, mixed> $assoc_args Associative arguments. + */ + public function list( array $args, array $assoc_args ): void { + $post_type = sanitize_key( $this->argument( $args, 0, 'post-type' ) ); + $query_args = [ + 'post_type' => $post_type, + 'post_status' => sanitize_key( (string) ( $assoc_args['status'] ?? 'any' ) ), + 'posts_per_page' => max( 1, min( 100, (int) ( $assoc_args['per-page'] ?? 20 ) ) ), + 'paged' => max( 1, (int) ( $assoc_args['page'] ?? 1 ) ), + 's' => sanitize_text_field( (string) ( $assoc_args['search'] ?? '' ) ), + 'orderby' => sanitize_key( (string) ( $assoc_args['orderby'] ?? 'date' ) ), + 'order' => strtoupper( (string) ( $assoc_args['order'] ?? 'DESC' ) ) === 'ASC' ? 'ASC' : 'DESC', + ]; + $query = new \WP_Query( $query_args ); + $rows = []; + foreach ( $query->posts as $post ) { + if ( $post instanceof \WP_Post ) { + $rows[] = $this->post_row( $post ); + } + } + $this->cli->format_items( $this->format( $assoc_args ), $rows, [ 'ID', 'post_type', 'post_status', 'post_title', 'post_name' ] ); + } + + /** + * @param list<string> $args Positional arguments. + * @param array<string, mixed> $assoc_args Associative arguments. + */ + public function get( array $args, array $assoc_args ): void { + $post = get_post( (int) $this->argument( $args, 0, 'id' ) ); + if ( ! $post instanceof \WP_Post ) { + $this->fail( 'Post not found.' ); + } + $row = $this->post_row( $post, true ); + $this->cli->format_items( $this->format( $assoc_args ), [ $row ], array_keys( $row ) ); + } + + /** + * @param list<string> $args Positional arguments. + * @param array<string, mixed> $assoc_args Associative arguments. + */ + public function create( array $args, array $assoc_args ): void { + $post_type = sanitize_key( $this->argument( $args, 0, 'post-type' ) ); + $title = sanitize_text_field( $this->argument( $args, 1, 'title' ) ); + $post_data = [ + 'post_type' => $post_type, + 'post_title' => $title, + 'post_content' => (string) ( $assoc_args['content'] ?? '' ), + 'post_excerpt' => (string) ( $assoc_args['excerpt'] ?? '' ), + 'post_name' => sanitize_title( (string) ( $assoc_args['slug'] ?? '' ) ), + 'post_status' => $this->post_status( (string) ( $assoc_args['status'] ?? 'draft' ) ), + ]; + $post_id = $this->ensure_result( wp_insert_post( $post_data, true ) ); + $this->apply_meta_and_terms( (int) $post_id, $assoc_args ); + $this->cli->success( 'Created post ' . (int) $post_id . '.' ); + } + + /** + * @param list<string> $args Positional arguments. + * @param array<string, mixed> $assoc_args Associative arguments. + */ + public function update( array $args, array $assoc_args ): void { + $post_id = (int) $this->argument( $args, 0, 'id' ); + if ( ! get_post( $post_id ) ) { + $this->fail( 'Post not found.' ); + } + $post_data = [ 'ID' => $post_id ]; + $map = [ + 'title' => 'post_title', + 'content' => 'post_content', + 'excerpt' => 'post_excerpt', + 'slug' => 'post_name', + ]; + foreach ( $map as $input => $field ) { + if ( array_key_exists( $input, $assoc_args ) ) { + $post_data[ $field ] = $input === 'slug' ? sanitize_title( (string) $assoc_args[ $input ] ) : (string) $assoc_args[ $input ]; + } + } + if ( isset( $assoc_args['status'] ) ) { + $post_data['post_status'] = $this->post_status( (string) $assoc_args['status'] ); + } + $this->ensure_result( wp_update_post( $post_data, true ) ); + $this->apply_meta_and_terms( $post_id, $assoc_args ); + $this->cli->success( 'Updated post ' . $post_id . '.' ); + } + + /** + * @param list<string> $args Positional arguments. + * @param array<string, mixed> $assoc_args Associative arguments. + */ + public function delete( array $args, array $assoc_args ): void { + $post_id = (int) $this->argument( $args, 0, 'id' ); + $result = wp_delete_post( $post_id, ! empty( $assoc_args['force'] ) ); + if ( ! $result ) { + $this->fail( 'Post could not be deleted.' ); + } + $this->cli->success( 'Deleted post ' . $post_id . '.' ); + } + + /** + * @param list<string> $args Positional arguments. + * @param array<string, mixed> $assoc_args Associative arguments. + */ + public function duplicate( array $args, array $assoc_args ): void { + $post_id = (int) $this->argument( $args, 0, 'id' ); + $post = get_post( $post_id ); + if ( ! $post instanceof \WP_Post ) { + $this->fail( 'Post not found.' ); + } + $new_id = $this->ensure_result( ( new SaltusDuplicate( $post->post_type, [] ) )->perform_duplication( $post_id ) ); + $this->cli->success( 'Duplicated post as ' . (int) $new_id . '.' ); + } + + /** + * @param list<string> $args Positional arguments. + * @param array<string, mixed> $assoc_args Associative arguments. + */ + public function export( array $args, array $assoc_args ): void { + $post_id = (int) $this->argument( $args, 0, 'id' ); + $result = $this->ensure_result( ( new SaltusSingleExport( '', [] ) )->export_post( $post_id ) ); + $wxr = is_array( $result ) ? (string) ( $result['wxr'] ?? '' ) : ''; + $file = (string) ( $assoc_args['file'] ?? '' ); + if ( $file === '' || $file === '-' ) { + $this->cli->line( $wxr ); + return; + } + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Explicit CLI export destination. + if ( file_put_contents( $file, $wxr ) === false ) { + $this->fail( 'Could not write export file.' ); + } + $this->cli->success( 'Exported post to ' . $file . '.' ); + } + + /** @param array<string, mixed> $assoc_args */ + private function apply_meta_and_terms( int $post_id, array $assoc_args ): void { + if ( isset( $assoc_args['meta'] ) ) { + foreach ( $this->json_object( (string) $assoc_args['meta'] ) as $key => $value ) { + update_post_meta( $post_id, (string) $key, $value ); + } + } + if ( isset( $assoc_args['terms'] ) ) { + foreach ( $this->json_object( (string) $assoc_args['terms'] ) as $taxonomy => $terms ) { + wp_set_object_terms( $post_id, is_array( $terms ) ? $terms : [ $terms ], (string) $taxonomy, false ); + } + } + } + + private function post_status( string $status ): string { + $status = sanitize_key( $status ); + if ( ! in_array( $status, [ 'publish', 'draft', 'pending', 'private' ], true ) ) { + $this->fail( 'Unsupported post status: ' . $status ); + } + return $status; + } + + /** @return array<string, mixed> */ + private function post_row( \WP_Post $post, bool $details = false ): array { + $row = [ + 'ID' => $post->ID, + 'post_type' => $post->post_type, + 'post_status' => $post->post_status, + 'post_title' => $post->post_title, + 'post_name' => $post->post_name, + ]; + if ( $details ) { + $row['post_content'] = $post->post_content; + $row['post_excerpt'] = $post->post_excerpt; + $row['meta'] = wp_json_encode( get_post_meta( $post->ID ) ); + } + return $row; + } +} diff --git a/src/Features/WpCli/Commands/ReorderCommand.php b/src/Features/WpCli/Commands/ReorderCommand.php new file mode 100644 index 00000000..256e2b88 --- /dev/null +++ b/src/Features/WpCli/Commands/ReorderCommand.php @@ -0,0 +1,24 @@ +<?php +namespace Saltus\WP\Framework\Features\WpCli\Commands; + +use Saltus\WP\Framework\Features\DragAndDrop\ReorderPostsService; + +final class ReorderCommand extends AbstractCommand { + private ReorderPostsService $reorder_service; + + public function __construct( \Saltus\WP\Framework\Features\WpCli\CliGateway $cli, callable $modeler_resolver, ?ReorderPostsService $reorder_service = null ) { + parent::__construct( $cli, $modeler_resolver ); + $this->reorder_service = $reorder_service ?? new ReorderPostsService(); + } + + /** + * @param list<string> $args Positional arguments. + * @param array<string, mixed> $assoc_args Associative arguments. + */ + public function __invoke( array $args, array $assoc_args ): void { + $payload = $this->json_list( $this->argument( $args, 0, 'json|@file' ) ); + $result = $this->reorder_service->reorder( $payload ); + $this->ensure_result( $result ); + $this->cli->success( 'Reordered ' . $result['updated'] . ' posts.' ); + } +} diff --git a/src/Features/WpCli/Commands/SaltusCommand.php b/src/Features/WpCli/Commands/SaltusCommand.php new file mode 100644 index 00000000..7db8ccef --- /dev/null +++ b/src/Features/WpCli/Commands/SaltusCommand.php @@ -0,0 +1,44 @@ +<?php +namespace Saltus\WP\Framework\Features\WpCli\Commands; + +use Saltus\WP\Framework\Core; +use Saltus\WP\Framework\Features\WpCli\CliGateway; +use Saltus\WP\Framework\MCP\Audit\AuditLogger; +use Saltus\WP\Framework\Rest\HealthController; + +final class SaltusCommand extends AbstractCommand { + /** + * @param list<string> $args Positional arguments. + * @param array<string, mixed> $assoc_args Associative arguments. + */ + public function __invoke( array $args, array $assoc_args ): void { + $this->health( $args, $assoc_args ); + } + + /** + * @param list<string> $args Positional arguments. + * @param array<string, mixed> $assoc_args Associative arguments. + */ + public function health( array $args, array $assoc_args ): void { + $response = ( new HealthController( Core::VERSION, new AuditLogger() ) )->get_item( null ); + $data = $response->get_data(); + $row = is_array( $data ) ? $data : [ 'result' => $data ]; + $this->cli->format_items( $this->format( $assoc_args ), [ $this->flatten( $row ) ], array_keys( $this->flatten( $row ) ) ); + } + + /** + * @param array<string, mixed> $data Health data. + * @return array<string, mixed> + */ + private function flatten( array $data ): array { + return [ + 'status' => $data['status'] ?? '', + 'version' => $data['version'] ?? '', + 'generated_at' => $data['generated_at'] ?? '', + 'error_rate' => $data['audit']['error_rate'] ?? 0, + 'latency_p95' => $data['audit']['latency_ms']['p95'] ?? null, + 'cache' => ! empty( $data['cache']['enabled'] ) ? 'enabled' : 'disabled', + 'rate_limit' => ! empty( $data['rate_limit']['enabled'] ) ? 'enabled' : 'disabled', + ]; + } +} diff --git a/src/Features/WpCli/Commands/SettingsCommand.php b/src/Features/WpCli/Commands/SettingsCommand.php new file mode 100644 index 00000000..21b195ac --- /dev/null +++ b/src/Features/WpCli/Commands/SettingsCommand.php @@ -0,0 +1,44 @@ +<?php +namespace Saltus\WP\Framework\Features\WpCli\Commands; + +use Saltus\WP\Framework\Features\Settings\SettingsManager; + +final class SettingsCommand extends AbstractCommand { + private SettingsManager $settings; + + public function __construct( \Saltus\WP\Framework\Features\WpCli\CliGateway $cli, callable $modeler_resolver, ?SettingsManager $settings = null ) { + parent::__construct( $cli, $modeler_resolver ); + $this->settings = $settings ?? new SettingsManager(); + } + + /** + * @param list<string> $args Positional arguments. + * @param array<string, mixed> $assoc_args Associative arguments. + */ + public function get( array $args, array $assoc_args ): void { + $post_type = sanitize_key( $this->argument( $args, 0, 'post-type' ) ); + $this->require_model( $post_type ); + $row = $this->settings->get_settings( $post_type ); + $row['settings'] = wp_json_encode( $row['settings'] ); + $this->cli->format_items( $this->format( $assoc_args ), [ $row ], [ 'post_type', 'settings' ] ); + } + + /** + * @param list<string> $args Positional arguments. + * @param array<string, mixed> $assoc_args Associative arguments. + */ + public function update( array $args, array $assoc_args ): void { + $post_type = sanitize_key( $this->argument( $args, 0, 'post-type' ) ); + $this->require_model( $post_type ); + $payload = $this->json_object( $this->argument( $args, 1, 'json|@file' ) ); + $result = $this->ensure_result( $this->settings->update_settings( $post_type, $payload ) ); + $status = is_array( $result ) ? (string) ( $result['status'] ?? 'updated' ) : 'updated'; + $this->cli->success( ucfirst( $status ) . ' settings for ' . $post_type . '.' ); + } + + private function require_model( string $post_type ): void { + if ( ! isset( $this->modeler()->get_models()[ $post_type ] ) ) { + $this->fail( 'Model not found: ' . $post_type ); + } + } +} diff --git a/src/Features/WpCli/Commands/TermCommand.php b/src/Features/WpCli/Commands/TermCommand.php new file mode 100644 index 00000000..d959f9e8 --- /dev/null +++ b/src/Features/WpCli/Commands/TermCommand.php @@ -0,0 +1,51 @@ +<?php +namespace Saltus\WP\Framework\Features\WpCli\Commands; + +final class TermCommand extends AbstractCommand { + /** + * @param list<string> $args Positional arguments. + * @param array<string, mixed> $assoc_args Associative arguments. + */ + public function list( array $args, array $assoc_args ): void { + $taxonomy = sanitize_key( $this->argument( $args, 0, 'taxonomy' ) ); + $terms = get_terms( [ + 'taxonomy' => $taxonomy, + 'hide_empty' => ! empty( $assoc_args['hide-empty'] ), + 'search' => sanitize_text_field( (string) ( $assoc_args['search'] ?? '' ) ), + ] ); + $terms = $this->ensure_result( $terms ); + $rows = []; + foreach ( is_array( $terms ) ? $terms : [] as $term ) { + if ( $term instanceof \WP_Term ) { + $rows[] = [ + 'term_id' => $term->term_id, + 'name' => $term->name, + 'slug' => $term->slug, + 'count' => $term->count, + ]; + } + } + $this->cli->format_items( $this->format( $assoc_args ), $rows, [ 'term_id', 'name', 'slug', 'count' ] ); + } + + /** + * @param list<string> $args Positional arguments. + * @param array<string, mixed> $assoc_args Associative arguments. + */ + public function create( array $args, array $assoc_args ): void { + $taxonomy = sanitize_key( $this->argument( $args, 0, 'taxonomy' ) ); + $name = sanitize_text_field( $this->argument( $args, 1, 'name' ) ); + $result = wp_insert_term( + $name, + $taxonomy, + [ + 'slug' => sanitize_title( (string) ( $assoc_args['slug'] ?? '' ) ), + 'description' => sanitize_textarea_field( (string) ( $assoc_args['description'] ?? '' ) ), + 'parent' => max( 0, (int) ( $assoc_args['parent'] ?? 0 ) ), + ] + ); + $result = $this->ensure_result( $result ); + $term_id = is_array( $result ) ? (int) ( $result['term_id'] ?? 0 ) : 0; + $this->cli->success( 'Created term ' . $term_id . '.' ); + } +} diff --git a/src/Features/WpCli/WordPressCliGateway.php b/src/Features/WpCli/WordPressCliGateway.php new file mode 100644 index 00000000..5412610e --- /dev/null +++ b/src/Features/WpCli/WordPressCliGateway.php @@ -0,0 +1,28 @@ +<?php +namespace Saltus\WP\Framework\Features\WpCli; + +final class WordPressCliGateway implements CliGateway { + public function add_command( string $name, $command_handler ): void { + \WP_CLI::add_command( $name, $command_handler ); + } + + /** + * @param list<array<string, mixed>> $items Rows to format. + * @param list<string> $fields Fields to display. + */ + public function format_items( string $format, array $items, array $fields ): void { + \WP_CLI\Utils\format_items( $format, $items, $fields ); + } + + public function line( string $message ): void { + \WP_CLI::line( $message ); + } + + public function success( string $message ): void { + \WP_CLI::success( $message ); + } + + public function error( string $message ): void { + \WP_CLI::error( $message ); + } +} diff --git a/src/Features/WpCli/WpCli.php b/src/Features/WpCli/WpCli.php new file mode 100644 index 00000000..caf3547a --- /dev/null +++ b/src/Features/WpCli/WpCli.php @@ -0,0 +1,50 @@ +<?php +namespace Saltus\WP\Framework\Features\WpCli; + +use Saltus\WP\Framework\Features\WpCli\Commands\BlockCommand; +use Saltus\WP\Framework\Features\WpCli\Commands\MetaCommand; +use Saltus\WP\Framework\Features\WpCli\Commands\ModelCommand; +use Saltus\WP\Framework\Features\WpCli\Commands\PostCommand; +use Saltus\WP\Framework\Features\WpCli\Commands\ReorderCommand; +use Saltus\WP\Framework\Features\WpCli\Commands\SaltusCommand; +use Saltus\WP\Framework\Features\WpCli\Commands\SettingsCommand; +use Saltus\WP\Framework\Features\WpCli\Commands\TermCommand; +use Saltus\WP\Framework\Infrastructure\Plugin\Registerable; +use Saltus\WP\Framework\Infrastructure\Service\Conditional; +use Saltus\WP\Framework\Infrastructure\Service\Service; + +/** Registers the wp saltus command tree. @api */ +final class WpCli implements Service, Conditional, Registerable { + /** @var callable */ + private $modeler_resolver; + private CliGateway $cli; + + /** @param array<string, mixed> $dependencies Framework dependencies. */ + public function __construct( array $dependencies = [], ?CliGateway $cli = null ) { + $resolver = $dependencies['modeler_resolver'] ?? null; + $this->modeler_resolver = is_callable( $resolver ) ? $resolver : static function () { + return null; + }; + $this->cli = $cli ?? new WordPressCliGateway(); + } + + public static function is_needed(): bool { + return defined( 'WP_CLI' ) && WP_CLI; + } + + public function register(): void { + add_action( 'cli_init', [ $this, 'register_commands' ] ); + } + + public function register_commands(): void { + $resolver = $this->modeler_resolver; + $this->cli->add_command( 'saltus', new SaltusCommand( $this->cli, $resolver ) ); + $this->cli->add_command( 'saltus model', new ModelCommand( $this->cli, $resolver ) ); + $this->cli->add_command( 'saltus post', new PostCommand( $this->cli, $resolver ) ); + $this->cli->add_command( 'saltus term', new TermCommand( $this->cli, $resolver ) ); + $this->cli->add_command( 'saltus settings', new SettingsCommand( $this->cli, $resolver ) ); + $this->cli->add_command( 'saltus meta', new MetaCommand( $this->cli, $resolver ) ); + $this->cli->add_command( 'saltus reorder', new ReorderCommand( $this->cli, $resolver ) ); + $this->cli->add_command( 'saltus block', new BlockCommand( $this->cli, $resolver ) ); + } +} 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<string, mixed> * @extends ArrayAccess<string, mixed> + * @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<string, mixed> + * @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<string, mixed> + * @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 @@ <?php namespace Saltus\WP\Framework\Infrastructure\Service; +/** + * @api + */ interface Factory { /** * Create a new resource that can return its instance diff --git a/src/Infrastructure/Service/Processable.php b/src/Infrastructure/Service/Processable.php index 8399efbd..091faaf3 100644 --- a/src/Infrastructure/Service/Processable.php +++ b/src/Infrastructure/Service/Processable.php @@ -4,6 +4,7 @@ /** * Something that can be registered. * + * @api */ interface Processable { diff --git a/src/Infrastructure/Service/Service.php b/src/Infrastructure/Service/Service.php index 6fad23c4..d1792930 100644 --- a/src/Infrastructure/Service/Service.php +++ b/src/Infrastructure/Service/Service.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 Service { } diff --git a/src/Infrastructure/Service/ServiceFactory.php b/src/Infrastructure/Service/ServiceFactory.php index 87b70979..afc3849f 100644 --- a/src/Infrastructure/Service/ServiceFactory.php +++ b/src/Infrastructure/Service/ServiceFactory.php @@ -3,6 +3,9 @@ use Saltus\WP\Framework\Infrastructure\Service\Service; +/** + * @api + */ class ServiceFactory implements Service, Factory { /** diff --git a/src/Infrastructure/Services/Assets/Asset.php b/src/Infrastructure/Services/Assets/Asset.php index ac0f6c75..3b92b22a 100644 --- a/src/Infrastructure/Services/Assets/Asset.php +++ b/src/Infrastructure/Services/Assets/Asset.php @@ -5,6 +5,9 @@ use Saltus\WP\Framework\Infrastructure\Service\Service; +/** + * @api + */ class Asset implements Service { /** diff --git a/src/Infrastructure/Services/Assets/AssetData.php b/src/Infrastructure/Services/Assets/AssetData.php index 5d3805d3..0ef4b696 100644 --- a/src/Infrastructure/Services/Assets/AssetData.php +++ b/src/Infrastructure/Services/Assets/AssetData.php @@ -9,6 +9,7 @@ * * This class holds the data that will be made available to a specific script * using `wp_localize_script`. + * @api */ class AssetData implements Service { diff --git a/src/Infrastructure/Services/Assets/AssetLoader.php b/src/Infrastructure/Services/Assets/AssetLoader.php index 099c9157..6da13083 100644 --- a/src/Infrastructure/Services/Assets/AssetLoader.php +++ b/src/Infrastructure/Services/Assets/AssetLoader.php @@ -7,6 +7,9 @@ use Saltus\WP\Framework\Infrastructure\Service\Factory; use Saltus\WP\Framework\Infrastructure\Service\ServiceFactory; +/** + * @api + */ trait AssetLoader { /** diff --git a/src/Infrastructure/Services/Assets/AssetLoadingService.php b/src/Infrastructure/Services/Assets/AssetLoadingService.php index 27db3c50..4792e855 100644 --- a/src/Infrastructure/Services/Assets/AssetLoadingService.php +++ b/src/Infrastructure/Services/Assets/AssetLoadingService.php @@ -5,6 +5,7 @@ /** * Base class for services that register and enqueue framework assets. + * @api */ abstract class AssetLoadingService implements HasAssets { use AssetLoader; diff --git a/src/Infrastructure/Services/Assets/AssetManager.php b/src/Infrastructure/Services/Assets/AssetManager.php index b708cc3b..35e6c3e2 100644 --- a/src/Infrastructure/Services/Assets/AssetManager.php +++ b/src/Infrastructure/Services/Assets/AssetManager.php @@ -7,6 +7,7 @@ /** * Manage Assets like scripts and styles. + * @api */ class AssetManager implements Service { diff --git a/src/Infrastructure/Services/Assets/AssetsContainer.php b/src/Infrastructure/Services/Assets/AssetsContainer.php index eccfedd6..c6afde2b 100644 --- a/src/Infrastructure/Services/Assets/AssetsContainer.php +++ b/src/Infrastructure/Services/Assets/AssetsContainer.php @@ -5,6 +5,9 @@ use Saltus\WP\Framework\Infrastructure\Service\Service; use Saltus\WP\Framework\Infrastructure\Container\SimpleContainer; +/** + * @api + */ class AssetsContainer extends SimpleContainer implements Service { /** diff --git a/src/Infrastructure/Services/Assets/HasAssets.php b/src/Infrastructure/Services/Assets/HasAssets.php index 1b854a23..11267612 100644 --- a/src/Infrastructure/Services/Assets/HasAssets.php +++ b/src/Infrastructure/Services/Assets/HasAssets.php @@ -1,6 +1,9 @@ <?php namespace Saltus\WP\Framework\Infrastructure\Services\Assets; +/** + * @api + */ interface HasAssets { public function set_assets_list(): void; public function register_assets(): void; diff --git a/src/Infrastructure/Services/FilterAwareTrait.php b/src/Infrastructure/Services/FilterAwareTrait.php index 8ddfb387..61d0f42b 100644 --- a/src/Infrastructure/Services/FilterAwareTrait.php +++ b/src/Infrastructure/Services/FilterAwareTrait.php @@ -4,6 +4,7 @@ /** * Shared helper for applying WordPress filters with a safe fallback outside WordPress. + * @api */ trait FilterAwareTrait { diff --git a/src/MCP/Abilities/AbilityDefinitionFactory.php b/src/MCP/Abilities/AbilityDefinitionFactory.php index 33ae5072..1b8921ce 100644 --- a/src/MCP/Abilities/AbilityDefinitionFactory.php +++ b/src/MCP/Abilities/AbilityDefinitionFactory.php @@ -1,6 +1,7 @@ <?php namespace Saltus\WP\Framework\MCP\Abilities; +use Saltus\WP\Framework\MCP\MCPConfig; use Saltus\WP\Framework\MCP\Tools\ToolInterface; use Saltus\WP\Framework\MCP\Tools\ToolProvider; @@ -19,6 +20,7 @@ * callback: callable, * meta: array<string, mixed> * } + * @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 @@ <?php namespace Saltus\WP\Framework\MCP\Abilities; +use Saltus\WP\Framework\MCP\MCPConfig; +use Saltus\WP\Framework\MCP\McpPolicy; use Saltus\WP\Framework\MCP\Tools\RestBackedToolInterface; use Saltus\WP\Framework\MCP\Tools\ToolInterface; use Saltus\WP\Framework\MCP\Tools\ToolProvider; -use Saltus\WP\Framework\Rest\ModelRestPolicy; /** * Registers MCP abilities with the WordPress native wp_register_ability API. * * @phpstan-import-type AbilityDefinition from \Saltus\WP\Framework\MCP\Abilities\AbilityDefinitionFactory + * @api */ class AbilityRegistrar { private ToolProvider $tool_provider; private AbilityDefinitionFactory $definition_factory; - private ?ModelRestPolicy $policy; + private ?McpPolicy $mcp_policy; /** * @param ToolProvider|null $tool_provider Optional injected tool provider. * @param AbilityDefinitionFactory|null $definition_factory Optional definition factory. - * @param ModelRestPolicy|null $policy Optional REST policy for capability gating. + * @param McpPolicy|null $mcp_policy Optional MCP policy for capability gating. */ - public function __construct( ?ToolProvider $tool_provider = null, ?AbilityDefinitionFactory $definition_factory = null, ?ModelRestPolicy $policy = null ) { + public function __construct( ?ToolProvider $tool_provider = null, ?AbilityDefinitionFactory $definition_factory = null, ?McpPolicy $mcp_policy = null ) { $this->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<string, mixed> $args Arguments to pass to the tool. * @return array<string, mixed>|\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<string, mixed> $args + * @return array<string, mixed>|\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<string, mixed> $args + * @return array<string, mixed>|\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 @@ <?php namespace Saltus\WP\Framework\MCP\Audit; +/** + * @api + */ interface AuditDatabase { /** diff --git a/src/MCP/Audit/AuditEntry.php b/src/MCP/Audit/AuditEntry.php index 297286d7..3017f303 100644 --- a/src/MCP/Audit/AuditEntry.php +++ b/src/MCP/Audit/AuditEntry.php @@ -3,6 +3,7 @@ /** * Value object representing a single MCP audit trail entry. + * @api */ class AuditEntry { diff --git a/src/MCP/Audit/AuditLogger.php b/src/MCP/Audit/AuditLogger.php index 6c6bc6a5..47fde633 100644 --- a/src/MCP/Audit/AuditLogger.php +++ b/src/MCP/Audit/AuditLogger.php @@ -3,6 +3,7 @@ /** * Persists MCP audit entries to a custom database table. + * @api */ class AuditLogger { use \Saltus\WP\Framework\Infrastructure\Services\FilterAwareTrait; diff --git a/src/MCP/Audit/WpdbAuditDatabase.php b/src/MCP/Audit/WpdbAuditDatabase.php index 8d3d334a..0bc5953b 100644 --- a/src/MCP/Audit/WpdbAuditDatabase.php +++ b/src/MCP/Audit/WpdbAuditDatabase.php @@ -3,6 +3,7 @@ /** * wpdb adapter implementing the AuditDatabase interface. + * @api */ class WpdbAuditDatabase implements AuditDatabase { private \wpdb $wpdb; diff --git a/src/MCP/Cache/CacheInterface.php b/src/MCP/Cache/CacheInterface.php index 6607b6c1..3237ed97 100644 --- a/src/MCP/Cache/CacheInterface.php +++ b/src/MCP/Cache/CacheInterface.php @@ -1,6 +1,9 @@ <?php namespace Saltus\WP\Framework\MCP\Cache; +/** + * @api + */ interface CacheInterface { /** diff --git a/src/MCP/Cache/TransientCache.php b/src/MCP/Cache/TransientCache.php index a87af6dc..78877544 100644 --- a/src/MCP/Cache/TransientCache.php +++ b/src/MCP/Cache/TransientCache.php @@ -3,6 +3,7 @@ /** * Transient-backed cache implementing CacheInterface. + * @api */ class TransientCache implements CacheInterface { use \Saltus\WP\Framework\Infrastructure\Services\FilterAwareTrait; diff --git a/src/MCP/Error/ErrorResponse.php b/src/MCP/Error/ErrorResponse.php new file mode 100644 index 00000000..8144c62c --- /dev/null +++ b/src/MCP/Error/ErrorResponse.php @@ -0,0 +1,120 @@ +<?php +namespace Saltus\WP\Framework\MCP\Error; + +/** + * Factory for standardized WP_Error responses across REST and MCP paths. + * @api + */ +class ErrorResponse { + + /** + * Insufficient permissions. + * + * @param string $capability The required capability. + * @param string $hint User-facing resolution hint. + * @return \WP_Error + */ + public static function forbidden( string $capability = '', string $hint = '' ): \WP_Error { + $data = [ 'status' => 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 @@ +<?php +namespace Saltus\WP\Framework\MCP; + +/** + * Central configuration for MCP naming. + * + * All values are filterable so plugins can customise the MCP server name, + * REST namespace, ability category, and ability name prefix without + * modifying framework source files. + * @api + */ +class MCPConfig { + + /** + * Get the REST API namespace used for route registration and dispatch. + * + * Default: 'saltus-framework/v1' + * + * @return non-falsy-string + */ + public static function get_namespace(): string { + $namespace = \apply_filters( + 'saltus/framework/mcp/namespace', + 'saltus-framework/v1' + ); + if ( ! is_string( $namespace ) || trim( $namespace ) === '' ) { + return 'saltus-framework/v1'; + } + /** @var non-falsy-string */ + return trim( $namespace ); + } + + /** + * Get the ability category registration array. + * + * Default: + * [ + * 'id' => '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..afe800b6 --- /dev/null +++ b/src/MCP/McpPolicy.php @@ -0,0 +1,153 @@ +<?php + +namespace Saltus\WP\Framework\MCP; + +use Saltus\WP\Framework\Modeler; +use Saltus\WP\Framework\Models\Model; +use Saltus\WP\Framework\Rest\ModelRestPolicy; + +/** + * @api + */ +class McpPolicy { + /** + * Modeler instance. + * It is responsible for providing models and model types. + * + * @var Modeler + */ + private Modeler $modeler; + + /** + * @param Modeler $modeler + */ + public function __construct( Modeler $modeler ) { + $this->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<string, mixed> $config The model configuration. + * @param string $capability The capability. + * @return mixed + */ + private function get_capability_config( array $config, string $capability ) { + if ( in_array( $capability, [ ModelRestPolicy::CAPABILITY_META, ModelRestPolicy::CAPABILITY_SETTINGS, ModelRestPolicy::CAPABILITY_BLOCKS ], true ) ) { + 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<string, mixed> $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 @@ +<?php +namespace Saltus\WP\Framework\MCP\Middleware; + +use Saltus\WP\Framework\MCP\Audit\AuditEntry; +use Saltus\WP\Framework\MCP\Audit\AuditLogger; + +/** + * Middleware that logs every request and response through the audit trail. + * + * Creates an audit entry before dispatch and records it with the result + * status after the response is available. + */ +class AuditMiddleware implements MiddlewareInterface { + + private AuditLogger $logger; + + public function __construct( AuditLogger $logger ) { + $this->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<string, mixed> + */ + 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 @@ +<?php +namespace Saltus\WP\Framework\MCP\Middleware; + +use Saltus\WP\Framework\MCP\Cache\CacheInterface; + +/** + * Middleware that provides transparent response caching for GET requests. + * + * Checks the cache before dispatch and stores the response after a + * successful dispatch. Only caches GET requests by default. + */ +class CacheMiddleware implements MiddlewareInterface { + + private CacheInterface $cache; + + public function __construct( CacheInterface $cache ) { + $this->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<string, mixed> $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 @@ +<?php +namespace Saltus\WP\Framework\MCP\Middleware; + +/** + * @api + */ +interface MiddlewareInterface { + + /** + * Handle a middleware stage. + * + * @param RequestContext $context The mutable request context flowing through the pipeline. + * @param callable $next The next stage in the chain. + * @return \WP_REST_Response|\WP_Error + */ + public function handle( RequestContext $context, callable $next ); +} diff --git a/src/MCP/Middleware/MiddlewarePipeline.php b/src/MCP/Middleware/MiddlewarePipeline.php new file mode 100644 index 00000000..1ce9dede --- /dev/null +++ b/src/MCP/Middleware/MiddlewarePipeline.php @@ -0,0 +1,56 @@ +<?php +namespace Saltus\WP\Framework\MCP\Middleware; + +/** + * Ordered middleware stage registry and chain executor. + * + * Stages are executed in registration order. Each stage receives the + * RequestContext and a callable to invoke the next stage. The innermost + * callable is the actual dispatch handler provided at execution time. + * @api + */ +class MiddlewarePipeline { + + /** @var list<MiddlewareInterface> */ + 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 @@ +<?php +namespace Saltus\WP\Framework\MCP\Middleware; + +use Saltus\WP\Framework\Rest\CapabilityPolicy; + +/** + * Middleware that resolves and checks required capabilities from route metadata. + * + * For REST requests: maps the route to a CapabilityPolicy capability key + * and checks current_user_can() against the resolved post-type capability. + * For MCP tools: resolves capability from tool metadata or tool's own + * permission check. + */ +class PermissionMiddleware implements MiddlewareInterface { + + private CapabilityPolicy $policy; + + public function __construct( CapabilityPolicy $policy ) { + $this->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<string, mixed> $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 @@ +<?php +namespace Saltus\WP\Framework\MCP\Middleware; + +/** + * Hooks the middleware pipeline into WordPress REST dispatch and MCP execution. + * + * For REST: intercepts rest_pre_dispatch to run the full middleware chain, + * replacing the inline orchestration that previously lived in REST controllers. + * For MCP: provides a factory method that builds a pipeline configured with + * the standard stage ordering. + * @api + */ +class PipelineIntegration { + + private MiddlewarePipeline $pipeline; + + public function __construct( MiddlewarePipeline $pipeline ) { + $this->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 @@ +<?php +namespace Saltus\WP\Framework\MCP\Middleware; + +use Saltus\WP\Framework\MCP\RateLimiter\RateLimiter; + +/** + * Middleware that applies rate limiting to incoming requests. + * + * Checks the rate limit before dispatch. Currently applies to all requests; + * per-route configuration can be set via context attributes. + */ +class RateLimitMiddleware implements MiddlewareInterface { + + private RateLimiter $limiter; + + public function __construct( RateLimiter $limiter ) { + $this->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 @@ +<?php +namespace Saltus\WP\Framework\MCP\Middleware; + +/** + * Mutable request context that flows through the middleware pipeline. + * + * Carries raw MCP tool arguments, the built REST request (populated after + * validation), the response from dispatch, and route/tool metadata. + * @api + */ +class RequestContext { + + /** @var array<string, mixed> */ + private array $args = []; + + private ?\WP_REST_Request $rest_request = null; + + /** @var \WP_REST_Response|\WP_Error|null */ + private $response = null; + + /** @var array<string, mixed> */ + private array $attributes = []; + + private string $route = ''; + + /** @var array<string, mixed> */ + private array $tool_metadata = []; + + /** + * Get the raw request arguments (MCP tool args or REST params). + * + * @return array<string, mixed> + */ + public function get_args(): array { + return $this->args; + } + + /** + * Set the raw request arguments. + * + * @param array<string, mixed> $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<string, mixed> + */ + public function get_attributes(): array { + return $this->attributes; + } + + /** + * Set all route/tool attributes at once. + * + * @param array<string, mixed> $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<string, mixed> + */ + public function get_tool_metadata(): array { + return $this->tool_metadata; + } + + /** + * Set the MCP tool metadata. + * + * @param array<string, mixed> $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 @@ +<?php +namespace Saltus\WP\Framework\MCP\Middleware; + +use Saltus\WP\Framework\MCP\Validation\Validator; + +/** + * Middleware that applies JSON Schema-like validation to incoming request arguments. + * + * Uses the existing Validator class. For REST requests, it maps route arg + * definitions to the Validator schema format. + */ +class ValidationMiddleware implements MiddlewareInterface { + + /** + * @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(); + $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<string, mixed> + */ + 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<string, mixed> + */ + 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/ListBlockModels.php b/src/MCP/Tools/ListBlockModels.php new file mode 100644 index 00000000..4434d531 --- /dev/null +++ b/src/MCP/Tools/ListBlockModels.php @@ -0,0 +1,32 @@ +<?php +namespace Saltus\WP\Framework\MCP\Tools; + +use Saltus\WP\Framework\Rest\ModelRestPolicy; + +/** MCP tool for discovering model-driven blocks. */ +final class ListBlockModels extends RestTool { + public function get_name(): string { + return 'list_block_models'; + } + + public function get_description(): string { + return 'List Saltus post type models with their registered list and single blocks'; + } + + /** @return array<string, mixed> */ + public function get_parameters(): array { + return []; + } + + public function get_rest_capability(): RestCapabilityRequirement { + return new RestCapabilityRequirement( ModelRestPolicy::CAPABILITY_BLOCKS, 'post_type' ); + } + + public function build_rest_request( array $args ): ?\WP_REST_Request { + return $this->request( 'GET', $this->mcp_route( '/blocks' ) ); + } + + public function is_cacheable(): bool { + return true; + } +} 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 @@ <?php namespace Saltus\WP\Framework\MCP\Tools; +/** + * @api + */ interface ToolInterface { /** diff --git a/src/MCP/Tools/UpdateMetaFields.php b/src/MCP/Tools/UpdateMetaFields.php index dac7e241..b83b5fa5 100644 --- a/src/MCP/Tools/UpdateMetaFields.php +++ b/src/MCP/Tools/UpdateMetaFields.php @@ -85,7 +85,7 @@ public function build_rest_request( array $args ): ?\WP_REST_Request { return $this->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<string, Model> */ 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<string, \Saltus\WP\Framework\Models\Model> Associative array keyed by model name. + * @return array<string, Model> 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<RestRouteDefinition> */ 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<ToolInterface> */ 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<string, mixed> + */ + 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<string, mixed> */ public function get_args(): array; + + /** + * Get the full raw model configuration. + * + * @return array<string, mixed> + */ + 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<string, mixed> */ 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/BlocksController.php b/src/Rest/BlocksController.php new file mode 100644 index 00000000..3931d073 --- /dev/null +++ b/src/Rest/BlocksController.php @@ -0,0 +1,67 @@ +<?php +namespace Saltus\WP\Framework\Rest; + +use Saltus\WP\Framework\Features\Blocks\SaltusBlocks; +use Saltus\WP\Framework\MCP\MCPConfig; +use Saltus\WP\Framework\Modeler; +use WP_Error; +use WP_REST_Controller; +use WP_REST_Response; +use WP_REST_Server; + +/** REST discovery for configured Saltus blocks. @api */ +final class BlocksController extends WP_REST_Controller { + + private Modeler $modeler; + private ModelRestPolicy $policy; + + public function __construct( Modeler $modeler, ModelRestPolicy $policy ) { + $this->modeler = $modeler; + $this->policy = $policy; + $this->namespace = MCPConfig::get_namespace(); + $this->rest_base = 'blocks'; + } + + public function register_routes(): void { + /** @var non-falsy-string $namespace */ + $namespace = $this->namespace; + register_rest_route( + $namespace, + '/' . $this->rest_base, + [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => [ $this, 'get_items' ], + 'permission_callback' => [ $this, 'get_items_permissions_check' ], + ] + ); + } + + /** @param mixed $request @return bool|WP_Error */ + public function get_items_permissions_check( $request ) { + return current_user_can( 'edit_posts' ) ? true : new WP_Error( 'rest_forbidden', __( 'You do not have permission to discover blocks.', 'saltus-framework' ), [ 'status' => 403 ] ); + } + + /** @param mixed $request */ + public function get_items( $request ): WP_REST_Response { + $definitions = ( new SaltusBlocks( $this->modeler ) )->definitions(); + $items = []; + foreach ( $definitions as $definition ) { + if ( ! $this->policy->is_post_type_enabled( (string) $definition['post_type'], ModelRestPolicy::CAPABILITY_BLOCKS ) ) { + continue; + } + $blocks = []; + foreach ( $definition['blocks'] as $view => $block ) { + $blocks[ $view ] = $block['name']; + } + $items[] = [ + 'post_type' => $definition['post_type'], + 'label' => $definition['label'], + 'label_plural' => $definition['label_plural'], + 'blocks' => $blocks, + 'meta_fields' => $definition['meta_fields'], + ]; + } + + return rest_ensure_response( [ 'post_types' => $items ] ); + } +} diff --git a/src/Rest/CapabilityPolicy.php b/src/Rest/CapabilityPolicy.php new file mode 100644 index 00000000..9c511b91 --- /dev/null +++ b/src/Rest/CapabilityPolicy.php @@ -0,0 +1,234 @@ +<?php +namespace Saltus\WP\Framework\Rest; + +use Saltus\WP\Framework\Modeler; +use Saltus\WP\Framework\Models\Model; + +/** + * Unified capability policy supporting both REST and MCP gate keys. + * + * Replaces ModelRestPolicy and McpPolicy with a single implementation + * parameterized by the config gate key (show_in_rest or show_in_mcp). + * @api + */ +class CapabilityPolicy { + + public const CAPABILITY_MODELS = 'models'; + public const CAPABILITY_META = 'meta'; + public const CAPABILITY_SETTINGS = 'settings'; + public const CAPABILITY_DUPLICATE = 'duplicate'; + public const CAPABILITY_EXPORT = 'export'; + public const CAPABILITY_REORDER = 'reorder'; + public const CAPABILITY_HEALTH = 'health'; + public const CAPABILITY_BLOCKS = 'blocks'; + + public const GATE_REST = 'show_in_rest'; + public const GATE_MCP = 'show_in_mcp'; + + private Modeler $modeler; + + public function __construct( Modeler $modeler ) { + $this->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<string, Model> + */ + 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<string, mixed> + */ + public function get_model_args( Model $model ): array { + return $model->get_args(); + } + + /** + * Get the configuration section for a specific capability. + * + * @param array<string, mixed> $config + * @param string $capability + * @return mixed + */ + private function get_capability_config( array $config, string $capability ) { + if ( in_array( $capability, [ self::CAPABILITY_META, self::CAPABILITY_SETTINGS, self::CAPABILITY_BLOCKS ], true ) ) { + 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<string, mixed> $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<post_id>\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<post_id>\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<post_type>[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<post_type>[a-z0-9_-]+)/(?P<post_id>\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..bb3aafc5 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'; @@ -14,6 +17,7 @@ class ModelRestPolicy { public const CAPABILITY_EXPORT = 'export'; public const CAPABILITY_REORDER = 'reorder'; public const CAPABILITY_HEALTH = 'health'; + public const CAPABILITY_BLOCKS = 'blocks'; private Modeler $modeler; @@ -40,22 +44,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<string, mixed> $config The model configuration. + * @param string $capability The capability. + * @return mixed + */ + private function get_capability_config( array $config, string $capability ) { + if ( in_array( $capability, [ self::CAPABILITY_META, self::CAPABILITY_SETTINGS, self::CAPABILITY_BLOCKS ], true ) ) { + 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<string, mixed> $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<post_type>[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<post_type>[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/templates/blocks/list.php b/templates/blocks/list.php new file mode 100644 index 00000000..baf06854 --- /dev/null +++ b/templates/blocks/list.php @@ -0,0 +1,31 @@ +<?php +/** @var list<WP_Post> $posts */ +/** @var array<int, array<string, mixed>> $meta_by_post */ +if ( $posts === [] ) : + ?> + <p class="saltus-block__empty"><?php echo esc_html__( 'No entries found.', 'saltus-framework' ); ?></p> + <?php + return; +endif; +?> +<ul class="saltus-block__list"> + <?php foreach ( $posts as $saltus_post ) : ?> + <li class="saltus-block__item"> + <h3 class="saltus-block__title"><?php echo esc_html( $saltus_post->post_title ); ?></h3> + <?php if ( $attributes['showExcerpt'] && $saltus_post->post_excerpt !== '' ) : ?> + <div class="saltus-block__excerpt"><?php echo wp_kses_post( $saltus_post->post_excerpt ); ?></div> + <?php endif; ?> + <?php if ( $attributes['showDate'] && isset( $saltus_post->post_date ) ) : ?> + <time class="saltus-block__date"><?php echo esc_html( mysql2date( (string) get_option( 'date_format', 'F j, Y' ), $saltus_post->post_date ) ); ?></time> + <?php endif; ?> + <?php if ( ! empty( $meta_by_post[ $saltus_post->ID ] ) ) : ?> + <dl class="saltus-block__meta"> + <?php foreach ( $meta_by_post[ $saltus_post->ID ] as $saltus_path => $saltus_value ) : ?> + <dt><?php echo esc_html( $saltus_path ); ?></dt> + <dd><?php echo esc_html( is_scalar( $saltus_value ) ? (string) $saltus_value : wp_json_encode( $saltus_value ) ); ?></dd> + <?php endforeach; ?> + </dl> + <?php endif; ?> + </li> + <?php endforeach; ?> +</ul> diff --git a/templates/blocks/single.php b/templates/blocks/single.php new file mode 100644 index 00000000..42800112 --- /dev/null +++ b/templates/blocks/single.php @@ -0,0 +1,19 @@ +<?php +/** @var WP_Post $post */ +?> +<article class="saltus-block__single"> + <?php if ( $attributes['showTitle'] ) : ?> + <h2 class="saltus-block__title"><?php echo esc_html( $post->post_title ); ?></h2> + <?php endif; ?> + <?php if ( $attributes['showContent'] ) : ?> + <div class="saltus-block__content"><?php echo wp_kses_post( apply_filters( 'the_content', $post->post_content ) ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound ?></div> + <?php endif; ?> + <?php if ( $meta !== [] ) : ?> + <dl class="saltus-block__meta"> + <?php foreach ( $meta as $saltus_path => $saltus_value ) : ?> + <dt><?php echo esc_html( $saltus_path ); ?></dt> + <dd><?php echo esc_html( is_scalar( $saltus_value ) ? (string) $saltus_value : wp_json_encode( $saltus_value ) ); ?></dd> + <?php endforeach; ?> + </dl> + <?php endif; ?> +</article> diff --git a/tests/Features/BlocksFeatureTest.php b/tests/Features/BlocksFeatureTest.php new file mode 100644 index 00000000..47ee4e6f --- /dev/null +++ b/tests/Features/BlocksFeatureTest.php @@ -0,0 +1,97 @@ +<?php +namespace Saltus\WP\Framework\Tests\Features; + +use PHPUnit\Framework\TestCase; +use Saltus\WP\Framework\Features\Blocks\BlockRenderer; +use Saltus\WP\Framework\Features\Blocks\SaltusBlocks; +use Saltus\WP\Framework\Modeler; +use Saltus\WP\Framework\Models\Model; +use WP_Post; + +require_once dirname( __DIR__ ) . '/Rest/functions.php'; + +/** + * @covers \Saltus\WP\Framework\Features\Blocks\SaltusBlocks + * @covers \Saltus\WP\Framework\Features\Blocks\BlockRenderer + */ +class BlocksFeatureTest extends TestCase { + + protected function setUp(): void { + global $wp_blocks_registered, $wp_scripts_enqueued, $wp_styles_enqueued, $wp_scripts_localized, $wp_query_posts, $wp_post_meta; + $wp_blocks_registered = []; + $wp_scripts_enqueued = []; + $wp_styles_enqueued = []; + $wp_scripts_localized = []; + $wp_query_posts = []; + $wp_post_meta = []; + } + + public function testNormalizeConfigSupportsBooleanAndIndependentViews(): void { + $this->assertSame( [ 'list' => true, 'single' => true, 'templates' => [] ], SaltusBlocks::normalize_config( true ) ); + $this->assertSame( [ 'list' => true, 'single' => false, 'templates' => [] ], SaltusBlocks::normalize_config( [ 'list' => true ] ) ); + $this->assertSame( [ 'list' => false, 'single' => false, 'templates' => [] ], SaltusBlocks::normalize_config( false ) ); + } + + public function testDefinitionsAndRegistrationUseApiVersionThree(): void { + global $wp_blocks_registered, $wp_scripts_localized; + $blocks = new SaltusBlocks( $this->modeler( [ $this->model( 'book', true ) ] ), [ 'root_url' => 'https://example.com/assets' ] ); + + $definitions = $blocks->definitions(); + $this->assertCount( 1, $definitions ); + $this->assertSame( [ 'list', 'single' ], array_keys( $definitions[0]['blocks'] ) ); + + $blocks->register(); + $this->assertArrayHasKey( 'saltus/book-list', $wp_blocks_registered ); + $this->assertArrayHasKey( 'saltus/book-single', $wp_blocks_registered ); + $this->assertSame( 3, $wp_blocks_registered['saltus/book-list']['api_version'] ); + $this->assertSame( [ 'isbn' ], $wp_blocks_registered['saltus/book-list']['attributes']['metaFields']['default'] ); + $this->assertSame( 'saltusBlockDefinitions', $wp_scripts_localized[0]['object_name'] ); + } + + public function testDisabledAndTaxonomyModelsDoNotRegisterBlocks(): void { + $blocks = new SaltusBlocks( $this->modeler( [ $this->model( 'book', false ), $this->model( 'genre', true, 'taxonomy' ) ] ) ); + $this->assertSame( [], $blocks->definitions() ); + } + + public function testRendererClampsQueryAndEscapesSingleOutput(): void { + global $wp_query_posts, $wp_post_meta, $wp_posts; + $post = new WP_Post( [ 'ID' => 7, 'post_type' => 'book', 'post_status' => 'publish', 'post_title' => '<Book>', 'post_content' => '<p>Body</p>', 'post_excerpt' => 'Excerpt' ] ); + $post->post_date = '2026-07-31 12:00:00'; + $wp_query_posts = [ $post ]; + $wp_posts[7] = $post; + $wp_post_meta[7] = [ 'isbn' => [ '123' ] ]; + $definition = ( new SaltusBlocks( $this->modeler( [ $this->model( 'book', true ) ] ) ) )->definitions()[0]; + $renderer = new BlockRenderer(); + + $list = $renderer->render( 'list', $definition, [ 'postsToShow' => 500, 'orderBy' => 'unsafe', 'metaFields' => [ 'isbn' ] ] ); + $this->assertStringContainsString( '<Book>', $list ); + $this->assertStringContainsString( '123', $list ); + + $single = $renderer->render( 'single', $definition, [ 'postId' => 7, 'metaFields' => [ 'isbn' ] ] ); + $this->assertStringContainsString( '<Book>', $single ); + $this->assertStringContainsString( '<p>Body</p>', $single ); + } + + private function modeler( array $models ): Modeler { + $modeler = $this->createStub( Modeler::class ); + $map = []; + foreach ( $models as $model ) { + $map[ $model->get_name() ] = $model; + } + $modeler->method( 'get_models' )->willReturn( $map ); + return $modeler; + } + + private function model( string $name, bool $blocks, string $type = 'post_type' ): Model { + $model = $this->createStub( Model::class ); + $model->method( 'get_name' )->willReturn( $name ); + $model->method( 'get_type' )->willReturn( $type ); + $model->method( 'get_config' )->willReturn( [ 'blocks' => $blocks ] ); + $model->method( 'get_options' )->willReturn( [ 'show_in_rest' => true, 'mcp_tools' => true ] ); + $model->method( 'get_args' )->willReturn( [ + 'labels' => [ 'name' => 'Books', 'singular_name' => 'Book' ], + 'meta' => [ 'details' => [ 'fields' => [ [ 'id' => 'isbn', 'type' => 'text', 'title' => 'ISBN' ] ] ] ], + ] ); + return $model; + } +} 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<string, mixed> */ + private array $config; - public function __construct( string $type ) { - $this->type = $type; + /** + * @param array<string, mixed> $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/Features/WpCliFeatureTest.php b/tests/Features/WpCliFeatureTest.php new file mode 100644 index 00000000..8f6a0ee1 --- /dev/null +++ b/tests/Features/WpCliFeatureTest.php @@ -0,0 +1,149 @@ +<?php +namespace Saltus\WP\Framework\Tests\Features; + +use PHPUnit\Framework\TestCase; +use Saltus\WP\Framework\Features\WpCli\CliGateway; +use Saltus\WP\Framework\Features\WpCli\CommandCatalog; +use Saltus\WP\Framework\Features\WpCli\Commands\BlockCommand; +use Saltus\WP\Framework\Features\WpCli\Commands\ModelCommand; +use Saltus\WP\Framework\Features\WpCli\Commands\PostCommand; +use Saltus\WP\Framework\Features\WpCli\Commands\ReorderCommand; +use Saltus\WP\Framework\Features\WpCli\Commands\SettingsCommand; +use Saltus\WP\Framework\Features\WpCli\WpCli; +use Saltus\WP\Framework\MCP\Tools\ToolInterface; +use Saltus\WP\Framework\Modeler; +use Saltus\WP\Framework\Models\Model; + +require_once dirname( __DIR__ ) . '/Rest/functions.php'; + +/** + * @covers \Saltus\WP\Framework\Features\WpCli\WpCli + * @covers \Saltus\WP\Framework\Features\WpCli\CommandCatalog + * @covers \Saltus\WP\Framework\Features\WpCli\Commands\AbstractCommand + * @covers \Saltus\WP\Framework\Features\WpCli\Commands\ModelCommand + * @covers \Saltus\WP\Framework\Features\WpCli\Commands\PostCommand + * @covers \Saltus\WP\Framework\Features\WpCli\Commands\SettingsCommand + * @covers \Saltus\WP\Framework\Features\WpCli\Commands\ReorderCommand + * @covers \Saltus\WP\Framework\Features\WpCli\Commands\BlockCommand + */ +class WpCliFeatureTest extends TestCase { + private TestCliGateway $cli; + private Modeler $modeler; + + protected function setUp(): void { + global $wp_posts, $wp_options, $wp_query_posts, $wp_current_user_can; + $this->cli = new TestCliGateway(); + $this->modeler = $this->modeler(); + $wp_posts = []; + $wp_options = []; + $wp_query_posts = []; + $wp_current_user_can = true; + } + + public function testRegistersEveryCommandGroup(): void { + $service = new WpCli( [ 'modeler_resolver' => function (): Modeler { return $this->modeler; } ], $this->cli ); + $service->register_commands(); + + $this->assertSame( [ + 'saltus', + 'saltus model', + 'saltus post', + 'saltus term', + 'saltus settings', + 'saltus meta', + 'saltus reorder', + 'saltus block', + ], array_keys( $this->cli->commands ) ); + } + + public function testCatalogMatchesEveryInstantiableMcpTool(): void { + $tool_names = []; + foreach ( glob( dirname( __DIR__, 2 ) . '/src/MCP/Tools/*.php' ) ?: [] as $file ) { + $class = 'Saltus\\WP\\Framework\\MCP\\Tools\\' . basename( $file, '.php' ); + if ( ! class_exists( $class ) ) { + continue; + } + $reflection = new \ReflectionClass( $class ); + $constructor = $reflection->getConstructor(); + if ( ! $reflection->isInstantiable() || ! $reflection->implementsInterface( ToolInterface::class ) || ( $constructor && $constructor->getNumberOfRequiredParameters() > 0 ) ) { + continue; + } + $tool = $reflection->newInstance(); + if ( $tool instanceof ToolInterface ) { + $tool_names[] = $tool->get_name(); + } + } + sort( $tool_names ); + $catalog = CommandCatalog::abilities(); + sort( $catalog ); + + $this->assertCount( 19, $catalog ); + $this->assertSame( $tool_names, $catalog ); + } + + public function testModelAndBlockCommandsProduceStructuredRows(): void { + $resolver = function (): Modeler { return $this->modeler; }; + ( new ModelCommand( $this->cli, $resolver ) )->list( [], [ 'format' => 'json' ] ); + $this->assertSame( 'json', $this->cli->formats[0]['format'] ); + $this->assertSame( 'book', $this->cli->formats[0]['items'][0]['name'] ); + + ( new BlockCommand( $this->cli, $resolver ) )->list( [], [] ); + $this->assertSame( 'saltus/book-list', $this->cli->formats[1]['items'][0]['block'] ); + } + + public function testPostCreateAndSettingsUpdateUseJsonPayloads(): void { + global $wp_posts, $wp_options; + $resolver = function (): Modeler { return $this->modeler; }; + ( new PostCommand( $this->cli, $resolver ) )->create( [ 'book', 'New Book' ], [ 'meta' => '{"isbn":"123"}' ] ); + $this->assertSame( 'New Book', end( $wp_posts )->post_title ); + + ( new SettingsCommand( $this->cli, $resolver ) )->update( [ 'book', '{"perPage":12}' ], [] ); + $this->assertSame( 12, $wp_options['saltus_framework_settings_book']['perPage'] ); + } + + public function testReorderRejectsObjectPayloadBeforeWriting(): void { + $this->expectException( \RuntimeException::class ); + ( new ReorderCommand( $this->cli, function (): Modeler { return $this->modeler; } ) )( [ '{"id":1}' ], [] ); + } + + private function modeler(): Modeler { + $model = $this->createStub( Model::class ); + $model->method( 'get_name' )->willReturn( 'book' ); + $model->method( 'get_type' )->willReturn( 'post_type' ); + $model->method( 'get_options' )->willReturn( [ 'show_in_rest' => true, 'mcp_tools' => true ] ); + $model->method( 'get_config' )->willReturn( [ 'blocks' => [ 'list' => true ], 'meta' => [] ] ); + $model->method( 'get_args' )->willReturn( [ 'labels' => [ 'name' => 'Books', 'singular_name' => 'Book' ], 'meta' => [] ] ); + $modeler = $this->createStub( Modeler::class ); + $modeler->method( 'get_models' )->willReturn( [ 'book' => $model ] ); + return $modeler; + } +} + +final class TestCliGateway implements CliGateway { + /** @var array<string, object|string> */ + public array $commands = []; + /** @var list<array<string, mixed>> */ + public array $formats = []; + /** @var list<string> */ + public array $messages = []; + + public function add_command( string $name, $command_handler ): void { + $this->commands[ $name ] = $command_handler; + } + + public function format_items( string $format, array $items, array $fields ): void { + $this->formats[] = compact( 'format', 'items', 'fields' ); + } + + public function line( string $message ): void { + $this->messages[] = $message; + } + + public function success( string $message ): void { + $this->messages[] = $message; + } + + public function error( string $message ): void { + throw new \RuntimeException( $message ); + } +} 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<string, mixed> $options * @return Model&object{options: array<string, mixed>} */ - private function createModelMock( array $options ) { - return new class( $options ) implements Model { + /** + * @param array<string, mixed> $options + * @param array<string, mixed> $config + * @return Model&object{options: array<string, mixed>} + */ + private function createModelMock( array $options, array $config = [] ) { + return new class( $options, $config ) implements Model { /** @var array<string, mixed> */ public array $options; + /** @var array<string, mixed> */ + public array $config; /** * @param array<string, mixed> $options + * @param array<string, mixed> $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 @@ +<?php + +namespace Saltus\WP\Framework\Tests\MCP; + +use PHPUnit\Framework\TestCase; +use Saltus\WP\Framework\MCP\MCPConfig; + +require_once dirname( __DIR__ ) . '/Rest/functions.php'; + +/** + * @covers \Saltus\WP\Framework\MCP\MCPConfig + */ +class MCPConfigTest extends TestCase { + + protected function setUp(): void { + global $wp_filter_values, $wp_filters_registered; + $wp_filter_values = []; + $wp_filters_registered = []; + } + + protected function tearDown(): void { + global $wp_filter_values, $wp_filters_registered; + $wp_filter_values = []; + $wp_filters_registered = []; + } + + public function testGetNamespaceReturnsDefault(): void { + $this->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 @@ +<?php + +namespace Saltus\WP\Framework\Tests\MCP; + +use PHPUnit\Framework\TestCase; +use Saltus\WP\Framework\MCP\McpPolicy; +use Saltus\WP\Framework\Modeler; +use Saltus\WP\Framework\Models\Model; +use Saltus\WP\Framework\Models\ModelFactory; +use Saltus\WP\Framework\Rest\ModelRestPolicy; + +require_once dirname( __DIR__ ) . '/Rest/functions.php'; + +/** + * @covers \Saltus\WP\Framework\MCP\McpPolicy + */ +class McpPolicyTest extends TestCase { + + public function testHasCapabilityReturnsTrueForHealth(): void { + $modeler = $this->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<string, mixed> $options + * @param array<string, mixed> $config + * @return Model&object{options: array<string, mixed>} + */ + 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<string, mixed> */ + public function get_options(): array { return $this->options; } + public function get_args(): array { return []; } + /** @return array<string, mixed> */ + 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 @@ +<?php +namespace Saltus\WP\Framework\Tests\MCP\Middleware; + +use PHPUnit\Framework\TestCase; +use Saltus\WP\Framework\MCP\Audit\AuditLogger; +use Saltus\WP\Framework\MCP\Middleware\AuditMiddleware; +use Saltus\WP\Framework\MCP\Middleware\MiddlewarePipeline; +use Saltus\WP\Framework\MCP\Middleware\RequestContext; + +require_once dirname( __DIR__, 2 ) . '/Rest/functions.php'; + +/** + * @covers \Saltus\WP\Framework\MCP\Middleware\AuditMiddleware + */ +class AuditMiddlewareTest extends TestCase { + + protected function setUp(): void { + global $wpdb; + $wpdb->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 @@ +<?php +namespace Saltus\WP\Framework\Tests\MCP\Middleware; + +use PHPUnit\Framework\TestCase; +use Saltus\WP\Framework\MCP\Cache\TransientCache; +use Saltus\WP\Framework\MCP\Middleware\CacheMiddleware; +use Saltus\WP\Framework\MCP\Middleware\MiddlewarePipeline; +use Saltus\WP\Framework\MCP\Middleware\RequestContext; + +require_once dirname( __DIR__, 2 ) . '/Rest/functions.php'; + +/** + * @covers \Saltus\WP\Framework\MCP\Middleware\CacheMiddleware + */ +class CacheMiddlewareTest extends TestCase { + + protected function setUp(): void { + global $wp_transients; + $wp_transients = []; + } + + public function testCachesGetResponse(): void { + $cache = new TransientCache(); + $pipeline = new MiddlewarePipeline(); + $pipeline->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 @@ +<?php +namespace Saltus\WP\Framework\Tests\MCP\Middleware; + +use PHPUnit\Framework\TestCase; +use Saltus\WP\Framework\MCP\Error\ErrorResponse; + +require_once dirname( __DIR__, 2 ) . '/Rest/functions.php'; + +/** + * @covers \Saltus\WP\Framework\MCP\Error\ErrorResponse + */ +class ErrorResponseTest extends TestCase { + + public function testForbidden(): void { + $error = ErrorResponse::forbidden( 'edit_posts', 'Assign edit_posts to your user.' ); + + $this->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 @@ +<?php +namespace Saltus\WP\Framework\Tests\MCP\Middleware; + +use PHPUnit\Framework\TestCase; +use Saltus\WP\Framework\MCP\Middleware\MiddlewareInterface; +use Saltus\WP\Framework\MCP\Middleware\MiddlewarePipeline; +use Saltus\WP\Framework\MCP\Middleware\RequestContext; + +require_once dirname( __DIR__, 2 ) . '/Rest/functions.php'; + +/** + * @covers \Saltus\WP\Framework\MCP\Middleware\MiddlewarePipeline + */ +class MiddlewarePipelineTest extends TestCase { + + public function testExecutesSingleStage(): void { + $pipeline = new MiddlewarePipeline(); + $pipeline->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 @@ +<?php +namespace Saltus\WP\Framework\Tests\MCP\Middleware; + +use PHPUnit\Framework\TestCase; +use Saltus\WP\Framework\MCP\Middleware\MiddlewarePipeline; +use Saltus\WP\Framework\MCP\Middleware\PermissionMiddleware; +use Saltus\WP\Framework\MCP\Middleware\RequestContext; +use Saltus\WP\Framework\Modeler; +use Saltus\WP\Framework\Models\ModelFactory; +use Saltus\WP\Framework\Rest\CapabilityPolicy; + +require_once dirname( __DIR__, 2 ) . '/Rest/functions.php'; + +/** + * @covers \Saltus\WP\Framework\MCP\Middleware\PermissionMiddleware + */ +class PermissionMiddlewareTest extends TestCase { + + protected function setUp(): void { + global $wp_current_user_can; + $wp_current_user_can = true; + } + + public function testAllowsWithSufficientPermission(): void { + $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/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 @@ +<?php +namespace Saltus\WP\Framework\Tests\MCP\Middleware; + +use PHPUnit\Framework\TestCase; +use Saltus\WP\Framework\MCP\Middleware\MiddlewareInterface; +use Saltus\WP\Framework\MCP\Middleware\MiddlewarePipeline; +use Saltus\WP\Framework\MCP\Middleware\PipelineIntegration; +use Saltus\WP\Framework\MCP\Middleware\RequestContext; + +require_once dirname( __DIR__, 2 ) . '/Rest/functions.php'; + +/** + * @covers \Saltus\WP\Framework\MCP\Middleware\PipelineIntegration + */ +class PipelineIntegrationTest extends TestCase { + + public function testRestPreDispatchShortCircuitsOnError(): void { + $pipeline = new MiddlewarePipeline(); + $pipeline->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 @@ +<?php +namespace Saltus\WP\Framework\Tests\MCP\Middleware; + +use PHPUnit\Framework\TestCase; +use Saltus\WP\Framework\MCP\Middleware\MiddlewarePipeline; +use Saltus\WP\Framework\MCP\Middleware\RateLimitMiddleware; +use Saltus\WP\Framework\MCP\Middleware\RequestContext; +use Saltus\WP\Framework\MCP\RateLimiter\RateLimiter; + +require_once dirname( __DIR__, 2 ) . '/Rest/functions.php'; + +/** + * @covers \Saltus\WP\Framework\MCP\Middleware\RateLimitMiddleware + */ +class RateLimitMiddlewareTest extends TestCase { + + protected function setUp(): void { + global $wp_transients; + $wp_transients = []; + } + + public function testAllowsRequestWithinLimit(): void { + $pipeline = new MiddlewarePipeline(); + $pipeline->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 @@ +<?php +namespace Saltus\WP\Framework\Tests\MCP\Middleware; + +use PHPUnit\Framework\TestCase; +use Saltus\WP\Framework\MCP\Middleware\RequestContext; + +require_once dirname( __DIR__, 2 ) . '/Rest/functions.php'; + +/** + * @covers \Saltus\WP\Framework\MCP\Middleware\RequestContext + */ +class RequestContextTest extends TestCase { + + public function testArgs(): void { + $context = new RequestContext(); + $this->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 @@ +<?php +namespace Saltus\WP\Framework\Tests\MCP\Middleware; + +use PHPUnit\Framework\TestCase; +use Saltus\WP\Framework\MCP\Middleware\MiddlewarePipeline; +use Saltus\WP\Framework\MCP\Middleware\ValidationMiddleware; +use Saltus\WP\Framework\MCP\Middleware\RequestContext; + +require_once dirname( __DIR__, 2 ) . '/Rest/functions.php'; + +/** + * @covers \Saltus\WP\Framework\MCP\Middleware\ValidationMiddleware + */ +class ValidationMiddlewareTest extends TestCase { + + public function testValidatesToolArgs(): void { + $pipeline = new MiddlewarePipeline(); + $pipeline->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 @@ +<?php + +namespace Saltus\WP\Framework\Tests\MCP\Tools; + +use PHPUnit\Framework\TestCase; +use Saltus\WP\Framework\MCP\Tools\DeletePost; +use WP_REST_Request; + +require_once dirname( __DIR__, 2 ) . '/Rest/functions.php'; + +/** + * @covers \Saltus\WP\Framework\MCP\Tools\DeletePost + */ +class DeletePostTest extends TestCase { + + private DeletePost $tool; + + protected function setUp(): void { + global $wp_post_type_objects, $wp_current_user_can; + $wp_post_type_objects = []; + $wp_current_user_can = true; + + $this->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 @@ +<?php + +namespace Saltus\WP\Framework\Tests\MCP\Tools; + +use PHPUnit\Framework\TestCase; +use Saltus\WP\Framework\MCP\Tools\GetHealth; +use Saltus\WP\Framework\Rest\ModelRestPolicy; +use WP_REST_Request; + +require_once dirname( __DIR__, 2 ) . '/Rest/functions.php'; + +/** + * @covers \Saltus\WP\Framework\MCP\Tools\GetHealth + * @covers \Saltus\WP\Framework\MCP\Tools\RestTool + */ +class GetHealthTest extends TestCase { + + private GetHealth $tool; + + protected function setUp(): void { + $this->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/MCP/Tools/ListBlockModelsTest.php b/tests/MCP/Tools/ListBlockModelsTest.php new file mode 100644 index 00000000..8c6ebddd --- /dev/null +++ b/tests/MCP/Tools/ListBlockModelsTest.php @@ -0,0 +1,22 @@ +<?php +namespace Saltus\WP\Framework\Tests\MCP\Tools; + +use PHPUnit\Framework\TestCase; +use Saltus\WP\Framework\MCP\Tools\ListBlockModels; +use Saltus\WP\Framework\Rest\ModelRestPolicy; + +require_once dirname( __DIR__, 2 ) . '/Rest/functions.php'; + +/** @covers \Saltus\WP\Framework\MCP\Tools\ListBlockModels */ +class ListBlockModelsTest extends TestCase { + public function testToolMetadataAndRequest(): void { + $tool = new ListBlockModels(); + $this->assertSame( 'list_block_models', $tool->get_name() ); + $this->assertSame( [], $tool->get_parameters() ); + $this->assertTrue( $tool->is_cacheable() ); + $this->assertSame( ModelRestPolicy::CAPABILITY_BLOCKS, $tool->get_rest_capability()->get_capability() ); + $request = $tool->build_rest_request( [] ); + $this->assertSame( 'GET', $request->get_method() ); + $this->assertSame( '/saltus-framework/v1/blocks', $request->get_route() ); + } +} diff --git a/tests/Rest/BlocksControllerTest.php b/tests/Rest/BlocksControllerTest.php new file mode 100644 index 00000000..9f3dc50c --- /dev/null +++ b/tests/Rest/BlocksControllerTest.php @@ -0,0 +1,65 @@ +<?php +namespace Saltus\WP\Framework\Tests\Rest; + +use PHPUnit\Framework\TestCase; +use Saltus\WP\Framework\Modeler; +use Saltus\WP\Framework\Models\Model; +use Saltus\WP\Framework\Rest\BlocksController; +use Saltus\WP\Framework\Rest\ModelRestPolicy; +use WP_Error; +use WP_REST_Request; + +require_once __DIR__ . '/functions.php'; + +/** @covers \Saltus\WP\Framework\Rest\BlocksController */ +class BlocksControllerTest extends TestCase { + + protected function setUp(): void { + global $wp_rest_routes_registered, $wp_current_user_can; + $wp_rest_routes_registered = []; + $wp_current_user_can = true; + } + + public function testRegistersRouteAndChecksPermission(): void { + global $wp_rest_routes_registered, $wp_current_user_can; + $modeler = $this->modeler( [] ); + $controller = new BlocksController( $modeler, new ModelRestPolicy( $modeler ) ); + $controller->register_routes(); + $this->assertSame( '/blocks', $wp_rest_routes_registered[0]['route'] ); + + $wp_current_user_can = false; + $this->assertInstanceOf( WP_Error::class, $controller->get_items_permissions_check( new WP_REST_Request() ) ); + } + + public function testReturnsOnlyRestEnabledBlockModels(): void { + $visible = $this->model( 'book', true ); + $hidden = $this->model( 'note', false ); + $modeler = $this->modeler( [ $visible, $hidden ] ); + $result = ( new BlocksController( $modeler, new ModelRestPolicy( $modeler ) ) )->get_items( new WP_REST_Request() )->get_data(); + + $this->assertCount( 1, $result['post_types'] ); + $this->assertSame( 'book', $result['post_types'][0]['post_type'] ); + $this->assertSame( 'saltus/book-list', $result['post_types'][0]['blocks']['list'] ); + } + + /** @param list<Model> $models */ + private function modeler( array $models ): Modeler { + $modeler = $this->createStub( Modeler::class ); + $map = []; + foreach ( $models as $model ) { + $map[ $model->get_name() ] = $model; + } + $modeler->method( 'get_models' )->willReturn( $map ); + return $modeler; + } + + private function model( string $name, bool $show_in_rest ): Model { + $model = $this->createStub( Model::class ); + $model->method( 'get_name' )->willReturn( $name ); + $model->method( 'get_type' )->willReturn( 'post_type' ); + $model->method( 'get_config' )->willReturn( [ 'blocks' => [ 'list' => true, 'show_in_rest' => $show_in_rest ] ] ); + $model->method( 'get_options' )->willReturn( [ 'show_in_rest' => true ] ); + $model->method( 'get_args' )->willReturn( [ 'labels' => [ 'name' => ucfirst( $name ) . 's', 'singular_name' => ucfirst( $name ) ] ] ); + return $model; + } +} 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<string, mixed> $options * @return Model&object{options: array<string, mixed>} */ - 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<string, mixed> */ public array $options; + /** @var array<string, mixed> */ + public array $config; /** * @param array<string, mixed> $options + * @param array<string, mixed> $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<string, mixed>} */ - 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<string, mixed> */ public array $args; /** @var array<string, mixed> */ public array $options; + /** @var array<string, mixed> */ + public array $config = []; private string $type; /** * @param array<string, mixed> $args * @param array<string, mixed> $options + * @param array<string, mixed> $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<string, mixed> */ public array $options; + /** @var array<string, mixed> */ + public array $config; private string $getType; /** * @param array<string, mixed> $options + * @param array<string, mixed> $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<string, mixed> $options * @return Model&object{options: array<string, mixed>} */ - 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<string, mixed> */ public array $options; + /** @var array<string, mixed> */ + public array $config; /** * @param array<string, mixed> $options + * @param array<string, mixed> $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<string, mixed>} */ - 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<string, mixed> */ public array $options; + /** @var array<string, mixed> */ + public array $config; /** * @param array<string, mixed> $options + * @param array<string, mixed> $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<string, mixed> $options * @return Model&object{options: array<string, mixed>} */ - 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<string, mixed> */ public array $options; + /** @var array<string, mixed> */ + public array $config; - /** - * @param array<string, mixed> $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..7d7dd1f3 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 []; + } } } @@ -151,6 +155,7 @@ class WP_Post { public string $post_name = ''; public int $post_parent = 0; public string $post_excerpt = ''; + public string $post_date = '2026-01-01 00:00:00'; public string $post_password = ''; public string $comment_status = 'open'; public string $ping_status = 'open'; @@ -178,6 +183,8 @@ public function __construct( array $properties = [] ) { $wp_scripts_enqueued = []; $wp_styles_enqueued = []; $wp_scripts_localized = []; +$wp_blocks_registered = []; +$wp_query_posts = []; $wp_nonce_valid = true; $wp_meta_updates = []; $wp_post_type_objects = []; @@ -755,11 +762,14 @@ function add_query_arg( $key, $value = false, string $url = '' ): string { if ( ! class_exists( 'WP_Query' ) ) { class WP_Query { public array $query = []; + public array $posts = []; private array $vars = []; public function __construct( array $query = [] ) { + global $wp_query_posts; $this->query = $query; $this->vars = $query; + $this->posts = is_array( $wp_query_posts ) ? $wp_query_posts : []; } public function get( string $key ) { @@ -772,10 +782,55 @@ public function set( string $key, $value ): void { } } +if ( ! function_exists( 'register_block_type' ) ) { + function register_block_type( string $block_type, array $args = [] ) { + global $wp_blocks_registered; + $wp_blocks_registered[ $block_type ] = $args; + return (object) [ 'name' => $block_type ]; + } +} + +if ( ! function_exists( 'get_block_wrapper_attributes' ) ) { + function get_block_wrapper_attributes( array $extra_attributes = [] ): string { + $class = (string) ( $extra_attributes['class'] ?? '' ); + return 'class="' . esc_attr( $class ) . '"'; + } +} + +if ( ! function_exists( 'is_object_in_taxonomy' ) ) { + function is_object_in_taxonomy( string $object_type, string $taxonomy ): bool { + global $wp_taxonomy_objects; + if ( ! isset( $wp_taxonomy_objects[ $taxonomy ] ) ) { + return false; + } + $types = $wp_taxonomy_objects[ $taxonomy ]->object_type ?? []; + return in_array( $object_type, (array) $types, true ); + } +} + +if ( ! function_exists( 'locate_template' ) ) { + function locate_template( $template_names, bool $load = false, bool $load_once = true, array $args = [] ): string { + return ''; + } +} + +if ( ! function_exists( 'wp_kses_post' ) ) { + function wp_kses_post( $data ): string { + return (string) $data; + } +} + +if ( ! function_exists( 'mysql2date' ) ) { + function mysql2date( string $format, string $date, bool $translate = true ): string { + return date( $format, strtotime( $date ) ); + } +} + if ( ! class_exists( 'WP_Term' ) ) { class WP_Term { public int $term_id = 0; public string $name = ''; + public string $slug = ''; public int $count = 0; public function __construct( array $properties = [] ) { @@ -786,6 +841,43 @@ public function __construct( array $properties = [] ) { } } +if ( ! function_exists( 'sanitize_title' ) ) { + function sanitize_title( string $title ): string { + return trim( preg_replace( '/[^a-z0-9]+/', '-', strtolower( $title ) ), '-' ); + } +} + +if ( ! function_exists( 'sanitize_textarea_field' ) ) { + function sanitize_textarea_field( string $str ): string { + return trim( $str ); + } +} + +if ( ! function_exists( 'get_terms' ) ) { + function get_terms( array $args = [] ) { + global $wp_terms; + return is_array( $wp_terms ?? null ) ? $wp_terms : []; + } +} + +if ( ! function_exists( 'wp_insert_term' ) ) { + function wp_insert_term( string $term, string $taxonomy, array $args = [] ) { + global $wp_terms; + $term_id = count( (array) ( $wp_terms ?? [] ) ) + 1; + $wp_terms[] = new WP_Term( [ 'term_id' => $term_id, 'name' => $term, 'slug' => $args['slug'] ?? sanitize_title( $term ) ] ); + return [ 'term_id' => $term_id, 'term_taxonomy_id' => $term_id ]; + } +} + +if ( ! function_exists( 'wp_delete_post' ) ) { + function wp_delete_post( int $post_id, bool $force_delete = false ): ?WP_Post { + global $wp_posts; + $post = $wp_posts[ $post_id ] ?? null; + unset( $wp_posts[ $post_id ] ); + return $post instanceof WP_Post ? $post : null; + } +} + if ( ! function_exists( 'wp_verify_nonce' ) ) { function wp_verify_nonce( $nonce, string $action = '' ) { global $wp_nonce_valid; @@ -839,6 +931,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 []; + } }