Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Fixed
- **Asset field values are round-trip safe** (#41) — `get` returns an assets field the way Statamic stores it, as a container-relative path (`icons/heart.svg`), but both the validation rules and the fieldtype pipeline expect the form the Control Panel submits, the asset ID (`assets::icons/heart.svg`). Sending a value straight back to `create`/`update` therefore failed file rules such as `mimes` — `MimesRule` does an `Asset::find()` on the value and a bare path finds nothing — and on rule-free fields it would have broken later in `Assets::process()`, which calls `Asset::findOrFail()`. Incoming asset paths are now resolved to canonical IDs before validation, in nested replicator, grid, group and bard set fields as well as top-level ones, and in the entry's stored data that an update merges in — so an unchanged asset field elsewhere in the blueprint no longer fails an update that never touched it. Values that resolve to no asset are left alone so validation still reports the real problem instead of silently dropping content

- **`content_validate` no longer flags every valid assets field** — The read-side sweep ran the blueprint's rules against stored values, which meant it reported up to three errors for a perfectly valid single-file assets field: "must be a file of type: svg" (the file rules resolve the value with `Asset::find()`, which a bare path misses), plus "must be an array" and "must not have more than 1 items" from the fieldtype's own rules, which expect a list. Asset references are now bridged to canonical IDs for the rule pass, through nested replicator, bard, grid and group fields as well as top-level ones. The structural pass still sees the stored values verbatim, so a `missing_asset` finding keeps quoting the reference as it appears on disk
- **`content_validate` resolves single-container asset fields** — An assets field that omits `container` where the site has exactly one was skipped by the missing-asset check; it now resolves the same way the fieldtype does

## [2.9.0] - 2026-08-27

### Added
Expand Down
90 changes: 90 additions & 0 deletions src/Mcp/Tools/Concerns/ResolvesAssetIds.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<?php

declare(strict_types=1);

namespace Cboxdk\StatamicMcp\Mcp\Tools\Concerns;

use Statamic\Contracts\Assets\AssetContainer as AssetContainerContract;
use Statamic\Facades\Asset;
use Statamic\Facades\AssetContainer;
use Statamic\Fields\Field;

/**
* Bridges the two forms an assets field value takes.
*
* Statamic *stores* an assets field as container-relative paths — a bare string
* when `max_files` is 1, a list otherwise — and that is what this addon reads
* back out. Everything downstream of a Control Panel form submission instead
* expects the canonical `container::path` asset ID in a list: the file rules
* (`mimes`, `image`, `dimensions`, `max_filesize`) resolve the value with
* `Asset::find()`, the fieldtype's own `array`/`min`/`max` rules require a list,
* and `Assets::process()` calls `Asset::findOrFail()` before writing back.
*
* The Control Panel bridges the gap in `Assets::preProcess()` when it loads the
* form. Anything that hands stored values to the validator without that step —
* a write whose payload came from `get`, or the read-side sweep over content
* already on disk — has to bridge it here instead.
*/
trait ResolvesAssetIds
{
/**
* Normalize an assets field's value into the list of canonical IDs that
* validation and the fieldtype pipeline expect.
*
* References that resolve to no asset are passed through untouched, so
* validation reports the real problem rather than silently dropping content.
*
* @return array<int, mixed>
*/
protected function normalizeAssetFieldValue(Field $field, mixed $value): array
{
$values = match (true) {
is_array($value) => array_values($value),
is_string($value) && $value !== '' => [$value],
default => [],
};

if ($values === [] || ($container = $this->assetFieldContainer($field)) === null) {
return $values;
}

return array_map(fn (mixed $item): mixed => $this->resolveAssetId($item, $container), $values);
}

/**
* Rewrite one stored reference to its canonical `container::path` ID.
*/
protected function resolveAssetId(mixed $reference, string $container): mixed
{
if (! is_string($reference) || $reference === '' || str_contains($reference, '::')) {
return $reference;
}

$id = $container . '::' . ltrim($reference, '/');

return Asset::find($id) ? $id : $reference;
}

/**
* Resolve the container an assets field points at, mirroring the fieldtype's
* own resolution: the configured container, or the only one that exists.
*/
protected function assetFieldContainer(Field $field): ?string
{
$configured = $field->get('container');

if (is_string($configured) && $configured !== '') {
return $configured;
}

$containers = AssetContainer::all();

if ($containers->count() !== 1) {
return null;
}

$only = $containers->first();

return $only instanceof AssetContainerContract ? $only->handle() : null;
}
}
5 changes: 4 additions & 1 deletion src/Mcp/Tools/Concerns/SanitizesFieldData.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@

trait SanitizesFieldData
{
use ResolvesAssetIds;

/**
* Keys that are entry-level properties, not blueprint data fields.
*
Expand Down Expand Up @@ -117,7 +119,8 @@ private function sanitizeFieldValue(Field $field, mixed $value, bool $allowLegac
'grid' => $this->sanitizeGridValue($field, $value, $allowLegacyCoercion, $path),
'replicator' => $this->sanitizeReplicatorValue($field, $value, $allowLegacyCoercion, $path),
'table' => $this->sanitizeTableValue($value, $allowLegacyCoercion, $path),
'terms', 'entries', 'users', 'assets', 'checkboxes' => $this->sanitizeRelationshipValue($value),
'assets' => $this->normalizeAssetFieldValue($field, $value),
'terms', 'entries', 'users', 'checkboxes' => $this->sanitizeRelationshipValue($value),
default => $value,
};
}
Expand Down
141 changes: 138 additions & 3 deletions src/Mcp/Tools/Concerns/ValidatesContentRecords.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@
*/
trait ValidatesContentRecords
{
use ResolvesAssetIds;

/** Keys a replicator/bard/grid row carries that are not blueprint fields. */
private const SET_META_KEYS = ['id', 'type', 'enabled'];

Expand All @@ -54,7 +56,10 @@ trait ValidatesContentRecords
protected function validateRecord(Fields $fields, array $data, RecordRef $record): array
{
return [
...$this->ruleFindings($fields, $data, $record),
// The rule pass sees asset references bridged to the form the rules
// expect; the structural pass sees the stored values verbatim, so a
// finding still quotes what is actually on disk.
...$this->ruleFindings($fields, $this->withResolvedAssetIds($fields, $data), $record),
...$this->structuralFindings($fields, $data, '', $record),
];
}
Expand Down Expand Up @@ -376,11 +381,11 @@ private function optionFindings(Field $field, mixed $value, string $path, Record
*/
private function assetFindings(Field $field, mixed $value, string $path, RecordRef $record): array
{
$container = $field->get('container');
$container = $this->assetFieldContainer($field);

// Without a container we cannot resolve the reference at all; that is a
// blueprint problem, which statamic-blueprints validate already reports.
if (! is_string($container) || $container === '') {
if ($container === null) {
return [];
}

Expand Down Expand Up @@ -408,6 +413,136 @@ private function assetFindings(Field $field, mixed $value, string $path, RecordR
return $findings;
}

/**
* Return a copy of the stored values with every assets reference rewritten
* to the canonical ID the validation rules expect.
*
* Statamic's validator recurses into nested fields on its own, but it does
* so over whatever values it was handed, so the bridge has to be applied to
* the whole tree up front. Nothing but assets values is touched.
*
* @param array<string, mixed> $data
*
* @return array<string, mixed>
*/
private function withResolvedAssetIds(Fields $fields, array $data): array
{
foreach ($fields->all() as $handle => $field) {
if (! is_string($handle) || ! $field instanceof Field || ! array_key_exists($handle, $data)) {
continue;
}

$value = $data[$handle];

$data[$handle] = match ($field->type()) {
'assets' => $this->normalizeAssetFieldValue($field, $value),
'replicator' => $this->withResolvedAssetIdsInSets($field, $value),
'bard' => $this->withResolvedAssetIdsInBard($field, $value),
'grid' => $this->withResolvedAssetIdsInRows($field, $value),
'group' => is_array($value)
? $this->withResolvedAssetIds($this->nestedFields($field), $this->stringKeyed($value))
: $value,
default => $value,
};
}

return $data;
}

private function withResolvedAssetIdsInSets(Field $field, mixed $value): mixed
{
if (! is_array($value)) {
return $value;
}

$sets = $this->flattenedSets($field);

return array_map(function (mixed $block) use ($sets): mixed {
if (! is_array($block) || ! is_string($type = $block['type'] ?? null) || ! isset($sets[$type])) {
return $block;
}

return $this->withResolvedAssetIds(new Fields($sets[$type]), $this->stringKeyed($block));
}, $value);
}

/**
* Bard keeps a set's own values under `attrs.values`; the rest of the tree
* is rich-text nodes with nothing to resolve.
*/
private function withResolvedAssetIdsInBard(Field $field, mixed $value): mixed
{
if (! is_array($value)) {
return $value;
}

$sets = $this->flattenedSets($field);

return array_map(function (mixed $node) use ($sets): mixed {
if (! is_array($node) || ($node['type'] ?? null) !== 'set') {
return $node;
}

$attrs = $node['attrs'] ?? null;
$values = is_array($attrs) ? ($attrs['values'] ?? null) : null;

if (! is_array($values) || ! is_string($type = $values['type'] ?? null) || ! isset($sets[$type])) {
return $node;
}

$attrs['values'] = $this->withResolvedAssetIds(new Fields($sets[$type]), $this->stringKeyed($values));
$node['attrs'] = $attrs;

return $node;
}, $value);
}

private function withResolvedAssetIdsInRows(Field $field, mixed $value): mixed
{
if (! is_array($value)) {
return $value;
}

$fields = $this->nestedFields($field);

return array_map(
fn (mixed $row): mixed => is_array($row)
? $this->withResolvedAssetIds($fields, $this->stringKeyed($row))
: $row,
$value
);
}

/**
* Resolve the child fields of a grid or group field.
*/
private function nestedFields(Field $field): Fields
{
$config = $field->get('fields');

return new Fields(is_array($config) ? array_values($config) : []);
}

/**
* Re-key a nested block or row so it carries the handle-keyed shape the
* walk expects. YAML can hand back numeric-looking keys, which PHP casts
* to integers on the way in.
*
* @param array<mixed> $value
*
* @return array<string, mixed>
*/
private function stringKeyed(array $value): array
{
$keyed = [];

foreach ($value as $key => $item) {
$keyed[(string) $key] = $item;
}

return $keyed;
}

/**
* Resolve a replicator/bard field's sets to [set handle => field configs].
*
Expand Down
54 changes: 54 additions & 0 deletions tests/Feature/Routers/ContentValidateTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,60 @@ public function test_accepts_numeric_option_keys_stored_as_strings(): void
$this->assertCount(1, $this->findingsOfType($this->validate(), 'invalid_option'));
}

public function test_does_not_report_stored_asset_paths_as_rule_violations(): void
{
config(['filesystems.disks.assets' => [
'driver' => 'local',
'root' => storage_path('framework/testing/disks/assets'),
]]);
Storage::fake('assets');
Storage::disk('assets')->put('icons/heart.svg', '<svg xmlns="http://www.w3.org/2000/svg"></svg>');

AssetContainer::make('media')->title('Media')->disk('assets')->save();

Blueprint::make('validated-assets')
->setNamespace("collections.{$this->collectionHandle}")
->setContents([
'title' => 'Validated Assets',
'tabs' => ['main' => ['sections' => [['fields' => [
['handle' => 'title', 'field' => ['type' => 'text']],
['handle' => 'icon', 'field' => [
'type' => 'assets',
'container' => 'media',
'max_files' => 1,
'validate' => ['required', 'mimes:svg'],
]],
['handle' => 'blocks', 'field' => [
'type' => 'replicator',
'sets' => ['content' => ['sets' => ['card' => ['fields' => [
['handle' => 'card_icon', 'field' => [
'type' => 'assets',
'container' => 'media',
'max_files' => 1,
'validate' => ['required', 'mimes:svg'],
]],
]]]]],
]],
]]]]],
])
->save();

Entry::make()
->collection($this->collectionHandle)
->slug('valid-icons')
->blueprint('validated-assets')
->data([
'title' => 'Valid Icons',
'icon' => 'icons/heart.svg',
'blocks' => [
['type' => 'card', 'id' => 'block-1', 'card_icon' => 'icons/heart.svg'],
],
])
->save();

$this->assertSame([], $this->findingsOfType($this->validate(), 'rule_violation'));
}

public function test_reports_asset_references_that_no_longer_resolve(): void
{
config(['filesystems.disks.assets' => [
Expand Down
Loading
Loading