From 0ad41d83a3c8a7593647d35c9b9c494bea95b37c Mon Sep 17 00:00:00 2001 From: Jacob Martella Date: Fri, 12 Jun 2026 17:45:25 -0500 Subject: [PATCH 1/2] feat: add BreadcrumbsResolver for artisanpack/breadcrumbs (#565) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stamps `_resolvedTrail` on every `artisanpack/breadcrumbs` block in the saved tree so the Blade / React / Vue renderers (which already consume the attribute) emit a populated `
    ` on the public frontend. The default trail is `Home → …ancestors → current`. The Home hop reads from `config('artisanpack.visual-editor.breadcrumbs.home_url')` (falls back to `url('/')`); the last entry is auto-marked `current: true` so the renderer drops the link and emits `aria-current="page"`. Hierarchical posts (pages with `parent`/`parent_id`) walk the ancestor chain bounded to 32 levels with cycle detection. Hosts customize the trail through the `ap.visual-editor.breadcrumbs.trail` filter (e.g. to insert a "Category" hop between Home and a blog post) without subclassing. A pre-stamped `_resolvedTrail` on the saved tree wins over the resolver fallback, matching PostResolver's contract. Also ships `breadcrumbs.css` to the frontend asset bundle and wires it into `` so the inline-flex layout + chevron separator applies on the public site without each host re-implementing the rules. Tightens the inter-item gap from 0.5rem to 0.25rem. Closes #565 Co-Authored-By: Claude Opus 4.7 --- config/visual-editor.php | 23 + .../resources/assets/frontend/breadcrumbs.css | 40 ++ .../views/components/blocks-styles.blade.php | 5 + .../src/Resolvers/BreadcrumbsResolver.php | 473 ++++++++++++++++++ .../src/View/Components/BlocksComponent.php | 14 + .../View/Components/BlocksStylesComponent.php | 3 + ...sualEditorRendererBladeServiceProvider.php | 10 + .../Feature/BlocksStylesComponentTest.php | 8 + .../tests/Feature/BreadcrumbsResolverTest.php | 364 ++++++++++++++ .../blocks/breadcrumbs/breadcrumbs.css | 4 +- 10 files changed, 942 insertions(+), 2 deletions(-) create mode 100644 packages/visual-editor-renderer-blade/resources/assets/frontend/breadcrumbs.css create mode 100644 packages/visual-editor-renderer-blade/src/Resolvers/BreadcrumbsResolver.php create mode 100644 packages/visual-editor-renderer-blade/tests/Feature/BreadcrumbsResolverTest.php diff --git a/config/visual-editor.php b/config/visual-editor.php index f8538103a..117560c2d 100644 --- a/config/visual-editor.php +++ b/config/visual-editor.php @@ -313,6 +313,29 @@ 'redirect_param' => 'redirect_to', ], + /* + |-------------------------------------------------------------------------- + | Breadcrumbs block (#565) + |-------------------------------------------------------------------------- + | + | Configures the `artisanpack/breadcrumbs` block's server-side trail + | resolver. The resolver builds a default trail + | (`Home → …ancestors → current`) for every breadcrumbs block on + | render; hosts customize the trail through the + | `ap.visual-editor.breadcrumbs.trail` filter (e.g. to insert a + | "Category" hop between Home and a blog post). + | + | `home_url` overrides the root link target — leave null to fall back + | to `url('/')`. `home_label` overrides the human-readable label — leave + | null to fall back to the translated "Home" string. + | + */ + + 'breadcrumbs' => [ + 'home_url' => null, + 'home_label' => null, + ], + /* |-------------------------------------------------------------------------- | Global styles diff --git a/packages/visual-editor-renderer-blade/resources/assets/frontend/breadcrumbs.css b/packages/visual-editor-renderer-blade/resources/assets/frontend/breadcrumbs.css new file mode 100644 index 000000000..41f8d57b3 --- /dev/null +++ b/packages/visual-editor-renderer-blade/resources/assets/frontend/breadcrumbs.css @@ -0,0 +1,40 @@ +/** + * Breadcrumbs block styles. + * + * Loaded in the editor via `blocks/breadcrumbs/index.ts`. Host apps + * are expected to ship matching (or compatible) rules on the public + * frontend — the Blade / React / Vue renderers emit the same class names. + */ + +.ap-breadcrumbs__list { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.25rem; + margin: 0; + padding: 0; + list-style: none; +} + +.ap-breadcrumbs__item { + display: inline-flex; + align-items: center; + gap: 0.25rem; +} + +.ap-breadcrumbs__separator { + display: inline-flex; + align-items: center; + width: 1em; + height: 1em; + opacity: 0.6; +} + +.ap-breadcrumbs__separator svg { + width: 100%; + height: 100%; +} + +.ap-breadcrumbs__current { + font-weight: 600; +} diff --git a/packages/visual-editor-renderer-blade/resources/views/components/blocks-styles.blade.php b/packages/visual-editor-renderer-blade/resources/views/components/blocks-styles.blade.php index 62c22b61a..3c9dc382e 100644 --- a/packages/visual-editor-renderer-blade/resources/views/components/blocks-styles.blade.php +++ b/packages/visual-editor-renderer-blade/resources/views/components/blocks-styles.blade.php @@ -18,6 +18,11 @@ the `--ap-social-*` custom properties stamped by the renderers so icon / hover / background color choices apply on the front end. --}} +{{-- Breadcrumbs layout + separator baseline (#565). Matches the rules + the editor loads from `blocks/breadcrumbs/breadcrumbs.css` so the + server-stamped `
      ` lays out inline with the chosen separator on + the public frontend. --}} + @if( '' !== $themeTokensCss ) @endif diff --git a/packages/visual-editor-renderer-blade/src/Resolvers/BreadcrumbsResolver.php b/packages/visual-editor-renderer-blade/src/Resolvers/BreadcrumbsResolver.php new file mode 100644 index 000000000..e0c6823de --- /dev/null +++ b/packages/visual-editor-renderer-blade/src/Resolvers/BreadcrumbsResolver.php @@ -0,0 +1,473 @@ +` + * on the public frontend. + * + * The trail is a list of `{ label: string, url: ?string, current: bool }` + * entries. A default trail always starts with a "Home" hop pointing at + * the configured home URL; when a host post is in scope the post (and + * any of its parent ancestors, for hierarchical content) is appended, + * with the final entry marked `current: true` so the renderer drops the + * link and emits `aria-current="page"`. + * + * Hosts customize the trail through the + * `ap.visual-editor.breadcrumbs.trail` filter — e.g. to insert a + * "Category" hop between Home and a blog post — without having to + * subclass the resolver. The filter receives the resolved trail, the + * current post (or null), and the block's attributes. + * + * @package ArtisanPack_UI + * @subpackage VisualEditorRendererBlade + * + * @author Jacob Martella + * + * @since 1.0.0 + */ + +declare( strict_types=1 ); + +namespace ArtisanPackUI\VisualEditorRendererBlade\Resolvers; + +use Illuminate\Http\Request; +use Throwable; + +class BreadcrumbsResolver +{ + /** + * Block name this resolver stamps. Anything else is returned untouched. + */ + protected const BREADCRUMBS_BLOCK = 'artisanpack/breadcrumbs'; + + /** + * Maximum depth we recurse when walking a hierarchical post's parent + * chain — guards against an accidental cycle (`page.parent_id` points + * back at the page itself, or a longer loop) without making the + * resolver care about model-level integrity. + */ + protected const MAX_PARENT_DEPTH = 32; + + /** + * Recursively walk a block subtree and stamp `_resolvedTrail` on every + * `artisanpack/breadcrumbs` block. Non-breadcrumbs blocks are walked + * through to handle nested cases (e.g. a breadcrumbs block inside a + * group), with their attributes left untouched. + * + * @since 1.0.0 + * + * @param array> $tree + * + * @return array> + */ + public function stampTree( array $tree, ?object $post = null ): array + { + $out = []; + + foreach ( $tree as $block ) { + if ( ! is_array( $block ) ) { + continue; + } + + $out[] = $this->stampBlock( $block, $post ); + } + + return $out; + } + + /** + * Stamp a single block (and recurse into its inner blocks) against the + * given post context. Only `artisanpack/breadcrumbs` blocks get a + * `_resolvedTrail` attribute; everything else is forwarded with its + * inner tree walked through. + * + * @since 1.0.0 + * + * @param array $block + * + * @return array + */ + public function stampBlock( array $block, ?object $post = null ): array + { + $name = isset( $block['name'] ) && is_string( $block['name'] ) ? $block['name'] : ''; + + $attributes = isset( $block['attributes'] ) && is_array( $block['attributes'] ) + ? $block['attributes'] + : []; + + if ( self::BREADCRUMBS_BLOCK === $name ) { + // A pre-stamped `_resolvedTrail` on the host's saved tree wins + // over the resolver fallback — matches PostResolver's contract + // so a host that has resolved the trail upstream (e.g. through + // a server-side render pipeline that knows more about the + // request than this resolver does) keeps full control. + if ( ! array_key_exists( '_resolvedTrail', $attributes ) ) { + $attributes['_resolvedTrail'] = $this->buildTrail( $post, $attributes ); + } + } + + $inner = isset( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) + ? $this->stampTree( $block['innerBlocks'], $post ) + : []; + + return array_merge( $block, [ + 'attributes' => $attributes, + 'innerBlocks' => $inner, + ] ); + } + + /** + * Build the resolved trail for a single breadcrumbs block. The default + * shape is `[Home, …ancestors, current]`. Hosts override the result + * through the `ap.visual-editor.breadcrumbs.trail` filter when the + * `artisanpack-ui/hooks` helper is available. + * + * @since 1.0.0 + * + * @param array $attributes Block attributes passed + * through to the filter so + * host code can inspect e.g. + * the `breadcrumbsSchema` flag. + * + * @return array> + */ + public function buildTrail( ?object $post, array $attributes = [] ): array + { + $trail = $this->defaultTrail( $post ); + + if ( function_exists( 'applyFilters' ) ) { + $filtered = applyFilters( 'ap.visual-editor.breadcrumbs.trail', $trail, $post, $attributes ); + + if ( is_array( $filtered ) ) { + $trail = $filtered; + } + } + + return $this->finalizeTrail( $trail ); + } + + /** + * Compose the unfiltered default trail: a "Home" entry plus, when a + * post is in scope, any parent ancestors and the post itself. + * + * @since 1.0.0 + * + * @return array> + */ + protected function defaultTrail( ?object $post ): array + { + $homeUrl = $this->resolveHomeUrl(); + + $trail = [ + [ + 'label' => $this->resolveHomeLabel(), + 'url' => $homeUrl, + ], + ]; + + // Homepage — when no post is in scope and the request URL matches + // the configured home URL, the trail collapses to a single Home + // entry. The finalizer marks it `current: true` so the renderer + // drops the link. + if ( null === $post && $this->onHomepage( $homeUrl ) ) { + return $trail; + } + + if ( null === $post ) { + return $trail; + } + + $ancestors = $this->collectAncestors( $post ); + + foreach ( $ancestors as $ancestor ) { + $trail[] = [ + 'label' => $this->postLabel( $ancestor ), + 'url' => $this->postUrl( $ancestor ), + ]; + } + + $trail[] = [ + 'label' => $this->postLabel( $post ), + 'url' => $this->postUrl( $post ), + ]; + + return $trail; + } + + /** + * Normalise every entry: ensure each item has `label`, `url`, and + * `current` keys; mark the final entry `current: true` and strip its + * `url` so the renderer emits an unlinked `aria-current="page"` span; + * drop entries whose label is blank or non-scalar so the host can + * filter values out by returning empty strings. + * + * @since 1.0.0 + * + * @param array $trail + * + * @return array> + */ + protected function finalizeTrail( array $trail ): array + { + $normalized = []; + + foreach ( $trail as $entry ) { + if ( ! is_array( $entry ) ) { + continue; + } + + $label = $entry['label'] ?? ''; + + if ( ! is_scalar( $label ) ) { + continue; + } + + $label = (string) $label; + + if ( '' === $label ) { + continue; + } + + $rawUrl = $entry['url'] ?? null; + $url = is_scalar( $rawUrl ) ? (string) $rawUrl : ''; + + $normalized[] = [ + 'label' => $label, + 'url' => '' === $url ? null : $url, + 'current' => ! empty( $entry['current'] ), + ]; + } + + if ( [] === $normalized ) { + return []; + } + + $lastIndex = count( $normalized ) - 1; + + // Only auto-mark the tail when no upstream entry has already + // declared itself current. Host filters that nominate a different + // entry as "current" (rare, but legal) keep control. + $hasCurrent = false; + foreach ( $normalized as $entry ) { + if ( true === $entry['current'] ) { + $hasCurrent = true; + break; + } + } + + if ( ! $hasCurrent ) { + $normalized[ $lastIndex ]['current'] = true; + $normalized[ $lastIndex ]['url'] = null; + } + + return $normalized; + } + + /** + * Walk the post's parent chain (bottom-up) and return the ancestor + * objects in top-down order. Returns an empty array when the post + * has no parent accessor or no `parent_id`. + * + * @since 1.0.0 + * + * @return array + */ + protected function collectAncestors( object $post ): array + { + $ancestors = []; + $seen = []; + $current = $this->parentPost( $post ); + $depth = 0; + + while ( $current instanceof \stdClass || is_object( $current ) ) { + $id = $this->postIdentity( $current ); + + if ( null !== $id && isset( $seen[ $id ] ) ) { + break; + } + + if ( null !== $id ) { + $seen[ $id ] = true; + } + + $ancestors[] = $current; + + if ( ++$depth >= self::MAX_PARENT_DEPTH ) { + break; + } + + $current = $this->parentPost( $current ); + } + + return array_reverse( $ancestors ); + } + + /** + * Resolve a post's parent record. Tries the `parent` accessor first + * (Eloquent `belongsTo` convention) and falls back to a `parent_id` + * lookup through `newQuery()` for hosts that expose the foreign key + * but not a relation method. Returns null when neither path yields + * a row. + * + * @since 1.0.0 + */ + protected function parentPost( object $post ): ?object + { + $parent = $post->parent ?? null; + + if ( is_object( $parent ) ) { + return $parent; + } + + $parentId = $post->parent_id ?? null; + + if ( null === $parentId || '' === $parentId ) { + return null; + } + + if ( ! method_exists( $post, 'newQuery' ) ) { + return null; + } + + try { + $result = $post->newQuery()->find( $parentId ); + } catch ( Throwable ) { + return null; + } + + return is_object( $result ) ? $result : null; + } + + /** + * Read a stable identity for a post (used to detect parent-chain + * cycles without trusting equality on the object reference). + * + * @since 1.0.0 + */ + protected function postIdentity( object $post ): ?string + { + $id = $post->id ?? null; + + if ( is_int( $id ) || is_string( $id ) ) { + return get_class( $post ) . ':' . $id; + } + + return null; + } + + /** + * Resolve the human-readable label for a post — title first, then + * `name`, then an empty string so the finalizer can drop the entry. + * + * @since 1.0.0 + */ + protected function postLabel( object $post ): string + { + $title = $post->title ?? null; + + if ( is_scalar( $title ) && '' !== (string) $title ) { + return (string) $title; + } + + $name = $post->name ?? null; + + if ( is_scalar( $name ) && '' !== (string) $name ) { + return (string) $name; + } + + return ''; + } + + /** + * Resolve the permalink URL for a post. Mirrors PostResolver's + * `permalink()` — many `HasBlockContent` models expose `permalink` + * as an Eloquent attribute accessor; hosts that don't get an empty + * string and the renderer drops the link. + * + * @since 1.0.0 + */ + protected function postUrl( object $post ): string + { + $permalink = $post->permalink ?? null; + + if ( is_string( $permalink ) ) { + return $permalink; + } + + $url = $post->url ?? null; + + return is_string( $url ) ? $url : ''; + } + + /** + * Resolve the configured home URL — `config('artisanpack.visual-editor.breadcrumbs.home_url')` + * wins when set; otherwise falls back to `url('/')`. + * + * @since 1.0.0 + */ + protected function resolveHomeUrl(): string + { + $configured = config( 'artisanpack.visual-editor.breadcrumbs.home_url' ); + + if ( is_string( $configured ) && '' !== $configured ) { + return $configured; + } + + try { + $resolved = url( '/' ); + + return is_string( $resolved ) ? $resolved : '/'; + } catch ( Throwable ) { + return '/'; + } + } + + /** + * Resolve the human-readable label for the Home entry — + * `config('artisanpack.visual-editor.breadcrumbs.home_label')` wins + * when set; otherwise falls back to the translated "Home" string. + * + * @since 1.0.0 + */ + protected function resolveHomeLabel(): string + { + $configured = config( 'artisanpack.visual-editor.breadcrumbs.home_label' ); + + if ( is_string( $configured ) && '' !== $configured ) { + return $configured; + } + + return __( 'Home' ); + } + + /** + * Best-effort detection of whether the current request is the + * configured home URL — used to short-circuit the default trail to + * a single Home entry. Compares paths so query strings / hashes + * don't make a homepage look like an inner page. + * + * @since 1.0.0 + */ + protected function onHomepage( string $homeUrl ): bool + { + try { + $request = app( Request::class ); + } catch ( Throwable ) { + return false; + } + + try { + $requestPath = '/' . ltrim( (string) $request->path(), '/' ); + } catch ( Throwable ) { + return false; + } + + $homePath = '' === $homeUrl ? '/' : ( parse_url( $homeUrl, PHP_URL_PATH ) ?? '/' ); + $homePath = '/' . ltrim( (string) $homePath, '/' ); + + return $requestPath === $homePath; + } +} diff --git a/packages/visual-editor-renderer-blade/src/View/Components/BlocksComponent.php b/packages/visual-editor-renderer-blade/src/View/Components/BlocksComponent.php index 523cd8ed4..ba73428a0 100644 --- a/packages/visual-editor-renderer-blade/src/View/Components/BlocksComponent.php +++ b/packages/visual-editor-renderer-blade/src/View/Components/BlocksComponent.php @@ -32,6 +32,7 @@ use ArtisanPackUI\VisualEditor\Services\GlobalStylesEmissionTracker; use ArtisanPackUI\VisualEditor\SiteEditor\NavigationBlockRefResolver; use ArtisanPackUI\VisualEditorRendererBlade\BlockRenderer; +use ArtisanPackUI\VisualEditorRendererBlade\Resolvers\BreadcrumbsResolver; use ArtisanPackUI\VisualEditorRendererBlade\Services\GlobalStylesEmissionResolver; use ArtisanPackUI\VisualEditorRendererBlade\Services\ResponsiveCssAccumulator; use ArtisanPackUI\VisualEditorRendererBlade\Services\StateCssAccumulator; @@ -60,6 +61,7 @@ public function __construct( protected QueryInliner $queryInliner, protected CommentInliner $commentInliner, protected PostResolver $postResolver, + protected BreadcrumbsResolver $breadcrumbsResolver, protected NavigationBlockRefResolver $navigationResolver, protected GlobalStylesEmissionResolver $globalStyles, protected GlobalStylesEmissionTracker $emissionTracker, @@ -73,6 +75,7 @@ public function __construct( bool $resolveQueries = true, bool $resolveComments = true, bool $resolvePost = true, + bool $resolveBreadcrumbs = true, bool $resolveNavigation = true, ) { $this->defaultTheme = $defaultTheme; @@ -122,6 +125,17 @@ public function __construct( ? $this->postResolver->stampTree( $resolved, $post ) : $resolved; + // Breadcrumbs resolution (#565) stamps `_resolvedTrail` on every + // `artisanpack/breadcrumbs` block. Runs unconditionally — unlike + // `PostResolver` it tolerates a null `$post` (homepage, 404, + // archive without a single record in scope) and still produces a + // usable trail (just the "Home" entry, marked current). Hosts + // extend the trail through the `ap.visual-editor.breadcrumbs.trail` + // filter; see `BreadcrumbsResolver::buildTrail()`. + $resolved = $resolveBreadcrumbs + ? $this->breadcrumbsResolver->stampTree( $resolved, is_object( $post ) ? $post : null ) + : $resolved; + // Navigation resolution mirrors the editor's read path // (Keystone #48 → #51): `core/navigation` blocks ship with // `__unstableLocation` and/or `ref` but empty `innerBlocks`. diff --git a/packages/visual-editor-renderer-blade/src/View/Components/BlocksStylesComponent.php b/packages/visual-editor-renderer-blade/src/View/Components/BlocksStylesComponent.php index 9876547af..2e43b6a18 100644 --- a/packages/visual-editor-renderer-blade/src/View/Components/BlocksStylesComponent.php +++ b/packages/visual-editor-renderer-blade/src/View/Components/BlocksStylesComponent.php @@ -67,6 +67,8 @@ class BlocksStylesComponent extends Component public string $socialIconsStyleHref; + public string $breadcrumbsStyleHref; + public string $interactivityScriptSrc; public bool $emitBlockLibrary; @@ -102,6 +104,7 @@ public function __construct( $this->gridStyleHref = $base . '/frontend/grid.css'; $this->marqueeStyleHref = $base . '/frontend/marquee.css'; $this->socialIconsStyleHref = $base . '/frontend/social-icons.css'; + $this->breadcrumbsStyleHref = $base . '/frontend/breadcrumbs.css'; $this->interactivityScriptSrc = $base . '/frontend/interactivity.js'; $this->emitBlockLibrary = $bundle; $this->emitInteractive = $interactive; diff --git a/packages/visual-editor-renderer-blade/src/VisualEditorRendererBladeServiceProvider.php b/packages/visual-editor-renderer-blade/src/VisualEditorRendererBladeServiceProvider.php index a6b8d3b41..2b72116cb 100644 --- a/packages/visual-editor-renderer-blade/src/VisualEditorRendererBladeServiceProvider.php +++ b/packages/visual-editor-renderer-blade/src/VisualEditorRendererBladeServiceProvider.php @@ -23,6 +23,7 @@ use ArtisanPackUI\VisualEditor\Resources\TemplatePartInliner; use ArtisanPackUI\VisualEditor\Responsive\BreakpointRegistry; use ArtisanPackUI\VisualEditor\Responsive\ResponsiveValueResolver; +use ArtisanPackUI\VisualEditorRendererBlade\Resolvers\BreadcrumbsResolver; use ArtisanPackUI\VisualEditorRendererBlade\Resolvers\LoginoutResolver; use ArtisanPackUI\VisualEditorRendererBlade\Resolvers\SiteMetaResolver; use ArtisanPackUI\VisualEditorRendererBlade\Responsive\ResponsiveClassResolver; @@ -58,6 +59,15 @@ public function register(): void return new LoginoutResolver(); } ); + // Scoped — the breadcrumbs resolver reads the current request's + // URL when checking for the homepage short-circuit and walks the + // in-flight post's parent chain. Pinning the first request's + // resolver in a long-running worker (Octane / queue) would feed + // stale request context to every subsequent render. #565. + $this->app->scoped( BreadcrumbsResolver::class, function () { + return new BreadcrumbsResolver(); + } ); + // Scoped (not singleton) so the BlockRenderer captures the // current request's SiteMetaResolver — also scoped — instead of // pinning the first request's resolver for the lifetime of a diff --git a/packages/visual-editor-renderer-blade/tests/Feature/BlocksStylesComponentTest.php b/packages/visual-editor-renderer-blade/tests/Feature/BlocksStylesComponentTest.php index 27af32d0b..0bcc3f11f 100644 --- a/packages/visual-editor-renderer-blade/tests/Feature/BlocksStylesComponentTest.php +++ b/packages/visual-editor-renderer-blade/tests/Feature/BlocksStylesComponentTest.php @@ -33,6 +33,14 @@ ->not->toContain( 'data-ve-tabs' ); } ); +it( 'emits the breadcrumbs frontend stylesheet link independently of $emitInteractive', function () { + $rendered = Blade::render( '' ); + + expect( $rendered ) + ->toContain( '/vendor/visual-editor-renderer-blade/frontend/breadcrumbs.css' ) + ->toContain( 'data-ve-breadcrumbs' ); +} ); + it( 'rebases the link href when assetBase is supplied', function () { $rendered = Blade::render( '' diff --git a/packages/visual-editor-renderer-blade/tests/Feature/BreadcrumbsResolverTest.php b/packages/visual-editor-renderer-blade/tests/Feature/BreadcrumbsResolverTest.php new file mode 100644 index 000000000..93f162f4f --- /dev/null +++ b/packages/visual-editor-renderer-blade/tests/Feature/BreadcrumbsResolverTest.php @@ -0,0 +1,364 @@ +` on the public frontend + * without each host wiring its own trail builder. + * - Defaults: a "Home" hop pointing at the configured home URL; the last + * entry marked `current: true` so the renderer drops the link and emits + * `aria-current="page"`. + * - Hierarchical content (pages with `parent`/`parent_id`) walks the + * ancestor chain into a multi-hop trail in top-down order. + * - Single posts get `Home → post title (current)`. + * - 404 / no post context still produces a `Home (current)` shell. + * - The `breadcrumbsSchema` attribute is honored (the resolver is + * indifferent; the markup carries Schema.org itemtype attributes). + * - Host filters through `ap.visual-editor.breadcrumbs.trail` override / + * extend the trail without subclassing the resolver. + * - Pre-stamped `_resolvedTrail` on the saved tree wins over the + * resolver fallback so a host that has resolved upstream keeps control. + * + * @since 1.0.0 + */ + +use ArtisanPackUI\VisualEditorRendererBlade\Resolvers\BreadcrumbsResolver; +use Illuminate\Support\Facades\Blade; + +function breadcrumbsBlockNode( array $attributes = [] ): array +{ + return [ + 'clientId' => 'breadcrumbs-cid', + 'name' => 'artisanpack/breadcrumbs', + 'attributes' => $attributes, + 'innerBlocks' => [], + ]; +} + +function breadcrumbsRenderTreeWithPost( array $tree, ?object $post = null ): string +{ + return Blade::render( + '', + [ 'tree' => $tree, 'post' => $post ] + ); +} + +function breadcrumbsFakePost( int $id, string $title, string $permalink = '', ?object $parent = null ): object +{ + $post = new stdClass(); + $post->id = $id; + $post->title = $title; + $post->permalink = '' === $permalink ? "/posts/{$id}" : $permalink; + $post->parent = $parent; + $post->parent_id = null === $parent ? null : ( $parent->id ?? null ); + + return $post; +} + +beforeEach( function (): void { + if ( function_exists( 'removeAllFilters' ) ) { + removeAllFilters( 'ap.visual-editor.breadcrumbs.trail' ); + } + + config()->set( 'artisanpack.visual-editor.breadcrumbs.home_url', null ); + config()->set( 'artisanpack.visual-editor.breadcrumbs.home_label', null ); +} ); + +it( 'stamps a Home-only trail when no post is supplied', function () { + $resolver = $this->app->make( BreadcrumbsResolver::class ); + + $tree = [ breadcrumbsBlockNode() ]; + + $stamped = $resolver->stampTree( $tree, null ); + + $trail = $stamped[0]['attributes']['_resolvedTrail']; + + expect( $trail )->toHaveCount( 1 ); + expect( $trail[0]['label'] )->toBe( 'Home' ); + expect( $trail[0]['current'] )->toBeTrue(); + expect( $trail[0]['url'] )->toBeNull(); +} ); + +it( 'renders a single Home entry as the current page on the homepage shell', function () { + $rendered = $this->stripGlobalStyles( breadcrumbsRenderTreeWithPost( [ + breadcrumbsBlockNode(), + ], null ) ); + + // Single entry — Home marked current, no link, aria-current="page". + expect( $rendered ) + ->toContain( 'toContain( 'aria-current="page"' ) + ->toContain( '>Home<' ); + + // And no separator since the trail has exactly one entry. + expect( $rendered )->not->toContain( 'ap-breadcrumbs__separator' ); +} ); + +it( 'stamps Home + post title for a single-post context', function () { + $post = breadcrumbsFakePost( 7, 'My First Post', '/blog/my-first-post' ); + + $resolver = $this->app->make( BreadcrumbsResolver::class ); + + $stamped = $resolver->stampTree( [ breadcrumbsBlockNode() ], $post ); + $trail = $stamped[0]['attributes']['_resolvedTrail']; + + expect( $trail )->toHaveCount( 2 ); + expect( $trail[0]['label'] )->toBe( 'Home' ); + expect( $trail[0]['current'] )->toBeFalse(); + expect( $trail[0]['url'] )->not->toBeNull(); + + expect( $trail[1]['label'] )->toBe( 'My First Post' ); + expect( $trail[1]['current'] )->toBeTrue(); + expect( $trail[1]['url'] )->toBeNull(); +} ); + +it( 'renders the populated trail end-to-end via x-ve-blocks for a single post', function () { + $post = breadcrumbsFakePost( 7, 'My First Post', '/blog/my-first-post' ); + + $rendered = $this->stripGlobalStyles( breadcrumbsRenderTreeWithPost( + [ breadcrumbsBlockNode() ], + $post + ) ); + + expect( $rendered ) + ->toContain( 'toContain( '>Home<' ) + ->toContain( '>My First Post<' ) + ->toContain( 'aria-current="page"' ) + ->toContain( 'ap-breadcrumbs__separator' ); +} ); + +it( 'walks the parent chain top-down for a nested page hierarchy', function () { + $grandparent = breadcrumbsFakePost( 1, 'About', '/about' ); + $parent = breadcrumbsFakePost( 2, 'Team', '/about/team', $grandparent ); + $page = breadcrumbsFakePost( 3, 'Engineering', '/about/team/engineering', $parent ); + + $resolver = $this->app->make( BreadcrumbsResolver::class ); + + $stamped = $resolver->stampTree( [ breadcrumbsBlockNode() ], $page ); + $trail = $stamped[0]['attributes']['_resolvedTrail']; + + expect( $trail )->toHaveCount( 4 ); + expect( $trail[0]['label'] )->toBe( 'Home' ); + expect( $trail[1]['label'] )->toBe( 'About' ); + expect( $trail[1]['url'] )->toBe( '/about' ); + expect( $trail[2]['label'] )->toBe( 'Team' ); + expect( $trail[2]['url'] )->toBe( '/about/team' ); + expect( $trail[3]['label'] )->toBe( 'Engineering' ); + expect( $trail[3]['current'] )->toBeTrue(); + expect( $trail[3]['url'] )->toBeNull(); +} ); + +it( 'guards against a parent-chain cycle without infinite recursion', function () { + $a = breadcrumbsFakePost( 1, 'A', '/a' ); + $b = breadcrumbsFakePost( 2, 'B', '/b' ); + + // Manually wire a cycle: A.parent = B, B.parent = A. + $a->parent = $b; + $b->parent = $a; + + $resolver = $this->app->make( BreadcrumbsResolver::class ); + + $stamped = $resolver->stampTree( [ breadcrumbsBlockNode() ], $a ); + $trail = $stamped[0]['attributes']['_resolvedTrail']; + + // Trail bounded — exactly Home + cycle members (deduped) + current. + // Cycle detection stops the walk as soon as we revisit A; ordering is + // [Home, B (parent of A), A (current)]. + expect( count( $trail ) )->toBeGreaterThanOrEqual( 2 ); + expect( count( $trail ) )->toBeLessThanOrEqual( 5 ); + expect( $trail[ count( $trail ) - 1 ]['label'] )->toBe( 'A' ); + expect( $trail[ count( $trail ) - 1 ]['current'] )->toBeTrue(); +} ); + +it( 'honors the breadcrumbsSchema attribute in the rendered markup', function () { + $post = breadcrumbsFakePost( 7, 'My Post', '/posts/7' ); + + $withSchema = $this->stripGlobalStyles( breadcrumbsRenderTreeWithPost( + [ breadcrumbsBlockNode( [ 'breadcrumbsSchema' => true ] ) ], + $post + ) ); + + expect( $withSchema ) + ->toContain( 'schema.org/BreadcrumbList' ) + ->toContain( 'schema.org/ListItem' ) + ->toContain( 'itemprop="position"' ); + + $withoutSchema = $this->stripGlobalStyles( breadcrumbsRenderTreeWithPost( + [ breadcrumbsBlockNode( [ 'breadcrumbsSchema' => false ] ) ], + $post + ) ); + + expect( $withoutSchema ) + ->not->toContain( 'schema.org/BreadcrumbList' ) + ->not->toContain( 'itemprop="position"' ); + + // The populated trail still renders without schema markup — the + // `breadcrumbsSchema` toggle controls structure-only annotations. + expect( $withoutSchema )->toContain( '>My Post<' ); +} ); + +it( 'allows hosts to override the trail through the filter hook', function () { + if ( ! function_exists( 'addFilter' ) ) { + expect( true )->toBeTrue(); + + return; + } + + addFilter( 'ap.visual-editor.breadcrumbs.trail', function ( array $trail, ?object $post, array $attributes ): array { + return [ + [ 'label' => 'Custom Home', 'url' => 'https://example.test/' ], + [ 'label' => 'Category: Travel', 'url' => '/category/travel' ], + [ 'label' => 'My Trip', 'url' => null, 'current' => true ], + ]; + }, 10 ); + + $post = breadcrumbsFakePost( 7, 'My Trip', '/posts/7' ); + + $rendered = $this->stripGlobalStyles( breadcrumbsRenderTreeWithPost( + [ breadcrumbsBlockNode() ], + $post + ) ); + + expect( $rendered ) + ->toContain( '>Custom Home<' ) + ->toContain( '>Category: Travel<' ) + ->toContain( '>My Trip<' ) + ->toContain( 'aria-current="page"' ); +} ); + +it( 'passes the block attributes to the filter so hosts can branch on them', function () { + if ( ! function_exists( 'addFilter' ) ) { + expect( true )->toBeTrue(); + + return; + } + + $capturedAttrs = null; + + addFilter( 'ap.visual-editor.breadcrumbs.trail', function ( array $trail, ?object $post, array $attributes ) use ( &$capturedAttrs ): array { + $capturedAttrs = $attributes; + + return $trail; + }, 10 ); + + $resolver = $this->app->make( BreadcrumbsResolver::class ); + + $resolver->stampTree( + [ breadcrumbsBlockNode( [ 'separatorIcon' => 'arrow-right', 'breadcrumbsSchema' => false ] ) ], + null + ); + + expect( $capturedAttrs )->toBeArray(); + expect( $capturedAttrs['separatorIcon'] ?? null )->toBe( 'arrow-right' ); + expect( $capturedAttrs['breadcrumbsSchema'] ?? null )->toBeFalse(); +} ); + +it( 'respects a pre-stamped _resolvedTrail on the saved tree', function () { + $resolver = $this->app->make( BreadcrumbsResolver::class ); + + $preStamped = [ + [ 'label' => 'Custom 1', 'url' => '/one' ], + [ 'label' => 'Custom 2', 'url' => null, 'current' => true ], + ]; + + $post = breadcrumbsFakePost( 7, 'Real Post', '/posts/7' ); + + $stamped = $resolver->stampTree( + [ breadcrumbsBlockNode( [ '_resolvedTrail' => $preStamped ] ) ], + $post + ); + + // The resolver leaves the pre-stamped trail completely alone — no + // finalization, no Home injection, no current-flag mutation. + expect( $stamped[0]['attributes']['_resolvedTrail'] )->toBe( $preStamped ); +} ); + +it( 'honors a configured custom home URL and home label', function () { + config()->set( 'artisanpack.visual-editor.breadcrumbs.home_url', 'https://example.test/start' ); + config()->set( 'artisanpack.visual-editor.breadcrumbs.home_label', 'Start' ); + + $post = breadcrumbsFakePost( 7, 'My Post', '/posts/7' ); + + $resolver = $this->app->make( BreadcrumbsResolver::class ); + + $stamped = $resolver->stampTree( [ breadcrumbsBlockNode() ], $post ); + $trail = $stamped[0]['attributes']['_resolvedTrail']; + + expect( $trail[0]['label'] )->toBe( 'Start' ); + expect( $trail[0]['url'] )->toBe( 'https://example.test/start' ); +} ); + +it( 'walks nested breadcrumbs blocks inside container blocks', function () { + $post = breadcrumbsFakePost( 7, 'Nested Post', '/posts/7' ); + + $resolver = $this->app->make( BreadcrumbsResolver::class ); + + $tree = [ + [ + 'name' => 'core/group', + 'attributes' => [], + 'innerBlocks' => [ + breadcrumbsBlockNode(), + ], + ], + ]; + + $stamped = $resolver->stampTree( $tree, $post ); + + $innerBlock = $stamped[0]['innerBlocks'][0]; + + expect( $innerBlock['name'] )->toBe( 'artisanpack/breadcrumbs' ); + expect( $innerBlock['attributes']['_resolvedTrail'] )->toBeArray(); + expect( $innerBlock['attributes']['_resolvedTrail'] )->toHaveCount( 2 ); +} ); + +it( 'tolerates posts with no parent accessor and only parent_id (no newQuery)', function () { + $post = new stdClass(); + $post->id = 5; + $post->title = 'Orphan Page'; + $post->permalink = '/orphan'; + $post->parent_id = 999; // Points at a non-existent parent. + // No `parent` accessor, no `newQuery()` method — resolver should bail + // gracefully and just stamp Home + the page itself. + + $resolver = $this->app->make( BreadcrumbsResolver::class ); + + $stamped = $resolver->stampTree( [ breadcrumbsBlockNode() ], $post ); + $trail = $stamped[0]['attributes']['_resolvedTrail']; + + expect( $trail )->toHaveCount( 2 ); + expect( $trail[1]['label'] )->toBe( 'Orphan Page' ); + expect( $trail[1]['current'] )->toBeTrue(); +} ); + +it( 'drops trail entries with blank labels through the filter', function () { + if ( ! function_exists( 'addFilter' ) ) { + expect( true )->toBeTrue(); + + return; + } + + addFilter( 'ap.visual-editor.breadcrumbs.trail', function ( array $trail, ?object $post, array $attributes ): array { + return [ + [ 'label' => 'Home', 'url' => '/' ], + [ 'label' => '', 'url' => '/dropped' ], + [ 'label' => 'Current', 'url' => null ], + ]; + }, 10 ); + + $resolver = $this->app->make( BreadcrumbsResolver::class ); + + $stamped = $resolver->stampTree( [ breadcrumbsBlockNode() ], null ); + $trail = $stamped[0]['attributes']['_resolvedTrail']; + + expect( $trail )->toHaveCount( 2 ); + expect( $trail[0]['label'] )->toBe( 'Home' ); + expect( $trail[1]['label'] )->toBe( 'Current' ); + expect( $trail[1]['current'] )->toBeTrue(); +} ); diff --git a/resources/js/visual-editor/blocks/breadcrumbs/breadcrumbs.css b/resources/js/visual-editor/blocks/breadcrumbs/breadcrumbs.css index 0504d5353..41f8d57b3 100644 --- a/resources/js/visual-editor/blocks/breadcrumbs/breadcrumbs.css +++ b/resources/js/visual-editor/blocks/breadcrumbs/breadcrumbs.css @@ -10,7 +10,7 @@ display: flex; flex-wrap: wrap; align-items: center; - gap: 0.5rem; + gap: 0.25rem; margin: 0; padding: 0; list-style: none; @@ -19,7 +19,7 @@ .ap-breadcrumbs__item { display: inline-flex; align-items: center; - gap: 0.5rem; + gap: 0.25rem; } .ap-breadcrumbs__separator { From 432aa7f705ca2e0b0fbcea91cee0db47b7d21970 Mon Sep 17 00:00:00 2001 From: Jacob Martella Date: Fri, 12 Jun 2026 17:58:33 -0500 Subject: [PATCH 2/2] chore: address CodeRabbit review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the redundant `onHomepage()` short-circuit in `BreadcrumbsResolver::defaultTrail()` — both the homepage-specific and the generic `null === $post` branches returned the same trail, so the extra check (and its `onHomepage()` helper + `Illuminate\Http\Request` import) were dead surface. Behavior unchanged: a null post still collapses to a single Home entry that the finalizer marks `current: true`. Co-Authored-By: Claude Opus 4.7 --- .../src/Resolvers/BreadcrumbsResolver.php | 41 ++----------------- 1 file changed, 4 insertions(+), 37 deletions(-) diff --git a/packages/visual-editor-renderer-blade/src/Resolvers/BreadcrumbsResolver.php b/packages/visual-editor-renderer-blade/src/Resolvers/BreadcrumbsResolver.php index e0c6823de..f1fa61c82 100644 --- a/packages/visual-editor-renderer-blade/src/Resolvers/BreadcrumbsResolver.php +++ b/packages/visual-editor-renderer-blade/src/Resolvers/BreadcrumbsResolver.php @@ -33,7 +33,6 @@ namespace ArtisanPackUI\VisualEditorRendererBlade\Resolvers; -use Illuminate\Http\Request; use Throwable; class BreadcrumbsResolver @@ -168,14 +167,10 @@ protected function defaultTrail( ?object $post ): array ], ]; - // Homepage — when no post is in scope and the request URL matches - // the configured home URL, the trail collapses to a single Home - // entry. The finalizer marks it `current: true` so the renderer - // drops the link. - if ( null === $post && $this->onHomepage( $homeUrl ) ) { - return $trail; - } - + // No post in scope (homepage, 404, archive without a single record). + // The trail collapses to the single Home entry the finalizer will + // mark `current: true`, so the renderer drops the link and emits + // `aria-current="page"`. if ( null === $post ) { return $trail; } @@ -442,32 +437,4 @@ protected function resolveHomeLabel(): string return __( 'Home' ); } - - /** - * Best-effort detection of whether the current request is the - * configured home URL — used to short-circuit the default trail to - * a single Home entry. Compares paths so query strings / hashes - * don't make a homepage look like an inner page. - * - * @since 1.0.0 - */ - protected function onHomepage( string $homeUrl ): bool - { - try { - $request = app( Request::class ); - } catch ( Throwable ) { - return false; - } - - try { - $requestPath = '/' . ltrim( (string) $request->path(), '/' ); - } catch ( Throwable ) { - return false; - } - - $homePath = '' === $homeUrl ? '/' : ( parse_url( $homeUrl, PHP_URL_PATH ) ?? '/' ); - $homePath = '/' . ltrim( (string) $homePath, '/' ); - - return $requestPath === $homePath; - } }