diff --git a/database/migrations/2026_04_23_100000_create_visual_editor_template_parts_table.php b/database/migrations/2026_04_23_100000_create_visual_editor_template_parts_table.php new file mode 100644 index 000000000..7a6443872 --- /dev/null +++ b/database/migrations/2026_04_23_100000_create_visual_editor_template_parts_table.php @@ -0,0 +1,55 @@ + + * + * @since 1.0.0 + */ + +declare( strict_types=1 ); + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + public function up(): void + { + Schema::create( 'visual_editor_template_parts', function ( Blueprint $table ) { + $table->id(); + $table->string( 'slug' ); + $table->string( 'title' )->default( '' ); + // The API envelope { raw, blocks } is stored verbatim; an empty + // part is `{ "raw": "", "blocks": [] }` so the column is never + // null once hydrated through the model. + $table->json( 'content' )->nullable(); + // Kept as a plain string (not an enum column) so new areas can + // be added without a schema migration; form-request validation + // gates the allowed values at the application layer. + $table->string( 'area' )->default( 'uncategorized' )->index(); + $table->string( 'theme' ); + $table->timestamps(); + + // `slug` is only unique within a theme — the same `header` + // slug can legitimately exist on two themes installed side + // by side. + $table->unique( [ 'slug', 'theme' ] ); + } ); + } + + public function down(): void + { + Schema::dropIfExists( 'visual_editor_template_parts' ); + } +}; diff --git a/routes/api.php b/routes/api.php index 99c0f2ecd..d45600bfa 100644 --- a/routes/api.php +++ b/routes/api.php @@ -20,6 +20,7 @@ use ArtisanPackUI\VisualEditor\Http\Controllers\BlockPreviewController; use ArtisanPackUI\VisualEditor\Http\Controllers\ResourceContentController; use ArtisanPackUI\VisualEditor\Http\Controllers\TemplateController; +use ArtisanPackUI\VisualEditor\Http\Controllers\TemplatePartController; use ArtisanPackUI\VisualEditor\Http\Controllers\VisualEditorBlocksController; use ArtisanPackUI\VisualEditor\Http\Controllers\VisualEditorPostsController; use Illuminate\Support\Facades\Route; @@ -56,6 +57,22 @@ Route::delete( 'templates/{template}', [ TemplateController::class, 'destroy' ] ) ->name( 'visual-editor.api.templates.destroy' ); +// C2 `wp_template_part` REST surface — see docs/core-data-shim.md §Template parts. +Route::get( 'template-parts', [ TemplatePartController::class, 'index' ] ) + ->name( 'visual-editor.api.template-parts.index' ); + +Route::post( 'template-parts', [ TemplatePartController::class, 'store' ] ) + ->name( 'visual-editor.api.template-parts.store' ); + +Route::get( 'template-parts/{templatePart}', [ TemplatePartController::class, 'show' ] ) + ->name( 'visual-editor.api.template-parts.show' ); + +Route::put( 'template-parts/{templatePart}', [ TemplatePartController::class, 'update' ] ) + ->name( 'visual-editor.api.template-parts.update' ); + +Route::delete( 'template-parts/{templatePart}', [ TemplatePartController::class, 'destroy' ] ) + ->name( 'visual-editor.api.template-parts.destroy' ); + // Legacy ve_contents routes retained for the existing editor tests and the // `VisualEditorPost` model. Deprecated in M3 in favor of the resource routes // above; removed once the dev app migrates off the seeded post fixture. diff --git a/src/Http/Controllers/TemplatePartController.php b/src/Http/Controllers/TemplatePartController.php new file mode 100644 index 000000000..6a4e83618 --- /dev/null +++ b/src/Http/Controllers/TemplatePartController.php @@ -0,0 +1,295 @@ + + * + * @since 1.0.0 + */ + +declare( strict_types=1 ); + +namespace ArtisanPackUI\VisualEditor\Http\Controllers; + +use ArtisanPackUI\VisualEditor\Http\Requests\StoreTemplatePartRequest; +use ArtisanPackUI\VisualEditor\Http\Requests\UpdateTemplatePartRequest; +use ArtisanPackUI\VisualEditor\Http\Resources\TemplatePartResource; +use ArtisanPackUI\VisualEditor\Models\VisualEditorTemplate; +use ArtisanPackUI\VisualEditor\Models\VisualEditorTemplatePart; +use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; +use Illuminate\Http\Resources\Json\AnonymousResourceCollection; +use Illuminate\Http\Response; +use Illuminate\Routing\Controller; +use Illuminate\Support\Facades\Gate; + +class TemplatePartController extends Controller +{ + /** + * Maximum per-page size for the index endpoint. Keeps a malicious or + * accidental `?per_page=999999` query from dragging the site-editor + * sidebar down while still letting the host page through explicit + * requests when necessary. + */ + protected const MAX_PER_PAGE = 100; + + /** + * Lists template parts with a paginated `{ data, meta }` envelope. + * + * The shim reads `meta.total` and `meta.last_page` for the selectors + * `getEntityRecordsTotalItems` / `getEntityRecordsTotalPages`; the + * default Laravel `Resource::collection( $paginator )` response + * already emits those keys, so no custom envelope is necessary. + * + * @since 1.0.0 + */ + public function index( Request $request ): AnonymousResourceCollection + { + Gate::authorize( 'viewAny', VisualEditorTemplatePart::class ); + + $perPage = (int) $request->integer( 'per_page', 25 ); + + if ( $perPage < 1 ) { + $perPage = 25; + } + + if ( $perPage > self::MAX_PER_PAGE ) { + $perPage = self::MAX_PER_PAGE; + } + + $query = VisualEditorTemplatePart::query()->orderBy( 'id' ); + + $theme = $request->string( 'theme' )->toString(); + if ( '' !== $theme ) { + $query->where( 'theme', $theme ); + } + + $slug = $request->string( 'slug' )->toString(); + if ( '' !== $slug ) { + $query->where( 'slug', $slug ); + } + + $area = $request->string( 'area' )->toString(); + if ( '' !== $area ) { + $query->where( 'area', $area ); + } + + return TemplatePartResource::collection( $query->paginate( $perPage ) ); + } + + /** + * Returns a single template part. + * + * The shim expects the record at the top level (not wrapped in `data`) + * so `fetchEntityRecord` can dispatch it straight into the cache. + * `referenced_by` is merged in at the top level so the D2 delete + * flow can read it without a separate request. + * + * @since 1.0.0 + */ + public function show( Request $request, VisualEditorTemplatePart $templatePart ): JsonResponse + { + Gate::authorize( 'view', $templatePart ); + + $payload = ( new TemplatePartResource( $templatePart ) )->toArray( $request ); + + $payload['referenced_by'] = $this->resolveReferencedBy( $templatePart ); + + return response()->json( $payload ); + } + + /** + * Creates a new template part. + * + * @since 1.0.0 + */ + public function store( StoreTemplatePartRequest $request ): JsonResponse + { + Gate::authorize( 'create', VisualEditorTemplatePart::class ); + + $data = $request->validated(); + + $part = new VisualEditorTemplatePart(); + $part->fill( [ + 'slug' => $data['slug'], + 'title' => $data['title'] ?? '', + 'area' => $data['area'], + 'theme' => $data['theme'], + ] ); + + $part->setContentEnvelope( $this->normalizeContentEnvelope( $data['content'] ?? null ) ); + $part->save(); + + return response()->json( + ( new TemplatePartResource( $part ) )->toArray( $request ), + Response::HTTP_CREATED + ); + } + + /** + * Updates an existing template part. + * + * @since 1.0.0 + */ + public function update( UpdateTemplatePartRequest $request, VisualEditorTemplatePart $templatePart ): JsonResponse + { + Gate::authorize( 'update', $templatePart ); + + $data = $request->validated(); + + foreach ( [ 'slug', 'title', 'area', 'theme' ] as $field ) { + if ( array_key_exists( $field, $data ) ) { + $templatePart->{$field} = $data[ $field ]; + } + } + + if ( array_key_exists( 'content', $data ) ) { + $templatePart->setContentEnvelope( $this->normalizeContentEnvelope( $data['content'] ) ); + } + + $templatePart->save(); + + return response()->json( ( new TemplatePartResource( $templatePart ) )->toArray( $request ) ); + } + + /** + * Deletes a template part. + * + * @since 1.0.0 + */ + public function destroy( VisualEditorTemplatePart $templatePart ): JsonResponse + { + Gate::authorize( 'delete', $templatePart ); + + $templatePart->delete(); + + return response()->json( null, Response::HTTP_NO_CONTENT ); + } + + /** + * Normalizes the inbound content payload into the `{ raw, blocks }` + * envelope the model expects. Form-request validation guarantees + * the shape before we get here; this method just guards against + * missing keys and bad types as a belt-and-braces fallback. + * + * @since 1.0.0 + * + * @param mixed $content + * + * @return array{raw: string, blocks: array>} + */ + protected function normalizeContentEnvelope( mixed $content ): array + { + if ( ! is_array( $content ) ) { + return [ 'raw' => '', 'blocks' => [] ]; + } + + return [ + 'raw' => isset( $content['raw'] ) && is_string( $content['raw'] ) ? $content['raw'] : '', + 'blocks' => isset( $content['blocks'] ) && is_array( $content['blocks'] ) ? array_values( $content['blocks'] ) : [], + ]; + } + + /** + * Resolves the list of template slugs whose block tree embeds this + * part via a `core/template-part` block. + * + * Correctness matters more than performance here: the V1 site-editor + * only calls this on a single part at a time (before showing a + * delete-confirmation dialog), so a linear scan over the templates + * for the matching theme is fine. If D2 grows to render the browser + * with per-part reference counts, this can move to a cached + * aggregate without changing the response contract. + * + * @since 1.0.0 + * + * @return array + */ + protected function resolveReferencedBy( VisualEditorTemplatePart $part ): array + { + $slug = $part->slug; + $theme = $part->theme; + + // A part is only referenced from templates in the same theme — + // `core/template-part` attributes carry the theme alongside the + // slug, so the cross-theme case never resolves. + $templates = VisualEditorTemplate::query() + ->where( 'theme', $theme ) + ->orderBy( 'slug' ) + ->get( [ 'slug', 'content' ] ); + + $matches = []; + + foreach ( $templates as $template ) { + if ( $this->blockTreeReferencesPart( $template->getBlocks(), $slug, $theme ) ) { + $matches[] = $template->slug; + } + } + + // Deduplicate while preserving order — the scan may visit the + // same template twice if the block tree embeds the part more + // than once. + return array_values( array_unique( $matches ) ); + } + + /** + * Recursively checks whether a parsed block tree contains a + * `core/template-part` pointer matching the given slug + theme. + * + * An empty/missing `theme` attribute on the block is treated as a + * same-theme reference. The B2 fixtures always set `theme` on the + * block, but older exports may omit it; since + * {@see resolveReferencedBy()} only scans templates already filtered + * to the target theme, an untagged reference inside one of those + * templates can only resolve to a part in that same theme. + * + * @since 1.0.0 + * + * @param array> $blocks + */ + protected function blockTreeReferencesPart( array $blocks, string $slug, string $theme ): bool + { + foreach ( $blocks as $block ) { + if ( ! is_array( $block ) ) { + continue; + } + + $name = isset( $block['name'] ) && is_string( $block['name'] ) ? $block['name'] : ''; + + if ( 'core/template-part' === $name ) { + $attributes = isset( $block['attributes'] ) && is_array( $block['attributes'] ) ? $block['attributes'] : []; + $attributeSlug = isset( $attributes['slug'] ) && is_string( $attributes['slug'] ) ? $attributes['slug'] : ''; + $attributeTheme = isset( $attributes['theme'] ) && is_string( $attributes['theme'] ) ? $attributes['theme'] : ''; + + if ( $slug === $attributeSlug && ( '' === $attributeTheme || $theme === $attributeTheme ) ) { + return true; + } + } + + $innerBlocks = isset( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ? $block['innerBlocks'] : []; + + if ( [] !== $innerBlocks && $this->blockTreeReferencesPart( $innerBlocks, $slug, $theme ) ) { + return true; + } + } + + return false; + } +} diff --git a/src/Http/Requests/StoreTemplatePartRequest.php b/src/Http/Requests/StoreTemplatePartRequest.php new file mode 100644 index 000000000..becf0c1e1 --- /dev/null +++ b/src/Http/Requests/StoreTemplatePartRequest.php @@ -0,0 +1,92 @@ + + * + * @since 1.0.0 + */ + +declare( strict_types=1 ); + +namespace ArtisanPackUI\VisualEditor\Http\Requests; + +use ArtisanPackUI\VisualEditor\Models\VisualEditorTemplatePart; +use ArtisanPackUI\VisualEditor\Rules\TemplateBlockTreeRule; +use Closure; +use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; + +class StoreTemplatePartRequest extends FormRequest +{ + public function authorize(): bool + { + return true; + } + + /** + * @return array> + */ + public function rules(): array + { + return [ + 'slug' => [ + 'required', + 'string', + 'max:191', + Rule::unique( 'visual_editor_template_parts', 'slug' ) + ->where( fn ( $query ) => $query->where( 'theme', $this->input( 'theme' ) ) ), + ], + // `title` backs a non-nullable DB column (default ''). The store + // controller falls back to '' when the field is missing, but + // `null` is never a legal value — rejecting it here keeps the + // DB from rejecting the write later with an opaque + // QueryException. + 'title' => [ 'sometimes', 'string', 'max:255' ], + 'content' => [ 'nullable', 'array', $this->envelopeShapeRule() ], + 'content.raw' => [ 'nullable', 'string' ], + 'content.blocks' => [ 'nullable', 'array', new TemplateBlockTreeRule() ], + 'area' => [ + 'required', + 'string', + Rule::in( VisualEditorTemplatePart::AREAS ), + ], + 'theme' => [ 'required', 'string', 'max:191' ], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'area.in' => 'The area must be one of: ' . implode( ', ', VisualEditorTemplatePart::AREAS ) . '.', + ]; + } + + /** + * Rejects a bare-list `content` payload. + * + * `content.blocks` is only validated when the request uses the + * `{ raw, blocks }` envelope; without this rule a caller could send + * `content: [ ]` to skip `TemplateBlockTreeRule` entirely. + * + * @since 1.0.0 + */ + protected function envelopeShapeRule(): Closure + { + return function ( string $attribute, mixed $value, Closure $fail ): void { + if ( is_array( $value ) && [] !== $value && array_is_list( $value ) ) { + $fail( 'The :attribute must be a { raw, blocks } envelope, not a bare list of blocks.' ); + } + }; + } +} diff --git a/src/Http/Requests/UpdateTemplatePartRequest.php b/src/Http/Requests/UpdateTemplatePartRequest.php new file mode 100644 index 000000000..61d924e6d --- /dev/null +++ b/src/Http/Requests/UpdateTemplatePartRequest.php @@ -0,0 +1,115 @@ + + * + * @since 1.0.0 + */ + +declare( strict_types=1 ); + +namespace ArtisanPackUI\VisualEditor\Http\Requests; + +use ArtisanPackUI\VisualEditor\Models\VisualEditorTemplatePart; +use ArtisanPackUI\VisualEditor\Rules\TemplateBlockTreeRule; +use Closure; +use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; + +class UpdateTemplatePartRequest extends FormRequest +{ + public function authorize(): bool + { + return true; + } + + /** + * @return array> + */ + public function rules(): array + { + /** @var VisualEditorTemplatePart $templatePart */ + $templatePart = $this->route( 'templatePart' ); + + $resolvedSlug = $this->input( 'slug', $templatePart->slug ); + $resolvedTheme = $this->input( 'theme', $templatePart->theme ); + + // Composite-uniqueness is mirrored on both slug and theme so a + // PUT that changes *either* field triggers the check. Without + // the theme-side mirror, `{ "theme": "other" }` (no slug) would + // slip past the `sometimes`-gated slug rule and rely on the DB + // constraint to reject the collision as an opaque + // QueryException. + $uniqueness = Rule::unique( 'visual_editor_template_parts', 'slug' ) + ->ignore( $templatePart->getKey() ) + ->where( fn ( $query ) => $query->where( 'theme', $resolvedTheme ) ); + + $themeUniqueness = Rule::unique( 'visual_editor_template_parts', 'theme' ) + ->ignore( $templatePart->getKey() ) + ->where( fn ( $query ) => $query->where( 'slug', $resolvedSlug ) ); + + return [ + 'slug' => [ + 'sometimes', + 'required', + 'string', + 'max:191', + $uniqueness, + ], + // `title` backs a non-nullable DB column — null is never a + // legal value. The update controller leaves missing fields + // untouched, so dropping `nullable` here only tightens the + // error path without changing what a partial update can do. + 'title' => [ 'sometimes', 'string', 'max:255' ], + 'content' => [ 'sometimes', 'nullable', 'array', $this->envelopeShapeRule() ], + 'content.raw' => [ 'sometimes', 'nullable', 'string' ], + 'content.blocks' => [ 'sometimes', 'nullable', 'array', new TemplateBlockTreeRule() ], + 'area' => [ + 'sometimes', + 'required', + 'string', + Rule::in( VisualEditorTemplatePart::AREAS ), + ], + 'theme' => [ + 'sometimes', + 'required', + 'string', + 'max:191', + $themeUniqueness, + ], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'area.in' => 'The area must be one of: ' . implode( ', ', VisualEditorTemplatePart::AREAS ) . '.', + ]; + } + + /** + * Rejects a bare-list `content` payload — see the mirrored method + * on {@see StoreTemplatePartRequest::envelopeShapeRule()} for why. + * + * @since 1.0.0 + */ + protected function envelopeShapeRule(): Closure + { + return function ( string $attribute, mixed $value, Closure $fail ): void { + if ( is_array( $value ) && [] !== $value && array_is_list( $value ) ) { + $fail( 'The :attribute must be a { raw, blocks } envelope, not a bare list of blocks.' ); + } + }; + } +} diff --git a/src/Http/Resources/TemplatePartResource.php b/src/Http/Resources/TemplatePartResource.php new file mode 100644 index 000000000..70eb02d56 --- /dev/null +++ b/src/Http/Resources/TemplatePartResource.php @@ -0,0 +1,65 @@ + + * + * @since 1.0.0 + */ + +declare( strict_types=1 ); + +namespace ArtisanPackUI\VisualEditor\Http\Resources; + +use ArtisanPackUI\VisualEditor\Models\VisualEditorTemplatePart; +use Illuminate\Http\Request; +use Illuminate\Http\Resources\Json\JsonResource; + +/** + * @property VisualEditorTemplatePart $resource + */ +class TemplatePartResource extends JsonResource +{ + /** + * Transforms the template part into the shim-compatible record shape. + * + * @since 1.0.0 + * + * @return array + */ + public function toArray( Request $request ): array + { + /** @var VisualEditorTemplatePart $part */ + $part = $this->resource; + + $envelope = $part->getContentEnvelope(); + + return [ + 'id' => $part->getKey(), + 'slug' => $part->slug, + 'title' => [ + 'rendered' => (string) ( $part->title ?? '' ), + ], + 'content' => [ + 'raw' => $envelope['raw'], + 'blocks' => $envelope['blocks'], + ], + 'area' => $part->area, + 'theme' => $part->theme, + 'type' => 'wp_template_part', + ]; + } +} diff --git a/src/Models/VisualEditorTemplatePart.php b/src/Models/VisualEditorTemplatePart.php new file mode 100644 index 000000000..aec93d937 --- /dev/null +++ b/src/Models/VisualEditorTemplatePart.php @@ -0,0 +1,187 @@ + + * + * @since 1.0.0 + */ + +declare( strict_types=1 ); + +namespace ArtisanPackUI\VisualEditor\Models; + +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Model; + +class VisualEditorTemplatePart extends Model +{ + public const AREA_HEADER = 'header'; + public const AREA_FOOTER = 'footer'; + public const AREA_SIDEBAR = 'sidebar'; + public const AREA_UNCATEGORIZED = 'uncategorized'; + + /** + * @var array + */ + public const AREAS = [ + self::AREA_HEADER, + self::AREA_FOOTER, + self::AREA_SIDEBAR, + self::AREA_UNCATEGORIZED, + ]; + + protected $table = 'visual_editor_template_parts'; + + /** + * @var array + */ + protected $fillable = [ + 'slug', + 'title', + 'content', + 'area', + 'theme', + ]; + + /** + * @var array + */ + protected $casts = [ + 'content' => 'array', + ]; + + /** + * Returns the canonical content envelope stored on the record. + * + * Always returns the `{ raw, blocks }` shape — callers never have to + * null-check the raw column or the blocks key separately. + * + * @since 1.0.0 + * + * @return array{raw: string, blocks: array>} + */ + public function getContentEnvelope(): array + { + $value = $this->getAttribute( 'content' ); + + $raw = ''; + $blocks = []; + + if ( is_array( $value ) ) { + $raw = isset( $value['raw'] ) && is_string( $value['raw'] ) ? $value['raw'] : ''; + $blocks = isset( $value['blocks'] ) && is_array( $value['blocks'] ) ? array_values( $value['blocks'] ) : []; + } + + return [ + 'raw' => $raw, + 'blocks' => $blocks, + ]; + } + + /** + * Returns just the parsed block tree. + * + * @since 1.0.0 + * + * @return array> + */ + public function getBlocks(): array + { + return $this->getContentEnvelope()['blocks']; + } + + /** + * Returns just the raw Gutenberg serialization. + * + * @since 1.0.0 + */ + public function getRawContent(): string + { + return $this->getContentEnvelope()['raw']; + } + + /** + * Persists an updated content envelope on the record. + * + * @since 1.0.0 + * + * @param array{raw?: string, blocks?: array>} $envelope + */ + public function setContentEnvelope( array $envelope ): void + { + $raw = isset( $envelope['raw'] ) && is_string( $envelope['raw'] ) ? $envelope['raw'] : ''; + $blocks = isset( $envelope['blocks'] ) && is_array( $envelope['blocks'] ) ? array_values( $envelope['blocks'] ) : []; + + $this->setAttribute( 'content', [ + 'raw' => $raw, + 'blocks' => $blocks, + ] ); + } + + /** + * Scopes the query to template parts published for a theme. + * + * @since 1.0.0 + * + * @param Builder $query + * + * @return Builder + */ + public function scopeForTheme( Builder $query, string $theme ): Builder + { + return $query->where( 'theme', $theme ); + } + + /** + * Scopes the query to a specific area enum value. + * + * @since 1.0.0 + * + * @param Builder $query + * + * @return Builder + */ + public function scopeForArea( Builder $query, string $area ): Builder + { + return $query->where( 'area', $area ); + } + + /** + * Scopes the query to parts matching a slug (optionally constrained + * to a theme). + * + * @since 1.0.0 + * + * @param Builder $query + * + * @return Builder + */ + public function scopeForSlug( Builder $query, string $slug, ?string $theme = null ): Builder + { + $query->where( 'slug', $slug ); + + if ( null !== $theme && '' !== $theme ) { + $query->where( 'theme', $theme ); + } + + return $query; + } +} diff --git a/src/Policies/VisualEditorTemplatePartPolicy.php b/src/Policies/VisualEditorTemplatePartPolicy.php new file mode 100644 index 000000000..b2044e1e3 --- /dev/null +++ b/src/Policies/VisualEditorTemplatePartPolicy.php @@ -0,0 +1,60 @@ + + * + * @since 1.0.0 + */ + +declare( strict_types=1 ); + +namespace ArtisanPackUI\VisualEditor\Policies; + +use ArtisanPackUI\VisualEditor\Models\VisualEditorTemplatePart; +use Illuminate\Auth\Access\HandlesAuthorization; +use Illuminate\Contracts\Auth\Authenticatable; + +class VisualEditorTemplatePartPolicy +{ + use HandlesAuthorization; + + public function viewAny( ?Authenticatable $user ): bool + { + return null !== $user; + } + + public function view( ?Authenticatable $user, VisualEditorTemplatePart $templatePart ): bool + { + return null !== $user; + } + + public function create( ?Authenticatable $user ): bool + { + return null !== $user; + } + + public function update( ?Authenticatable $user, VisualEditorTemplatePart $templatePart ): bool + { + return null !== $user; + } + + public function delete( ?Authenticatable $user, VisualEditorTemplatePart $templatePart ): bool + { + return null !== $user; + } +} diff --git a/src/VisualEditorServiceProvider.php b/src/VisualEditorServiceProvider.php index 0fef7e21b..66fa52739 100644 --- a/src/VisualEditorServiceProvider.php +++ b/src/VisualEditorServiceProvider.php @@ -6,8 +6,10 @@ use ArtisanPackUI\VisualEditor\MediaBridge\GutenbergAttachmentAdapter; use ArtisanPackUI\VisualEditor\Models\VisualEditorPost; use ArtisanPackUI\VisualEditor\Models\VisualEditorTemplate; +use ArtisanPackUI\VisualEditor\Models\VisualEditorTemplatePart; use ArtisanPackUI\VisualEditor\Policies\VisualEditorPostPolicy; use ArtisanPackUI\VisualEditor\Policies\VisualEditorTemplatePolicy; +use ArtisanPackUI\VisualEditor\Policies\VisualEditorTemplatePartPolicy; use ArtisanPackUI\VisualEditor\Registries\BlockTypeRegistry; use ArtisanPackUI\VisualEditor\Registries\DynamicBlockRegistry; use ArtisanPackUI\VisualEditor\Resources\ResourceResolver; @@ -85,6 +87,7 @@ public function boot(): void Gate::policy( VisualEditorPost::class, VisualEditorPostPolicy::class ); Gate::policy( VisualEditorTemplate::class, VisualEditorTemplatePolicy::class ); + Gate::policy( VisualEditorTemplatePart::class, VisualEditorTemplatePartPolicy::class ); // 3. Register core blocks from their block.json manifests. $this->registerCoreBlocks(); diff --git a/tests/Feature/VisualEditor/TemplatePartControllerTest.php b/tests/Feature/VisualEditor/TemplatePartControllerTest.php new file mode 100644 index 000000000..56c3c5ed2 --- /dev/null +++ b/tests/Feature/VisualEditor/TemplatePartControllerTest.php @@ -0,0 +1,583 @@ + 'header', + 'title' => 'Header', + 'content' => [ + 'raw' => '', + 'blocks' => [ + [ + 'name' => 'core/site-title', + 'attributes' => new stdClass(), + 'innerBlocks' => [], + ], + ], + ], + 'area' => 'header', + 'theme' => 'artisanpack-base', + ], $overrides ) ); +} + +function createTemplateEmbeddingPart( string $templateSlug, string $partSlug, string $theme ): VisualEditorTemplate +{ + return VisualEditorTemplate::create( [ + 'slug' => $templateSlug, + 'title' => ucfirst( $templateSlug ), + 'content' => [ + 'raw' => sprintf( '', $partSlug, $theme ), + 'blocks' => [ + [ + 'name' => 'core/template-part', + 'attributes' => [ 'slug' => $partSlug, 'theme' => $theme ], + 'innerBlocks' => [], + ], + ], + ], + 'status' => 'publish', + 'theme' => $theme, + 'source' => 'theme', + 'origin' => 'theme', + ] ); +} + +function actingAsTemplatePartUser(): TestUser +{ + $user = TestUser::create( [ + 'name' => 'Template Part Tester', + 'email' => 'template-part+' . uniqid() . '@example.com', + 'password' => bcrypt( 'secret' ), + ] ); + + test()->actingAs( $user ); + + return $user; +} + +it( 'returns 401 when unauthenticated on index', function () { + $this->getJson( '/visual-editor/api/template-parts' )->assertUnauthorized(); +} ); + +it( 'returns an empty data/meta envelope when no parts exist', function () { + actingAsTemplatePartUser(); + + $this->getJson( '/visual-editor/api/template-parts' ) + ->assertOk() + ->assertJsonPath( 'data', [] ) + ->assertJsonPath( 'meta.total', 0 ) + ->assertJsonPath( 'meta.last_page', 1 ); +} ); + +it( 'lists template parts in the data/meta envelope', function () { + actingAsTemplatePartUser(); + + createTemplatePart( [ 'slug' => 'header', 'title' => 'Header', 'area' => 'header' ] ); + createTemplatePart( [ 'slug' => 'footer', 'title' => 'Footer', 'area' => 'footer' ] ); + + $this->getJson( '/visual-editor/api/template-parts' ) + ->assertOk() + ->assertJsonCount( 2, 'data' ) + ->assertJsonPath( 'meta.total', 2 ) + ->assertJsonPath( 'data.0.slug', 'header' ) + ->assertJsonPath( 'data.0.type', 'wp_template_part' ) + ->assertJsonPath( 'data.0.area', 'header' ) + ->assertJsonPath( 'data.0.title.rendered', 'Header' ) + ->assertJsonPath( 'data.1.slug', 'footer' ) + ->assertJsonPath( 'data.1.area', 'footer' ); +} ); + +it( 'filters the index by slug, theme, and area', function () { + actingAsTemplatePartUser(); + + createTemplatePart( [ 'slug' => 'header', 'theme' => 'theme-a', 'area' => 'header' ] ); + createTemplatePart( [ 'slug' => 'header', 'theme' => 'theme-b', 'area' => 'header' ] ); + createTemplatePart( [ 'slug' => 'footer', 'theme' => 'theme-a', 'area' => 'footer' ] ); + + $this->getJson( '/visual-editor/api/template-parts?theme=theme-a' ) + ->assertOk() + ->assertJsonCount( 2, 'data' ); + + $this->getJson( '/visual-editor/api/template-parts?slug=header&theme=theme-b' ) + ->assertOk() + ->assertJsonCount( 1, 'data' ) + ->assertJsonPath( 'data.0.theme', 'theme-b' ); + + $this->getJson( '/visual-editor/api/template-parts?area=footer' ) + ->assertOk() + ->assertJsonCount( 1, 'data' ) + ->assertJsonPath( 'data.0.slug', 'footer' ); +} ); + +it( 'returns 401 when unauthenticated on show', function () { + $part = createTemplatePart(); + + $this->getJson( "/visual-editor/api/template-parts/{$part->id}" )->assertUnauthorized(); +} ); + +it( 'returns the content envelope and rendered title on show', function () { + actingAsTemplatePartUser(); + + $part = createTemplatePart(); + + $this->getJson( "/visual-editor/api/template-parts/{$part->id}" ) + ->assertOk() + ->assertJsonPath( 'id', $part->id ) + ->assertJsonPath( 'slug', 'header' ) + ->assertJsonPath( 'type', 'wp_template_part' ) + ->assertJsonPath( 'area', 'header' ) + ->assertJsonPath( 'title.rendered', 'Header' ) + ->assertJsonPath( 'content.raw', '' ) + ->assertJsonPath( 'content.blocks.0.name', 'core/site-title' ) + ->assertJsonPath( 'referenced_by', [] ); +} ); + +it( 'returns 404 when the template part id does not exist', function () { + actingAsTemplatePartUser(); + + $this->getJson( '/visual-editor/api/template-parts/999' )->assertNotFound(); +} ); + +it( 'lists referencing template slugs in referenced_by', function () { + actingAsTemplatePartUser(); + + $part = createTemplatePart( [ 'slug' => 'header', 'theme' => 'artisanpack-base' ] ); + + createTemplateEmbeddingPart( 'single', 'header', 'artisanpack-base' ); + createTemplateEmbeddingPart( 'page', 'header', 'artisanpack-base' ); + + // Unrelated template — references a different part. + createTemplateEmbeddingPart( 'archive', 'footer', 'artisanpack-base' ); + + // Same slug, different theme — should not match. + createTemplateEmbeddingPart( 'index', 'header', 'theme-b' ); + + $this->getJson( "/visual-editor/api/template-parts/{$part->id}" ) + ->assertOk() + ->assertJsonPath( 'referenced_by', [ 'page', 'single' ] ); +} ); + +it( 'detects references nested inside inner blocks', function () { + actingAsTemplatePartUser(); + + $part = createTemplatePart( [ 'slug' => 'header', 'theme' => 'artisanpack-base' ] ); + + VisualEditorTemplate::create( [ + 'slug' => 'single', + 'title' => 'Single', + 'content' => [ + 'raw' => '', + 'blocks' => [ + [ + 'name' => 'core/group', + 'attributes' => [ 'tagName' => 'div' ], + 'innerBlocks' => [ + [ + 'name' => 'core/template-part', + 'attributes' => [ 'slug' => 'header', 'theme' => 'artisanpack-base' ], + 'innerBlocks' => [], + ], + ], + ], + ], + ], + 'status' => 'publish', + 'theme' => 'artisanpack-base', + 'source' => 'theme', + 'origin' => 'theme', + ] ); + + $this->getJson( "/visual-editor/api/template-parts/{$part->id}" ) + ->assertOk() + ->assertJsonPath( 'referenced_by', [ 'single' ] ); +} ); + +it( 'counts an untagged template-part block as a same-theme reference', function () { + actingAsTemplatePartUser(); + + $part = createTemplatePart( [ 'slug' => 'header', 'theme' => 'artisanpack-base' ] ); + + // Older export — `core/template-part` block references the slug but + // omits the `theme` attribute. Since the containing template is + // already in `artisanpack-base`, the reference resolves there. + VisualEditorTemplate::create( [ + 'slug' => 'single', + 'title' => 'Single', + 'content' => [ + 'raw' => '', + 'blocks' => [ + [ + 'name' => 'core/template-part', + 'attributes' => [ 'slug' => 'header' ], + 'innerBlocks' => [], + ], + ], + ], + 'status' => 'publish', + 'theme' => 'artisanpack-base', + 'source' => 'theme', + 'origin' => 'theme', + ] ); + + $this->getJson( "/visual-editor/api/template-parts/{$part->id}" ) + ->assertOk() + ->assertJsonPath( 'referenced_by', [ 'single' ] ); +} ); + +it( 'deduplicates referenced_by when a template embeds the part twice', function () { + actingAsTemplatePartUser(); + + $part = createTemplatePart( [ 'slug' => 'header', 'theme' => 'artisanpack-base' ] ); + + VisualEditorTemplate::create( [ + 'slug' => 'single', + 'title' => 'Single', + 'content' => [ + 'raw' => '', + 'blocks' => [ + [ + 'name' => 'core/template-part', + 'attributes' => [ 'slug' => 'header', 'theme' => 'artisanpack-base' ], + 'innerBlocks' => [], + ], + [ + 'name' => 'core/template-part', + 'attributes' => [ 'slug' => 'header', 'theme' => 'artisanpack-base' ], + 'innerBlocks' => [], + ], + ], + ], + 'status' => 'publish', + 'theme' => 'artisanpack-base', + 'source' => 'theme', + 'origin' => 'theme', + ] ); + + $this->getJson( "/visual-editor/api/template-parts/{$part->id}" ) + ->assertOk() + ->assertJsonPath( 'referenced_by', [ 'single' ] ); +} ); + +it( 'creates a template part with a 201 Created response for each valid area', function ( string $area ) { + actingAsTemplatePartUser(); + + $payload = [ + 'slug' => "my-{$area}", + 'title' => ucfirst( $area ), + 'content' => [ + 'raw' => '', + 'blocks' => [ + [ + 'name' => 'core/site-title', + 'attributes' => new stdClass(), + 'innerBlocks' => [], + ], + ], + ], + 'area' => $area, + 'theme' => 'artisanpack-base', + ]; + + $this->postJson( '/visual-editor/api/template-parts', $payload ) + ->assertCreated() + ->assertJsonPath( 'slug', "my-{$area}" ) + ->assertJsonPath( 'area', $area ) + ->assertJsonPath( 'content.raw', '' ) + ->assertJsonPath( 'content.blocks.0.name', 'core/site-title' ); + + expect( VisualEditorTemplatePart::where( 'slug', "my-{$area}" )->first() ) + ->not->toBeNull() + ->area->toBe( $area ); +} )->with( [ 'header', 'footer', 'sidebar', 'uncategorized' ] ); + +it( 'rejects store with an invalid area', function () { + actingAsTemplatePartUser(); + + $response = $this->postJson( '/visual-editor/api/template-parts', [ + 'slug' => 'header', + 'theme' => 'artisanpack-base', + 'area' => 'banner', + ] ) + ->assertUnprocessable() + ->assertJsonValidationErrors( 'area' ); + + expect( $response->json( 'errors.area.0' ) ) + ->toContain( 'header' ) + ->toContain( 'footer' ) + ->toContain( 'sidebar' ) + ->toContain( 'uncategorized' ); +} ); + +it( 'rejects store with a missing area', function () { + actingAsTemplatePartUser(); + + $this->postJson( '/visual-editor/api/template-parts', [ + 'slug' => 'header', + 'theme' => 'artisanpack-base', + ] ) + ->assertUnprocessable() + ->assertJsonValidationErrors( 'area' ); +} ); + +it( 'rejects store when the (slug, theme) pair already exists', function () { + actingAsTemplatePartUser(); + + createTemplatePart( [ 'slug' => 'header', 'theme' => 'artisanpack-base' ] ); + + $this->postJson( '/visual-editor/api/template-parts', [ + 'slug' => 'header', + 'theme' => 'artisanpack-base', + 'area' => 'header', + ] ) + ->assertUnprocessable() + ->assertJsonValidationErrors( 'slug' ); +} ); + +it( 'allows the same slug on different themes', function () { + actingAsTemplatePartUser(); + + createTemplatePart( [ 'slug' => 'header', 'theme' => 'theme-a' ] ); + + $this->postJson( '/visual-editor/api/template-parts', [ + 'slug' => 'header', + 'theme' => 'theme-b', + 'area' => 'header', + 'title' => 'Header on Theme B', + ] )->assertCreated(); +} ); + +it( 'rejects store when the block tree is malformed', function () { + actingAsTemplatePartUser(); + + $this->postJson( '/visual-editor/api/template-parts', [ + 'slug' => 'header', + 'theme' => 'artisanpack-base', + 'area' => 'header', + 'content' => [ + 'raw' => '', + 'blocks' => [ + [ 'not-a-block' => true ], + ], + ], + ] ) + ->assertUnprocessable() + ->assertJsonValidationErrors( 'content.blocks' ); +} ); + +it( 'updates a template part with a partial payload', function () { + actingAsTemplatePartUser(); + + $part = createTemplatePart( [ 'title' => 'Before' ] ); + + $this->putJson( "/visual-editor/api/template-parts/{$part->id}", [ + 'title' => 'After', + ] ) + ->assertOk() + ->assertJsonPath( 'title.rendered', 'After' ) + ->assertJsonPath( 'slug', 'header' ); + + expect( $part->fresh()->title )->toBe( 'After' ); +} ); + +it( 'rejects update with an invalid area', function () { + actingAsTemplatePartUser(); + + $part = createTemplatePart(); + + $this->putJson( "/visual-editor/api/template-parts/{$part->id}", [ + 'area' => 'banner', + ] ) + ->assertUnprocessable() + ->assertJsonValidationErrors( 'area' ); + + expect( $part->fresh()->area )->toBe( 'header' ); +} ); + +it( 'updates the area with a valid enum value', function () { + actingAsTemplatePartUser(); + + $part = createTemplatePart( [ 'area' => 'uncategorized' ] ); + + $this->putJson( "/visual-editor/api/template-parts/{$part->id}", [ + 'area' => 'footer', + ] ) + ->assertOk() + ->assertJsonPath( 'area', 'footer' ); + + expect( $part->fresh()->area )->toBe( 'footer' ); +} ); + +it( 'persists a new content envelope on update', function () { + actingAsTemplatePartUser(); + + $part = createTemplatePart(); + + $newContent = [ + 'raw' => '

Hi

', + 'blocks' => [ + [ + 'name' => 'core/heading', + 'attributes' => [ 'level' => 1, 'content' => 'Hi' ], + 'innerBlocks' => [], + ], + ], + ]; + + $this->putJson( "/visual-editor/api/template-parts/{$part->id}", [ + 'content' => $newContent, + ] ) + ->assertOk() + ->assertJsonPath( 'content.raw', $newContent['raw'] ) + ->assertJsonPath( 'content.blocks.0.attributes.content', 'Hi' ); + + expect( $part->fresh()->getContentEnvelope() )->toEqual( $newContent ); +} ); + +it( 'deletes a template part', function () { + actingAsTemplatePartUser(); + + $part = createTemplatePart(); + + $this->deleteJson( "/visual-editor/api/template-parts/{$part->id}" ) + ->assertNoContent(); + + expect( VisualEditorTemplatePart::find( $part->id ) )->toBeNull(); +} ); + +it( 'round-trips the B2 fixture template parts through store + show', function () { + actingAsTemplatePartUser(); + + $fixturesDir = dirname( __DIR__, 2 ) . '/Fixtures/sample-content/template-parts'; + $files = glob( $fixturesDir . '/*.json' ); + + expect( $files )->not->toBeEmpty(); + + foreach ( $files as $file ) { + $fixture = json_decode( (string) file_get_contents( $file ), true, flags: JSON_THROW_ON_ERROR ); + + $payload = [ + 'slug' => $fixture['slug'], + 'title' => $fixture['title']['rendered'] ?? '', + 'content' => $fixture['content'] ?? [ 'raw' => '', 'blocks' => [] ], + 'area' => $fixture['area'], + 'theme' => $fixture['theme'], + ]; + + $response = $this->postJson( '/visual-editor/api/template-parts', $payload ) + ->assertCreated() + ->assertJsonPath( 'slug', $fixture['slug'] ) + ->assertJsonPath( 'area', $fixture['area'] ) + ->assertJsonPath( 'content.raw', $fixture['content']['raw'] ); + + $id = $response->json( 'id' ); + + $this->getJson( "/visual-editor/api/template-parts/{$id}" ) + ->assertOk() + ->assertJsonPath( 'slug', $fixture['slug'] ) + ->assertJsonPath( 'content.blocks', $fixture['content']['blocks'] ); + } +} ); + +it( 'rejects a null title on store (column is non-nullable)', function () { + actingAsTemplatePartUser(); + + $this->postJson( '/visual-editor/api/template-parts', [ + 'slug' => 'header', + 'theme' => 'artisanpack-base', + 'area' => 'header', + 'title' => null, + ] ) + ->assertUnprocessable() + ->assertJsonValidationErrors( 'title' ); +} ); + +it( 'rejects a null title on update (column is non-nullable)', function () { + actingAsTemplatePartUser(); + + $part = createTemplatePart(); + + $this->putJson( "/visual-editor/api/template-parts/{$part->id}", [ + 'title' => null, + ] ) + ->assertUnprocessable() + ->assertJsonValidationErrors( 'title' ); + + expect( $part->fresh()->title )->toBe( 'Header' ); +} ); + +it( 'rejects a bare-list content payload on store', function () { + actingAsTemplatePartUser(); + + $this->postJson( '/visual-editor/api/template-parts', [ + 'slug' => 'header', + 'theme' => 'artisanpack-base', + 'area' => 'header', + 'content' => [ + [ + 'name' => 'core/paragraph', + 'attributes' => [], + 'innerBlocks' => [], + ], + ], + ] ) + ->assertUnprocessable() + ->assertJsonValidationErrors( 'content' ); +} ); + +it( 'rejects a bare-list content payload on update', function () { + actingAsTemplatePartUser(); + + $part = createTemplatePart(); + + $this->putJson( "/visual-editor/api/template-parts/{$part->id}", [ + 'content' => [ + [ + 'name' => 'core/paragraph', + 'attributes' => [], + 'innerBlocks' => [], + ], + ], + ] ) + ->assertUnprocessable() + ->assertJsonValidationErrors( 'content' ); +} ); + +it( 'rejects a theme-only update that would collide with an existing (slug, theme)', function () { + actingAsTemplatePartUser(); + + // Existing record we will try to update into a colliding (slug, theme). + $editing = createTemplatePart( [ 'slug' => 'header', 'theme' => 'theme-a' ] ); + + // The collision target. + createTemplatePart( [ 'slug' => 'header', 'theme' => 'theme-b' ] ); + + $this->putJson( "/visual-editor/api/template-parts/{$editing->id}", [ + 'theme' => 'theme-b', + ] ) + ->assertUnprocessable() + ->assertJsonValidationErrors( 'theme' ); + + expect( $editing->fresh()->theme )->toBe( 'theme-a' ); +} ); + +it( 'rejects unauthenticated store, update, and destroy', function () { + $part = createTemplatePart(); + + $this->postJson( '/visual-editor/api/template-parts', [ + 'slug' => 'new-header', + 'theme' => 'artisanpack-base', + 'area' => 'header', + ] )->assertUnauthorized(); + + $this->putJson( "/visual-editor/api/template-parts/{$part->id}", [ + 'title' => 'Nope', + ] )->assertUnauthorized(); + + $this->deleteJson( "/visual-editor/api/template-parts/{$part->id}" )->assertUnauthorized(); +} );