From 0cb600c5778e38d6eec46d7ae03f3119c8d8823e Mon Sep 17 00:00:00 2001 From: Jacob Martella Date: Sat, 30 May 2026 18:42:21 -0500 Subject: [PATCH 1/2] feat(blocks): preview live entity values in site-* and post-* edits The Phase I5 entity-cluster fork (#413) rendered the artisanpack/site-* and artisanpack/post-* blocks as crash-free but generic placeholder chips in the editor canvas because @wordpress/core-data was backed by the V1 shim that didn't expose the site or current-page entity. This adds two new fallback paths to createEntityPlaceholderEdit so those blocks preview real values in the canvas, while keeping the existing #483 query-loop path intact: - A singleton root/__unstableBase site-meta entity, served by a new GET /visual-editor/api/site/{id} endpoint backed by a new ArtisanPackUI\VisualEditor\Resources\SiteMetaResolver that reads from apGetSetting('site.*') with a config('artisanpack.visual-editor .site_meta') fallback. site-title / site-tagline / site-logo edits read the live record via useEntityRecord and preview its title / description / logoUrl. - A live post-entity read via useEntityRecord('postType', postType, postId), keyed off the postId/postType block context the editor stamps on the canvas (editor-app.tsx). post-title / post-excerpt / post-date / post-author / post-featured-image edits surface the active page's actual title / excerpt / formatted date / author name / featured image when placed outside a query loop. Priority order in the edit helper: stamped _resolved* attribute > artisanpack/postPreview query-loop context (#483) > live post entity (#481) > live site entity (#481) > placeholder label. Closes #481 --- .../entity-placeholder-live-entity.test.tsx | 356 ++++++++++++++++++ .../_shared/entity-placeholder-edit.tsx | 269 ++++++++++++- .../visual-editor/blocks/post-author/edit.tsx | 22 +- .../visual-editor/blocks/post-date/edit.tsx | 41 +- .../blocks/post-excerpt/edit.tsx | 27 +- .../blocks/post-featured-image/edit.tsx | 34 +- .../visual-editor/blocks/post-title/edit.tsx | 30 +- .../visual-editor/blocks/site-logo/edit.tsx | 42 ++- .../blocks/site-tagline/edit.tsx | 32 +- .../visual-editor/blocks/site-title/edit.tsx | 33 +- .../vendor/__tests__/core-data-shim.test.tsx | 5 + .../js/visual-editor/vendor/core-data-shim.ts | 28 ++ routes/api.php | 10 + src/Http/Controllers/SiteController.php | 86 +++++ src/Resources/SiteMetaResolver.php | 160 ++++++++ .../VisualEditor/SiteControllerTest.php | 87 +++++ 16 files changed, 1178 insertions(+), 84 deletions(-) create mode 100644 resources/js/visual-editor/blocks/_shared/__tests__/entity-placeholder-live-entity.test.tsx create mode 100644 src/Http/Controllers/SiteController.php create mode 100644 src/Resources/SiteMetaResolver.php create mode 100644 tests/Feature/VisualEditor/SiteControllerTest.php diff --git a/resources/js/visual-editor/blocks/_shared/__tests__/entity-placeholder-live-entity.test.tsx b/resources/js/visual-editor/blocks/_shared/__tests__/entity-placeholder-live-entity.test.tsx new file mode 100644 index 000000000..96a1a8be6 --- /dev/null +++ b/resources/js/visual-editor/blocks/_shared/__tests__/entity-placeholder-live-entity.test.tsx @@ -0,0 +1,356 @@ +/** + * Tests for the live-entity preview path added in #481 — when an + * `artisanpack/post-*` block is placed at the page level (outside + * a query loop) or an `artisanpack/site-*` block is dropped on the + * canvas, the editor reads the live page / site entity through the + * core-data shim's `useEntityRecord` and renders its real value + * instead of the generic placeholder. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { render } from '@testing-library/react'; + +vi.mock('@wordpress/block-editor', () => ({ + useBlockProps: () => ({ className: 'fallback-wrapper' }), +})); + +import { + SITE_ENTITY_ID, + __resetCoreDataShimConfig, + configureCoreDataShim, +} from '../../../vendor/core-data-shim'; +import { dispatch } from '@wordpress/data'; + +import PostTitleEdit from '../../post-title/edit'; +import PostExcerptEdit from '../../post-excerpt/edit'; +import PostDateEdit from '../../post-date/edit'; +import PostAuthorEdit from '../../post-author/edit'; +import PostFeaturedImageEdit from '../../post-featured-image/edit'; +import SiteTitleEdit from '../../site-title/edit'; +import SiteTaglineEdit from '../../site-tagline/edit'; +import SiteLogoEdit from '../../site-logo/edit'; + +const previewKey = 'artisanpack/postPreview'; + +type CoreDispatch = Record unknown>; +const coreDispatch = (): CoreDispatch => dispatch('core') as CoreDispatch; + +async function resetStore(): Promise { + // Drain in-flight resolvers from the previous test so their + // pending dispatches don't land mid-reset. Mirrors the cleanup + // pattern in `core-data-shim.test.tsx`. + configureCoreDataShim({ + apiBase: '/_drain_', + fetcher: async () => + new Response(null, { + status: 503, + headers: { 'Content-Type': 'application/json' }, + }), + }); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + coreDispatch().reset(); + coreDispatch().invalidateResolutionForStore(); +} + +beforeEach(async () => { + await resetStore(); + __resetCoreDataShimConfig(); + // Stub the fetcher so any speculative resolver dispatch fired + // by `useEntityRecord` during a render that we haven't pre- + // populated doesn't hit the network. Records the test cares + // about are seeded via `receiveEntityRecords` below. + configureCoreDataShim({ + apiBase: '/visual-editor/api', + fetcher: async () => + new Response(null, { + status: 404, + headers: { 'Content-Type': 'application/json' }, + }), + }); +}); + +afterEach(async () => { + await resetStore(); + __resetCoreDataShimConfig(); +}); + +function seedPostEntity( + postType: 'post' | 'page', + id: number, + record: Record, +): void { + coreDispatch().receiveEntityRecords('postType', postType, [ + { id, ...record }, + ]); +} + +function seedSiteEntity(record: Record): void { + coreDispatch().receiveEntityRecords('root', '__unstableBase', [ + { id: SITE_ENTITY_ID, ...record }, + ]); +} + +describe('post-* live entity preview (#481)', () => { + it('post-title reads the live page entity title when block context provides postId/postType', () => { + seedPostEntity('page', 42, { title: 'About us' }); + + const { getByText, queryByText } = render( + + ); + + expect(getByText('About us')).not.toBeNull(); + // The placeholder label must not appear when the entity resolves. + expect(queryByText('Post Title')).toBeNull(); + }); + + it('post-title accepts a `{ raw, rendered }` shaped title from the unflattened record', () => { + seedPostEntity('page', 42, { + title: { raw: 'Raw title', rendered: 'Rendered title' }, + }); + + const { getByText } = render( + + ); + + // `readEntityString` prefers the `raw` form. + expect(getByText('Raw title')).not.toBeNull(); + }); + + it('post-excerpt reads the live entity excerpt', () => { + seedPostEntity('post', 7, { excerpt: 'A brief blurb.' }); + + const { getByText } = render( + + ); + + expect(getByText('A brief blurb.')).not.toBeNull(); + }); + + it('post-date reads `_preview.dateFormatted` from the live entity', () => { + seedPostEntity('page', 42, { + _preview: { dateFormatted: 'June 3, 2026' }, + }); + + const { getByText } = render( + + ); + + expect(getByText('June 3, 2026')).not.toBeNull(); + }); + + it('post-date falls back to the ISO date portion when `_preview.dateFormatted` is absent', () => { + seedPostEntity('page', 42, { date: '2026-05-15T08:30:00+00:00' }); + + const { getByText } = render( + + ); + + expect(getByText('2026-05-15')).not.toBeNull(); + }); + + it('post-author reads `_preview.author.name` from the live entity', () => { + seedPostEntity('page', 42, { + _preview: { author: { name: 'Jane Doe' } }, + }); + + const { getByText } = render( + + ); + + expect(getByText('Jane Doe')).not.toBeNull(); + }); + + it('post-featured-image renders the image from `_preview.featuredImage` on the live entity', () => { + seedPostEntity('page', 42, { + _preview: { + featuredImage: { + url: 'https://cdn.example/hero.jpg', + alt: 'Hero', + }, + }, + }); + + const { container } = render( + + ); + + const img = container.querySelector('img'); + expect(img?.getAttribute('src')).toBe('https://cdn.example/hero.jpg'); + expect(img?.getAttribute('alt')).toBe('Hero'); + }); + + it('prefers the stamped `_resolved*` attribute over the live entity value', () => { + seedPostEntity('page', 42, { title: 'Live entity title' }); + + const { getByText, queryByText } = render( + + ); + + expect(getByText('Stamped title')).not.toBeNull(); + expect(queryByText('Live entity title')).toBeNull(); + }); + + it('prefers the query-preview context over the live entity value', () => { + seedPostEntity('page', 42, { title: 'Live entity title' }); + + const { getByText, queryByText } = render( + + ); + + expect(getByText('Query preview title')).not.toBeNull(); + expect(queryByText('Live entity title')).toBeNull(); + }); + + it('falls back to the placeholder when no entity record has resolved', () => { + // No `receiveEntityRecords` — the fetcher 404s and the record + // stays null. + const { getByText } = render( + + ); + + expect(getByText('Post Title')).not.toBeNull(); + }); + + it('falls back to the placeholder when `postId`/`postType` block context is absent', () => { + seedPostEntity('page', 42, { title: 'About us' }); + + const { getByText } = render( + + ); + + expect(getByText('Post Title')).not.toBeNull(); + }); + + it('rejects malformed `postId` values (non-numeric strings) instead of crashing', () => { + const { getByText } = render( + + ); + + expect(getByText('Post Title')).not.toBeNull(); + }); + + it('accepts a numeric-string `postId` from block context', () => { + seedPostEntity('page', 42, { title: 'About us' }); + + const { getByText } = render( + + ); + + expect(getByText('About us')).not.toBeNull(); + }); +}); + +describe('site-* live entity preview (#481)', () => { + it('site-title reads the live site title from the shim', () => { + seedSiteEntity({ title: 'ArtisanPack Studios' }); + + const { getByText, queryByText } = render( + + ); + + expect(getByText('ArtisanPack Studios')).not.toBeNull(); + expect(queryByText('Site Title')).toBeNull(); + }); + + it('site-title accepts the `{ raw, rendered }` site title shape', () => { + seedSiteEntity({ + title: { raw: 'Raw site title', rendered: 'Rendered site title' }, + }); + + const { getByText } = render(); + + expect(getByText('Raw site title')).not.toBeNull(); + }); + + it('site-tagline reads the live site description', () => { + seedSiteEntity({ description: 'Crafting beautiful interfaces.' }); + + const { getByText } = render(); + + expect(getByText('Crafting beautiful interfaces.')).not.toBeNull(); + }); + + it('site-logo renders the live `logoUrl` with the site title as alt text', () => { + seedSiteEntity({ + title: 'ArtisanPack', + logoUrl: 'https://cdn.example/logo.png', + }); + + const { container } = render(); + + const img = container.querySelector('img'); + expect(img?.getAttribute('src')).toBe('https://cdn.example/logo.png'); + expect(img?.getAttribute('alt')).toBe('ArtisanPack'); + }); + + it('site-logo falls back to the placeholder when `logoUrl` is empty', () => { + seedSiteEntity({ title: 'ArtisanPack', logoUrl: '' }); + + const { getByText } = render(); + + expect(getByText('Site Logo')).not.toBeNull(); + }); + + it('prefers the stamped `_resolvedSiteTitle` attribute over the live entity', () => { + seedSiteEntity({ title: 'Live site title' }); + + const { getByText, queryByText } = render( + + ); + + expect(getByText('Stamped site title')).not.toBeNull(); + expect(queryByText('Live site title')).toBeNull(); + }); + + it('falls back to the placeholder when the site entity has no title', () => { + seedSiteEntity({ title: '' }); + + const { getByText } = render(); + + expect(getByText('Site Title')).not.toBeNull(); + }); +}); diff --git a/resources/js/visual-editor/blocks/_shared/entity-placeholder-edit.tsx b/resources/js/visual-editor/blocks/_shared/entity-placeholder-edit.tsx index e6e8d96a2..e6f4aad0a 100644 --- a/resources/js/visual-editor/blocks/_shared/entity-placeholder-edit.tsx +++ b/resources/js/visual-editor/blocks/_shared/entity-placeholder-edit.tsx @@ -10,22 +10,37 @@ * (`post-author` unlocks block-editor private APIs and queries the user * entity). Delegating to them therefore gives a poor editing experience. * - * Instead each display fork uses this preview: it renders the block's - * resolved value when the attribute is present (so a block that already - * carries `_resolved*` data previews faithfully); otherwise, when the - * block is inside an `artisanpack/query` loop, it consumes the resolved - * first post the query block pipes through `BlockContextProvider` under - * the `artisanpack/postPreview` key (#483) and renders that post's - * data; otherwise a clean, clearly-labelled placeholder so the block - * looks intentional in the canvas. `navigation` and `template-part` - * keep delegating to their upstream edits (see `forked-entity-edit.tsx`) - * because they drive the V1 interactive editor surfaces that #413 must - * not regress. + * Instead each display fork uses this preview. Resolution priority, + * highest first: + * + * 1. The stamped `_resolved*` attribute (front-end / saved-tree path) — + * a block that already carries `_resolved*` data previews faithfully. + * 2. The `artisanpack/postPreview` block context the query block pipes + * down (#483) — when inside an `artisanpack/query` loop the editor + * previews the resolved first post's data. + * 3. The live page entity record fetched through the core-data shim + * (#481) — when a `post-*` block is placed at the page level + * (outside a loop), the preview reads the title / excerpt / + * featured image / author / date from the page being edited. + * 4. The live site-meta entity record fetched through the shim (#481) + * — site-title / site-tagline / site-logo preview the configured + * site values. + * 5. A clean, clearly-labelled placeholder so the block looks + * intentional in the canvas. + * + * `navigation` and `template-part` keep delegating to their upstream + * edits (see `forked-entity-edit.tsx`) because they drive the V1 + * interactive editor surfaces that #413 must not regress. */ import type { CSSProperties, ReactElement } from 'react'; import { useBlockProps } from '@wordpress/block-editor'; +import { + SITE_ENTITY_ID, + useEntityRecord, + type EntityRecord, +} from '../../vendor/core-data-shim'; import type { QueryPreviewPost } from '../../editor/use-query-preview'; export type EntityPreviewKind = 'text' | 'html' | 'image'; @@ -48,6 +63,63 @@ export interface EntityPreviewValue { readonly imageAlt?: string; } +/** + * Shape of the live post entity record returned by the core-data + * shim for `useEntityRecord('postType', postType, postId)`. Matches + * the WP REST envelope emitted by `WpEntityResource`. `_preview` + * carries the same author / date / featured-image data the + * server-side `PostResolver` stamps for the front-end render. + * + * `title` / `excerpt` come back in two shapes depending on the + * shim selector path: the unflattened `{ raw, rendered }` envelope + * from `useEntityRecord` (which our `fromPostEntity` extractors + * consume via `readEntityString` below), or the flattened raw + * string the shim's `getEditedEntityRecord` returns after + * `flattenRawProperties` collapses the envelope. Typing them as the + * union lets the same `PostEntityRecord` cover both reads without a + * separate "edited" record type. + */ +export interface PostEntityRecord extends EntityRecord { + readonly id?: number | string; + readonly title?: string | { raw?: string; rendered?: string }; + readonly excerpt?: string | { raw?: string; rendered?: string }; + readonly date?: string; + readonly featured_media?: number | null; + readonly _preview?: { + readonly dateFormatted?: string | null; + readonly author?: { + readonly name?: string; + readonly bio?: string; + readonly url?: string; + readonly avatarUrl?: string; + } | null; + readonly featuredImage?: { + readonly url: string; + readonly alt?: string; + readonly width?: number; + readonly height?: number; + } | null; + }; +} + +/** + * Shape of the live site-meta entity record returned by + * `useEntityRecord('root', '__unstableBase', SITE_ENTITY_ID)`. The + * controller backing the request emits `title` / `description` as + * `{ raw, rendered }` so the shim's `flattenRawProperties` collapses + * them to a string on the edited-record read path, but the unflattened + * `record` shape preserves the object form — the extractor below + * handles either. + */ +export interface SiteEntityRecord extends EntityRecord { + readonly id?: number | string; + readonly title?: string | { raw?: string; rendered?: string }; + readonly description?: string | { raw?: string; rendered?: string }; + readonly url?: string; + readonly logo?: number | null; + readonly logoUrl?: string; +} + export interface EntityPlaceholderConfig { /** Human-readable block label, e.g. "Post Title". */ readonly label: string; @@ -62,12 +134,32 @@ export interface EntityPlaceholderConfig { * `artisanpack/postPreview` block context the query block pipes * down (#483). When the block is inside an `artisanpack/query` * loop, the resolved first post is available here; otherwise the - * extractor is skipped and the placeholder renders. + * extractor is skipped and the next strategy is tried. */ readonly fromQueryPreview?: ( post: QueryPreviewPost ) => EntityPreviewValue | null; + /** + * Optional extractor that pulls the block's value from a live + * post entity record fetched through the core-data shim. Triggered + * when the block's `postId` / `postType` block context is present + * (the editor wraps the canvas in `BlockContextProvider` with the + * active page's id/type — see `editor-app.tsx`). Used by the + * `artisanpack/post-*` previews so a block placed at the page + * level (outside a query loop) reads the live page entity (#481). + */ + readonly fromPostEntity?: ( entity: PostEntityRecord ) => EntityPreviewValue | null; + /** + * Optional extractor that pulls the block's value from the live + * singleton site-meta entity (`root/__unstableBase`). Used by the + * `artisanpack/site-*` previews (#481) so the editor canvas + * shows the configured site title / tagline / logo instead of a + * generic placeholder chip. + */ + readonly fromSiteEntity?: ( site: SiteEntityRecord ) => EntityPreviewValue | null; } const PREVIEW_CONTEXT_KEY = 'artisanpack/postPreview'; +const POST_ID_CONTEXT_KEY = 'postId'; +const POST_TYPE_CONTEXT_KEY = 'postType'; const placeholderStyle: CSSProperties = { display: 'inline-flex', @@ -122,6 +214,104 @@ function readQueryPreviewPost( context: unknown ): QueryPreviewPost | null { return value as QueryPreviewPost; } +interface PostEntityIdentity { + readonly postType: string; + readonly postId: number; +} + +/** + * Reads the `postId` / `postType` pair from a block's `usesContext` + * payload. Returns null unless both are present and well-formed — + * post-* blocks placed outside any entity context (e.g. on a non- + * cms-framework resource) skip the entity-fetch path entirely. + */ +function readPostEntityIdentity( context: unknown ): PostEntityIdentity | null { + if ( context === null || typeof context !== 'object' ) { + return null; + } + + const record = context as Record; + const postType = record[ POST_TYPE_CONTEXT_KEY ]; + const rawPostId = record[ POST_ID_CONTEXT_KEY ]; + + if ( typeof postType !== 'string' || postType === '' ) { + return null; + } + + if ( typeof rawPostId === 'number' && Number.isInteger( rawPostId ) && rawPostId > 0 ) { + return { postType, postId: rawPostId }; + } + + if ( typeof rawPostId === 'string' && /^[1-9]\d*$/.test( rawPostId ) ) { + return { postType, postId: Number.parseInt( rawPostId, 10 ) }; + } + + return null; +} + +/** + * Subscribes the block's edit component to the live page entity + * record for its ambient `postType` / `postId` block context and + * extracts a preview value through the supplied `extractor`. Returns + * null when the context isn't populated, no extractor was supplied, + * the record hasn't resolved yet, or the extractor itself returned + * null. Always calls `useEntityRecord` (with a `null` id when the + * context is absent) so React's rules-of-hooks invariant holds across + * renders. + */ +function useLivePostEntityValue( + extractor: EntityPlaceholderConfig['fromPostEntity'] | null | undefined, + blockContext: unknown, +): EntityPreviewValue | null { + const identity = readPostEntityIdentity( blockContext ); + + const { record } = useEntityRecord( + identity !== null ? 'postType' : undefined, + identity !== null ? identity.postType : undefined, + identity !== null ? identity.postId : null, + ); + + if ( extractor === null || extractor === undefined ) { + return null; + } + + if ( identity === null || record === null ) { + return null; + } + + return extractor( record ); +} + +/** + * Subscribes the block's edit component to the singleton site-meta + * entity record and extracts a preview value through the supplied + * `extractor`. Returns null when the extractor wasn't supplied, the + * record hasn't resolved yet, or the extractor itself returned null. + * Always calls `useEntityRecord` (with a `null` id when no extractor + * was supplied) so the hook fires unconditionally. + */ +function useLiveSiteEntityValue( + extractor: EntityPlaceholderConfig['fromSiteEntity'] | null | undefined, +): EntityPreviewValue | null { + const enabled = extractor !== null && extractor !== undefined; + + const { record } = useEntityRecord( + enabled ? 'root' : undefined, + enabled ? '__unstableBase' : undefined, + enabled ? SITE_ENTITY_ID : null, + ); + + if ( ! enabled ) { + return null; + } + + if ( record === null ) { + return null; + } + + return extractor( record ); +} + /** * Build an `edit` component for a server-rendered entity display fork. */ @@ -133,9 +323,23 @@ export function createEntityPlaceholderEdit( const blockProps = ( useBlockProps as any )(); const attributes = props.attributes ?? {}; - // Priority order: stamped `_resolved*` attribute (front-end / - // saved-tree path) → `artisanpack/postPreview` block context - // (editor canvas inside a query loop, #483) → placeholder. + // Live entity hooks must fire unconditionally on every render + // — see React's rules-of-hooks. The hooks return null when + // their context isn't populated or no extractor was supplied, + // so the conditional chain below stays cheap on blocks that + // don't opt into the live-preview paths. + const livePostEntityValue = useLivePostEntityValue( + config.fromPostEntity, + props.context, + ); + const liveSiteEntityValue = useLiveSiteEntityValue( config.fromSiteEntity ); + + // Priority order: + // 1. stamped `_resolved*` attribute (front-end / saved-tree path) + // 2. `artisanpack/postPreview` block context (#483 query loop) + // 3. live post entity from `postId`/`postType` context (#481) + // 4. live singleton site-meta entity (#481) + // 5. placeholder label const resolvedValue: EntityPreviewValue = readResolvedAttributes( config, attributes ); if ( hasPreviewValue( config.kind, resolvedValue ) ) { @@ -154,6 +358,14 @@ export function createEntityPlaceholderEdit( } } + if ( livePostEntityValue !== null && hasPreviewValue( config.kind, livePostEntityValue ) ) { + return renderResolved( config.kind, livePostEntityValue, blockProps ); + } + + if ( liveSiteEntityValue !== null && hasPreviewValue( config.kind, liveSiteEntityValue ) ) { + return renderResolved( config.kind, liveSiteEntityValue, blockProps ); + } + return (
@@ -216,4 +428,31 @@ function renderResolved( return
{ value.text ?? '' }
; } +/** + * Extract a plain string from a `{ raw, rendered }` shaped field or + * a plain string. Used by `fromSiteEntity` / `fromPostEntity` + * extractors below to read `title` / `description` regardless of + * whether the consumer received the unflattened record (object shape) + * or the flattened edited record (string). + */ +export function readEntityString( value: unknown ): string { + if ( typeof value === 'string' ) { + return value; + } + + if ( value !== null && typeof value === 'object' ) { + const shape = value as { raw?: unknown; rendered?: unknown }; + + if ( typeof shape.raw === 'string' ) { + return shape.raw; + } + + if ( typeof shape.rendered === 'string' ) { + return shape.rendered; + } + } + + return ''; +} + export default createEntityPlaceholderEdit; diff --git a/resources/js/visual-editor/blocks/post-author/edit.tsx b/resources/js/visual-editor/blocks/post-author/edit.tsx index 1508be063..94f79b278 100644 --- a/resources/js/visual-editor/blocks/post-author/edit.tsx +++ b/resources/js/visual-editor/blocks/post-author/edit.tsx @@ -2,14 +2,16 @@ * Post Author — edit component. * * Server-rendered display block: the real markup is produced by the - * Blade / React / Vue renderers from stamped `_resolved*` attributes, and - * the editor's core-data shim does not expose the entity to the post - * editor. The fork therefore previews through the lightweight - * `createEntityPlaceholderEdit` helper (renders the resolved value when - * present; falls back to the resolved post in the query-loop block - * context — #483 — and finally to a clearly-labelled placeholder) rather - * than delegating to upstream's entity-querying edit. Phase I5 entity - * cluster (#413). + * Blade / React / Vue renderers from stamped `_resolved*` attributes. + * The fork previews through `createEntityPlaceholderEdit`: + * + * 1. Stamped `_resolvedAuthorName` attribute. + * 2. `artisanpack/postPreview` query-loop context (#483). + * 3. Live page entity author name from the core-data shim's + * `_preview.author.name` (#481). + * 4. Clearly-labelled placeholder. + * + * Phase I5 entity cluster (#413), live-preview path #481. */ import { createEntityPlaceholderEdit } from '../_shared/entity-placeholder-edit'; @@ -22,4 +24,8 @@ export default createEntityPlaceholderEdit( { const name = post.author?.name; return typeof name === 'string' && name !== '' ? { text: name } : null; }, + fromPostEntity: ( entity ) => { + const name = entity._preview?.author?.name; + return typeof name === 'string' && name !== '' ? { text: name } : null; + }, } ); diff --git a/resources/js/visual-editor/blocks/post-date/edit.tsx b/resources/js/visual-editor/blocks/post-date/edit.tsx index a3929d00c..4a5a3426b 100644 --- a/resources/js/visual-editor/blocks/post-date/edit.tsx +++ b/resources/js/visual-editor/blocks/post-date/edit.tsx @@ -2,14 +2,19 @@ * Post Date — edit component. * * Server-rendered display block: the real markup is produced by the - * Blade / React / Vue renderers from stamped `_resolved*` attributes, and - * the editor's core-data shim does not expose the entity to the post - * editor. The fork therefore previews through the lightweight - * `createEntityPlaceholderEdit` helper (renders the resolved value when - * present; falls back to the resolved post in the query-loop block - * context — #483 — and finally to a clearly-labelled placeholder) rather - * than delegating to upstream's entity-querying edit. Phase I5 entity - * cluster (#413). + * Blade / React / Vue renderers from stamped `_resolved*` attributes. + * The fork previews through `createEntityPlaceholderEdit`: + * + * 1. Stamped `_resolvedDateFormatted` attribute. + * 2. `artisanpack/postPreview` query-loop context (#483) — prefers + * the server-formatted `dateFormatted`, falls back to the ISO + * date portion of `publishedAt`. + * 3. Live page entity date from the core-data shim, using + * `_preview.dateFormatted` (mirrors the front-end format) and + * falling back to the ISO `date` portion (#481). + * 4. Clearly-labelled placeholder. + * + * Phase I5 entity cluster (#413), live-preview path #481. */ import { createEntityPlaceholderEdit } from '../_shared/entity-placeholder-edit'; @@ -31,6 +36,26 @@ export default createEntityPlaceholderEdit( { return datePart !== '' ? { text: datePart } : null; } + return null; + }, + fromPostEntity: ( entity ) => { + const preview = entity._preview; + const formatted = preview?.dateFormatted; + + if ( typeof formatted === 'string' && formatted !== '' ) { + return { text: formatted }; + } + + // Mirror the query-preview ISO fallback so a missing + // `_preview.dateFormatted` still surfaces the date instead of + // collapsing back to the placeholder. + const iso = entity.date; + + if ( typeof iso === 'string' && iso !== '' ) { + const datePart = iso.slice( 0, 10 ); + return datePart !== '' ? { text: datePart } : null; + } + return null; }, } ); diff --git a/resources/js/visual-editor/blocks/post-excerpt/edit.tsx b/resources/js/visual-editor/blocks/post-excerpt/edit.tsx index 584d79873..977f4c256 100644 --- a/resources/js/visual-editor/blocks/post-excerpt/edit.tsx +++ b/resources/js/visual-editor/blocks/post-excerpt/edit.tsx @@ -2,17 +2,22 @@ * Post Excerpt — edit component. * * Server-rendered display block: the real markup is produced by the - * Blade / React / Vue renderers from stamped `_resolved*` attributes, and - * the editor's core-data shim does not expose the entity to the post - * editor. The fork therefore previews through the lightweight - * `createEntityPlaceholderEdit` helper (renders the resolved value when - * present; falls back to the resolved post in the query-loop block - * context — #483 — and finally to a clearly-labelled placeholder) rather - * than delegating to upstream's entity-querying edit. Phase I5 entity - * cluster (#413). + * Blade / React / Vue renderers from stamped `_resolved*` attributes. + * The fork previews through `createEntityPlaceholderEdit`: + * + * 1. Stamped `_resolvedExcerpt` attribute. + * 2. `artisanpack/postPreview` query-loop context (#483). + * 3. Live page entity excerpt fetched through the core-data shim + * when `postId`/`postType` block context is present (#481). + * 4. Clearly-labelled placeholder. + * + * Phase I5 entity cluster (#413), live-preview path #481. */ -import { createEntityPlaceholderEdit } from '../_shared/entity-placeholder-edit'; +import { + createEntityPlaceholderEdit, + readEntityString, +} from '../_shared/entity-placeholder-edit'; export default createEntityPlaceholderEdit( { label: 'Post Excerpt', @@ -22,4 +27,8 @@ export default createEntityPlaceholderEdit( { typeof post.excerpt === 'string' && post.excerpt !== '' ? { text: post.excerpt } : null, + fromPostEntity: ( entity ) => { + const excerpt = readEntityString( entity.excerpt ); + return excerpt !== '' ? { text: excerpt } : null; + }, } ); diff --git a/resources/js/visual-editor/blocks/post-featured-image/edit.tsx b/resources/js/visual-editor/blocks/post-featured-image/edit.tsx index aeb4bfb3c..0e5443ab3 100644 --- a/resources/js/visual-editor/blocks/post-featured-image/edit.tsx +++ b/resources/js/visual-editor/blocks/post-featured-image/edit.tsx @@ -2,14 +2,16 @@ * Featured Image — edit component. * * Server-rendered display block: the real markup is produced by the - * Blade / React / Vue renderers from stamped `_resolved*` attributes, and - * the editor's core-data shim does not expose the entity to the post - * editor. The fork therefore previews through the lightweight - * `createEntityPlaceholderEdit` helper (renders the resolved value when - * present; falls back to the resolved post in the query-loop block - * context — #483 — and finally to a clearly-labelled placeholder) rather - * than delegating to upstream's entity-querying edit. Phase I5 entity - * cluster (#413). + * Blade / React / Vue renderers from stamped `_resolved*` attributes. + * The fork previews through `createEntityPlaceholderEdit`: + * + * 1. Stamped `_resolvedImageUrl` / `_resolvedImageAlt` attributes. + * 2. `artisanpack/postPreview` query-loop context (#483). + * 3. Live page entity featured-image from the core-data shim's + * `_preview.featuredImage` (#481). + * 4. Clearly-labelled placeholder. + * + * Phase I5 entity cluster (#413), live-preview path #481. */ import { createEntityPlaceholderEdit } from '../_shared/entity-placeholder-edit'; @@ -30,6 +32,22 @@ export default createEntityPlaceholderEdit( { return null; } + return { + imageUrl: image.url, + imageAlt: typeof image.alt === 'string' ? image.alt : '', + }; + }, + fromPostEntity: ( entity ) => { + const image = entity._preview?.featuredImage; + + if ( image === null || image === undefined ) { + return null; + } + + if ( typeof image.url !== 'string' || image.url === '' ) { + return null; + } + return { imageUrl: image.url, imageAlt: typeof image.alt === 'string' ? image.alt : '', diff --git a/resources/js/visual-editor/blocks/post-title/edit.tsx b/resources/js/visual-editor/blocks/post-title/edit.tsx index 937435b17..40770921e 100644 --- a/resources/js/visual-editor/blocks/post-title/edit.tsx +++ b/resources/js/visual-editor/blocks/post-title/edit.tsx @@ -2,17 +2,25 @@ * Post Title — edit component. * * Server-rendered display block: the real markup is produced by the - * Blade / React / Vue renderers from stamped `_resolved*` attributes, and - * the editor's core-data shim does not expose the entity to the post - * editor. The fork therefore previews through the lightweight - * `createEntityPlaceholderEdit` helper (renders the resolved value when - * present; falls back to the resolved post in the query-loop block - * context — #483 — and finally to a clearly-labelled placeholder) rather - * than delegating to upstream's entity-querying edit. Phase I5 entity - * cluster (#413). + * Blade / React / Vue renderers from stamped `_resolved*` attributes. + * The fork previews through `createEntityPlaceholderEdit`: + * + * 1. The stamped `_resolvedTitle` attribute (front-end / saved-tree + * path) wins when present. + * 2. Falls back to the resolved post in the `artisanpack/postPreview` + * block context — query-loop case (#483). + * 3. Falls back to the live page entity (`postType` / `postId` block + * context, fetched through the core-data shim) so a block placed + * at the page level previews the page's actual title (#481). + * 4. Falls back to a clearly-labelled placeholder. + * + * Phase I5 entity cluster (#413), live-preview path #481. */ -import { createEntityPlaceholderEdit } from '../_shared/entity-placeholder-edit'; +import { + createEntityPlaceholderEdit, + readEntityString, +} from '../_shared/entity-placeholder-edit'; export default createEntityPlaceholderEdit( { label: 'Post Title', @@ -22,4 +30,8 @@ export default createEntityPlaceholderEdit( { typeof post.title === 'string' && post.title !== '' ? { text: post.title } : null, + fromPostEntity: ( entity ) => { + const title = readEntityString( entity.title ); + return title !== '' ? { text: title } : null; + }, } ); diff --git a/resources/js/visual-editor/blocks/site-logo/edit.tsx b/resources/js/visual-editor/blocks/site-logo/edit.tsx index 03e184f80..360e3d79e 100644 --- a/resources/js/visual-editor/blocks/site-logo/edit.tsx +++ b/resources/js/visual-editor/blocks/site-logo/edit.tsx @@ -2,15 +2,39 @@ * Site Logo — edit component. * * Server-rendered display block: the real markup is produced by the - * Blade / React / Vue renderers from stamped `_resolved*` attributes, and - * the editor's core-data shim does not expose the entity to the post - * editor. The fork therefore previews through the lightweight - * `createEntityPlaceholderEdit` helper (renders the resolved value when - * present, otherwise a clearly-labelled placeholder) rather than - * delegating to upstream's entity-querying edit. Phase I5 entity cluster - * (#413). + * Blade / React / Vue renderers from stamped `_resolved*` attributes. + * The fork previews through `createEntityPlaceholderEdit`: + * + * 1. The stamped `_resolvedLogoUrl` attribute (paired with + * `_resolvedSiteTitle` for alt text) wins when present. + * 2. Otherwise the editor reads the live singleton site-meta entity + * (`root/__unstableBase` — see #481), using its `logoUrl` and + * `title` fields so the canvas previews the configured logo. + * 3. Falls back to the placeholder label. + * + * Phase I5 entity cluster (#413), live-preview path #481. */ -import { createEntityPlaceholderEdit } from '../_shared/entity-placeholder-edit'; +import { + createEntityPlaceholderEdit, + readEntityString, +} from '../_shared/entity-placeholder-edit'; + +export default createEntityPlaceholderEdit( { + label: 'Site Logo', + resolvedKey: '_resolvedLogoUrl', + kind: 'image', + altKey: '_resolvedSiteTitle', + fromSiteEntity: ( site ) => { + const url = typeof site.logoUrl === 'string' ? site.logoUrl : ''; + + if ( url === '' ) { + return null; + } -export default createEntityPlaceholderEdit( { label: 'Site Logo', resolvedKey: '_resolvedLogoUrl', kind: 'image', altKey: '_resolvedSiteTitle' } ); + return { + imageUrl: url, + imageAlt: readEntityString( site.title ), + }; + }, +} ); diff --git a/resources/js/visual-editor/blocks/site-tagline/edit.tsx b/resources/js/visual-editor/blocks/site-tagline/edit.tsx index a40a70314..adc4465a1 100644 --- a/resources/js/visual-editor/blocks/site-tagline/edit.tsx +++ b/resources/js/visual-editor/blocks/site-tagline/edit.tsx @@ -2,15 +2,29 @@ * Site Tagline — edit component. * * Server-rendered display block: the real markup is produced by the - * Blade / React / Vue renderers from stamped `_resolved*` attributes, and - * the editor's core-data shim does not expose the entity to the post - * editor. The fork therefore previews through the lightweight - * `createEntityPlaceholderEdit` helper (renders the resolved value when - * present, otherwise a clearly-labelled placeholder) rather than - * delegating to upstream's entity-querying edit. Phase I5 entity cluster - * (#413). + * Blade / React / Vue renderers from stamped `_resolved*` attributes. + * The fork previews through `createEntityPlaceholderEdit`: + * + * 1. The stamped `_resolvedSiteTagline` attribute wins when present. + * 2. Otherwise the editor reads the live singleton site-meta entity + * (`root/__unstableBase` — see #481) so the canvas previews the + * configured tagline. + * 3. Falls back to the placeholder label. + * + * Phase I5 entity cluster (#413), live-preview path #481. */ -import { createEntityPlaceholderEdit } from '../_shared/entity-placeholder-edit'; +import { + createEntityPlaceholderEdit, + readEntityString, +} from '../_shared/entity-placeholder-edit'; -export default createEntityPlaceholderEdit( { label: 'Site Tagline', resolvedKey: '_resolvedSiteTagline', kind: 'text' } ); +export default createEntityPlaceholderEdit( { + label: 'Site Tagline', + resolvedKey: '_resolvedSiteTagline', + kind: 'text', + fromSiteEntity: ( site ) => { + const description = readEntityString( site.description ); + return description !== '' ? { text: description } : null; + }, +} ); diff --git a/resources/js/visual-editor/blocks/site-title/edit.tsx b/resources/js/visual-editor/blocks/site-title/edit.tsx index 76c4d9dda..73e4f9a51 100644 --- a/resources/js/visual-editor/blocks/site-title/edit.tsx +++ b/resources/js/visual-editor/blocks/site-title/edit.tsx @@ -2,15 +2,30 @@ * Site Title — edit component. * * Server-rendered display block: the real markup is produced by the - * Blade / React / Vue renderers from stamped `_resolved*` attributes, and - * the editor's core-data shim does not expose the entity to the post - * editor. The fork therefore previews through the lightweight - * `createEntityPlaceholderEdit` helper (renders the resolved value when - * present, otherwise a clearly-labelled placeholder) rather than - * delegating to upstream's entity-querying edit. Phase I5 entity cluster - * (#413). + * Blade / React / Vue renderers from stamped `_resolved*` attributes. + * The fork previews through `createEntityPlaceholderEdit`: + * + * 1. The stamped `_resolvedSiteTitle` attribute (front-end / saved + * tree path) wins when present. + * 2. Otherwise the editor reads the live singleton site-meta entity + * (`root/__unstableBase` — see #481) through the core-data shim + * so the canvas previews the configured site title. + * 3. Falls back to the placeholder label when neither resolves. + * + * Phase I5 entity cluster (#413), live-preview path #481. */ -import { createEntityPlaceholderEdit } from '../_shared/entity-placeholder-edit'; +import { + createEntityPlaceholderEdit, + readEntityString, +} from '../_shared/entity-placeholder-edit'; -export default createEntityPlaceholderEdit( { label: 'Site Title', resolvedKey: '_resolvedSiteTitle', kind: 'text' } ); +export default createEntityPlaceholderEdit( { + label: 'Site Title', + resolvedKey: '_resolvedSiteTitle', + kind: 'text', + fromSiteEntity: ( site ) => { + const title = readEntityString( site.title ); + return title !== '' ? { text: title } : null; + }, +} ); diff --git a/resources/js/visual-editor/vendor/__tests__/core-data-shim.test.tsx b/resources/js/visual-editor/vendor/__tests__/core-data-shim.test.tsx index e5caade5e..dd644eb4c 100644 --- a/resources/js/visual-editor/vendor/__tests__/core-data-shim.test.tsx +++ b/resources/js/visual-editor/vendor/__tests__/core-data-shim.test.tsx @@ -118,6 +118,11 @@ describe('core-data-shim entity registry', () => { // `apGetMedia()` and falls back to 404 when no media // library is installed. 'postType|attachment', + // #481 — singleton site-meta entity consumed by the + // editor's `artisanpack/site-*` block previews. Backed + // by `/visual-editor/api/site/{id}` which is a singleton + // (the id is ignored). + 'root|__unstableBase', ]); }); diff --git a/resources/js/visual-editor/vendor/core-data-shim.ts b/resources/js/visual-editor/vendor/core-data-shim.ts index e3d612a76..e6c013584 100644 --- a/resources/js/visual-editor/vendor/core-data-shim.ts +++ b/resources/js/visual-editor/vendor/core-data-shim.ts @@ -270,8 +270,36 @@ export const DEFAULT_ENTITIES: readonly EntityConfig[] = Object.freeze([ label: 'Attachment', plural: 'attachments', }, + // #481 — singleton site-meta entity consumed by the editor's + // `artisanpack/site-*` block previews (`createEntityPlaceholderEdit` + // with `fromSiteEntity`). The endpoint is a singleton but the + // shim's URL builder always appends an id; consumers pass the + // sentinel `'self'` id, which the controller ignores. The record + // shape mirrors a lightweight WP REST `wp/v2/` site envelope so + // `flattenRawProperties()` collapses `title.raw` / `description.raw` + // through the same path post entities take. + { + kind: 'root', + name: '__unstableBase', + baseURL: '/site', + key: 'id', + label: 'Site', + plural: 'site', + }, ]); +/** + * Sentinel id used by editor consumers (`createEntityPlaceholderEdit`) + * to read the singleton site-meta entity from the shim via + * `useEntityRecord('root', '__unstableBase', SITE_ENTITY_ID)`. The + * controller backing `/visual-editor/api/site/{id}` ignores the id + * and always returns the same record, but the shim's URL builder + * still needs *some* id to fetch. + * + * @see issue #481 + */ +export const SITE_ENTITY_ID = 'self' as const; + // --------------------------------------------------------------------------- // Shim configuration // --------------------------------------------------------------------------- diff --git a/routes/api.php b/routes/api.php index 87a901eae..5f094d0e5 100644 --- a/routes/api.php +++ b/routes/api.php @@ -24,6 +24,7 @@ use ArtisanPackUI\VisualEditor\Http\Controllers\EntitySearchController; use ArtisanPackUI\VisualEditor\Http\Controllers\MenuLocationsController; use ArtisanPackUI\VisualEditor\Http\Controllers\QueryResolveController; +use ArtisanPackUI\VisualEditor\Http\Controllers\SiteController; use ArtisanPackUI\VisualEditor\Http\Controllers\SiteEditor\GlobalStylesController; use ArtisanPackUI\VisualEditor\Http\Controllers\SiteEditor\MenuController; use ArtisanPackUI\VisualEditor\Http\Controllers\SiteEditor\MenuItemController; @@ -243,3 +244,12 @@ Route::get( 'attachments/{id}', [ AttachmentController::class, 'show' ] ) ->where( 'id', '[0-9]+' ) ->name( 'visual-editor.api.attachments.show' ); + +// #481 — singleton site-meta envelope consumed by the editor's +// `artisanpack/site-*` block previews. The shim's URL builder always +// appends an id segment to a single-record fetch, so the route accepts +// any short alphanumeric token and treats it as a sentinel — the +// controller always returns the same record. +Route::get( 'site/{id}', [ SiteController::class, 'show' ] ) + ->where( 'id', '[A-Za-z0-9_-]+' ) + ->name( 'visual-editor.api.site.show' ); diff --git a/src/Http/Controllers/SiteController.php b/src/Http/Controllers/SiteController.php new file mode 100644 index 000000000..5443cad72 --- /dev/null +++ b/src/Http/Controllers/SiteController.php @@ -0,0 +1,86 @@ + + * + * @since 1.0.0 + */ + +declare( strict_types=1 ); + +namespace ArtisanPackUI\VisualEditor\Http\Controllers; + +use ArtisanPackUI\VisualEditor\Resources\SiteMetaResolver; +use Illuminate\Http\JsonResponse; +use Illuminate\Routing\Controller; + +class SiteController extends Controller +{ + public function __construct( protected SiteMetaResolver $resolver ) + { + } + + /** + * Returns the singleton site-meta record. + * + * @since 1.0.0 + */ + public function show( int|string $id ): JsonResponse + { + $meta = $this->resolver->resolve(); + + return response()->json( $this->shape( $id, $meta ) ); + } + + /** + * Shape the resolver output into the WP-shape site record the + * editor's core-data shim consumes. + * + * `title` / `description` use the `{ raw, rendered }` shape so + * the shim's `flattenRawProperties()` selector path (which other + * post-type entities round-trip through) treats them the same way + * as a `wp_template` / `wp_block` title. `logo` is a media id or + * null, `logoUrl` is a flat URL string the site-logo preview can + * consume without a follow-up `attachment` fetch. + * + * @since 1.0.0 + * + * @param array{title: string, description: string, url: string, logoId: ?int, iconId: ?int, logoUrl: string} $meta + * + * @return array + */ + protected function shape( int|string $id, array $meta ): array + { + return [ + 'id' => $id, + 'title' => [ 'raw' => $meta['title'], 'rendered' => $meta['title'] ], + 'description' => [ 'raw' => $meta['description'], 'rendered' => $meta['description'] ], + 'url' => $meta['url'], + 'logo' => $meta['logoId'], + 'icon' => $meta['iconId'], + 'logoUrl' => $meta['logoUrl'], + ]; + } +} diff --git a/src/Resources/SiteMetaResolver.php b/src/Resources/SiteMetaResolver.php new file mode 100644 index 000000000..264b21653 --- /dev/null +++ b/src/Resources/SiteMetaResolver.php @@ -0,0 +1,160 @@ + + * + * @since 1.0.0 + */ + +declare( strict_types=1 ); + +namespace ArtisanPackUI\VisualEditor\Resources; + +class SiteMetaResolver +{ + /** + * Cached resolved meta for the current request. + * + * @var array{title: string, description: string, url: string, logoId: ?int, iconId: ?int, logoUrl: string}|null + */ + protected ?array $cache = null; + + /** + * Returns the resolved site-meta envelope. + * + * @since 1.0.0 + * + * @return array{title: string, description: string, url: string, logoId: ?int, iconId: ?int, logoUrl: string} + */ + public function resolve(): array + { + if ( null !== $this->cache ) { + return $this->cache; + } + + $title = $this->lookup( 'site.title', 'title' ); + $description = $this->lookup( 'site.tagline', 'description' ); + $url = $this->lookup( 'site.url', 'url' ); + $logoId = $this->lookupId( 'site.logo_id', 'logo_id' ); + $iconId = $this->lookupId( 'site.icon_id', 'icon_id' ); + $logoUrl = $this->resolveMediaUrl( $logoId ); + + $this->cache = [ + 'title' => $this->coerceString( $title ), + 'description' => $this->coerceString( $description ), + 'url' => $this->coerceString( $url ), + 'logoId' => $logoId, + 'iconId' => $iconId, + 'logoUrl' => $this->coerceString( $logoUrl ), + ]; + + return $this->cache; + } + + /** + * Clears the resolver's cache. Useful in long-running workers where a + * `site.*` setting was changed mid-process. + * + * @since 1.0.0 + */ + public function flush(): void + { + $this->cache = null; + } + + /** + * Reads a string-shaped setting, preferring `apGetSetting()` when present. + * + * @since 1.0.0 + */ + protected function lookup( string $settingKey, string $configKey ): mixed + { + if ( function_exists( 'apGetSetting' ) ) { + $value = apGetSetting( $settingKey ); + + if ( null !== $value && '' !== $value ) { + return $value; + } + } + + return config( 'artisanpack.visual-editor.site_meta.' . $configKey ); + } + + /** + * Reads an integer-shaped setting (logo / icon ids). + * + * @since 1.0.0 + */ + protected function lookupId( string $settingKey, string $configKey ): ?int + { + $value = $this->lookup( $settingKey, $configKey ); + + if ( is_int( $value ) ) { + return $value; + } + + if ( is_string( $value ) && '' !== $value && ctype_digit( $value ) ) { + return (int) $value; + } + + return null; + } + + /** + * Resolves a media id to a public URL via `apGetMediaUrl()`. Returns + * the empty string when the helper is unavailable or the id is missing. + * + * @since 1.0.0 + */ + protected function resolveMediaUrl( ?int $id ): string + { + if ( null === $id || ! function_exists( 'apGetMediaUrl' ) ) { + return ''; + } + + $url = apGetMediaUrl( $id ); + + return is_string( $url ) ? $url : ''; + } + + /** + * Coerce arbitrary scalar values to a string, preserving the empty + * string for null / non-scalar input so the consumer's `is_string` + * guard treats unresolved values as "no value". + * + * @since 1.0.0 + */ + protected function coerceString( mixed $value ): string + { + if ( is_string( $value ) ) { + return $value; + } + + if ( is_scalar( $value ) ) { + return (string) $value; + } + + return ''; + } +} diff --git a/tests/Feature/VisualEditor/SiteControllerTest.php b/tests/Feature/VisualEditor/SiteControllerTest.php new file mode 100644 index 000000000..61df73e83 --- /dev/null +++ b/tests/Feature/VisualEditor/SiteControllerTest.php @@ -0,0 +1,87 @@ +set( 'artisanpack.visual-editor.api.middleware', [ 'auth' ] ); + + $this->actor = TestUser::create( [ + 'name' => 'Site Tester', + 'email' => 'site+' . uniqid() . '@example.com', + 'password' => bcrypt( 'secret' ), + ] ); + + $this->actingAs( $this->actor ); +} ); + +it( 'returns the configured site-meta envelope', function () { + config()->set( 'artisanpack.visual-editor.site_meta', [ + 'title' => 'ArtisanPack Studios', + 'description' => 'Crafting beautiful interfaces.', + 'url' => 'https://artisanpack.test', + 'logo_id' => null, + 'icon_id' => null, + ] ); + + $this->getJson( '/visual-editor/api/site/self' ) + ->assertOk() + ->assertJsonPath( 'id', 'self' ) + ->assertJsonPath( 'title.raw', 'ArtisanPack Studios' ) + ->assertJsonPath( 'title.rendered', 'ArtisanPack Studios' ) + ->assertJsonPath( 'description.raw', 'Crafting beautiful interfaces.' ) + ->assertJsonPath( 'description.rendered', 'Crafting beautiful interfaces.' ) + ->assertJsonPath( 'url', 'https://artisanpack.test' ) + ->assertJsonPath( 'logo', null ) + ->assertJsonPath( 'icon', null ) + ->assertJsonPath( 'logoUrl', '' ); +} ); + +it( 'returns empty strings when no source has populated the meta', function () { + config()->set( 'artisanpack.visual-editor.site_meta', [ + 'title' => null, + 'description' => null, + 'url' => null, + 'logo_id' => null, + 'icon_id' => null, + ] ); + + $this->getJson( '/visual-editor/api/site/self' ) + ->assertOk() + ->assertJsonPath( 'title.raw', '' ) + ->assertJsonPath( 'description.raw', '' ) + ->assertJsonPath( 'url', '' ) + ->assertJsonPath( 'logo', null ) + ->assertJsonPath( 'logoUrl', '' ); +} ); + +it( 'ignores the id segment and always returns the singleton record', function () { + config()->set( 'artisanpack.visual-editor.site_meta', [ + 'title' => 'Sentinel Test', + 'description' => null, + 'url' => null, + 'logo_id' => null, + 'icon_id' => null, + ] ); + + $this->getJson( '/visual-editor/api/site/anything-goes' ) + ->assertOk() + ->assertJsonPath( 'id', 'anything-goes' ) + ->assertJsonPath( 'title.raw', 'Sentinel Test' ); +} ); + +it( 'rejects unauthenticated requests when the api middleware requires auth', function () { + auth()->logout(); + + $this->getJson( '/visual-editor/api/site/self' )->assertUnauthorized(); +} ); From 36b1014f13415f28ac146742dfcf15e9464a44a5 Mon Sep 17 00:00:00 2001 From: Jacob Martella Date: Sat, 30 May 2026 18:52:37 -0500 Subject: [PATCH 2/2] chore: address CodeRabbit review on #481 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Wrap apGetMediaUrl() in try/catch in SiteMetaResolver so a host media-library helper that throws doesn't crash the editor's site-meta fetch. Mirrors the defensive pattern in WpEntityResource::featuredImageEnvelope(). - Tighten SiteController::show signature from int|string to string — the {id} route is constrained to [A-Za-z0-9_-]+, so Laravel never hands the controller a numeric value. - Reverse the spread order in the test seeders so an explicit `id` argument always wins over any `id` a future test might pass inside the record payload. --- .../entity-placeholder-live-entity.test.tsx | 9 +++++++-- src/Http/Controllers/SiteController.php | 8 ++++++-- src/Resources/SiteMetaResolver.php | 14 ++++++++++++-- 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/resources/js/visual-editor/blocks/_shared/__tests__/entity-placeholder-live-entity.test.tsx b/resources/js/visual-editor/blocks/_shared/__tests__/entity-placeholder-live-entity.test.tsx index 96a1a8be6..fd8d166f1 100644 --- a/resources/js/visual-editor/blocks/_shared/__tests__/entity-placeholder-live-entity.test.tsx +++ b/resources/js/visual-editor/blocks/_shared/__tests__/entity-placeholder-live-entity.test.tsx @@ -81,14 +81,19 @@ function seedPostEntity( id: number, record: Record, ): void { + // Merge the explicit `id` argument last so a future test that + // happens to pass `id` inside `record` cannot silently override + // the seed's primary-key slot — `receiveEntityRecords` keys by + // the entity's `key` field (`'id'`) and a mismatched record + // would be cached under the wrong slot. coreDispatch().receiveEntityRecords('postType', postType, [ - { id, ...record }, + { ...record, id }, ]); } function seedSiteEntity(record: Record): void { coreDispatch().receiveEntityRecords('root', '__unstableBase', [ - { id: SITE_ENTITY_ID, ...record }, + { ...record, id: SITE_ENTITY_ID }, ]); } diff --git a/src/Http/Controllers/SiteController.php b/src/Http/Controllers/SiteController.php index 5443cad72..8406db1f5 100644 --- a/src/Http/Controllers/SiteController.php +++ b/src/Http/Controllers/SiteController.php @@ -45,9 +45,13 @@ public function __construct( protected SiteMetaResolver $resolver ) /** * Returns the singleton site-meta record. * + * The `{id}` route segment is constrained to `[A-Za-z0-9_-]+`, so + * Laravel always hands the controller a string — the parameter is + * a sentinel echoed back on the record, not a primary key. + * * @since 1.0.0 */ - public function show( int|string $id ): JsonResponse + public function show( string $id ): JsonResponse { $meta = $this->resolver->resolve(); @@ -71,7 +75,7 @@ public function show( int|string $id ): JsonResponse * * @return array */ - protected function shape( int|string $id, array $meta ): array + protected function shape( string $id, array $meta ): array { return [ 'id' => $id, diff --git a/src/Resources/SiteMetaResolver.php b/src/Resources/SiteMetaResolver.php index 264b21653..f1631a8a2 100644 --- a/src/Resources/SiteMetaResolver.php +++ b/src/Resources/SiteMetaResolver.php @@ -31,6 +31,8 @@ namespace ArtisanPackUI\VisualEditor\Resources; +use Throwable; + class SiteMetaResolver { /** @@ -123,7 +125,11 @@ protected function lookupId( string $settingKey, string $configKey ): ?int /** * Resolves a media id to a public URL via `apGetMediaUrl()`. Returns - * the empty string when the helper is unavailable or the id is missing. + * the empty string when the helper is unavailable, the id is missing, + * or the helper throws (a host's media-library implementation that + * fails — e.g. DB error, invalid id — must not crash the editor's + * site-meta fetch). Mirrors the defensive pattern in + * {@see \ArtisanPackUI\VisualEditor\Http\Resources\Adapters\CmsFramework\WpEntityResource::featuredImageEnvelope()}. * * @since 1.0.0 */ @@ -133,7 +139,11 @@ protected function resolveMediaUrl( ?int $id ): string return ''; } - $url = apGetMediaUrl( $id ); + try { + $url = apGetMediaUrl( $id ); + } catch ( Throwable ) { + return ''; + } return is_string( $url ) ? $url : ''; }