Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/renderer-parity.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
"core/post-terms",
"core/read-more",
"core/term-description",
"artisanpack/breadcrumbs",
"artisanpack/callout",
"artisanpack/paragraph",
"artisanpack/heading",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
@php
use ArtisanPackUI\VisualEditorRendererBlade\Support\BlockSupports;
use ArtisanPackUI\VisualEditorRendererBlade\Support\UrlSanitizer;

$validSeparators = [ 'arrow-right', 'chevron-right', 'chevron-double-right', 'long-arrow-right' ];

$separatorIcon = (string) ( $attributes['separatorIcon'] ?? 'chevron-right' );

if ( ! in_array( $separatorIcon, $validSeparators, true ) ) {
$separatorIcon = 'chevron-right';
}

$separatorPaths = [
'arrow-right' => 'M5 12h14m-6-6 6 6-6 6',
'chevron-right' => 'm9 6 6 6-6 6',
'chevron-double-right' => 'm6 6 6 6-6 6m6-12 6 6-6 6',
'long-arrow-right' => 'M3 12h17m-5-5 5 5-5 5',
];
$separatorPath = $separatorPaths[ $separatorIcon ];

$breadcrumbsSchema = (bool) ( $attributes['breadcrumbsSchema'] ?? true );

$trailRaw = $attributes['_resolvedTrail'] ?? [];
$trail = [];

if ( is_array( $trailRaw ) ) {
foreach ( $trailRaw as $entry ) {
if ( ! is_array( $entry ) ) {
continue;
}

$label = (string) ( $entry['label'] ?? '' );

if ( '' === $label ) {
continue;
}

$rawUrl = isset( $entry['url'] ) ? (string) $entry['url'] : '';
$sanitizedUrl = '' === $rawUrl ? '' : UrlSanitizer::safe( $rawUrl );

$trail[] = [
'label' => $label,
'url' => '' === $sanitizedUrl ? null : $sanitizedUrl,
'current' => ! empty( $entry['current'] ),
];
}
}

$ariaLabel = (string) ( $attributes['ariaLabel'] ?? 'Breadcrumb' );

$baseClasses = [ 'ap-breadcrumbs' ];

$listAttrs = $breadcrumbsSchema ? ' itemscope itemtype="https://schema.org/BreadcrumbList"' : '';
$listItemAttrs = $breadcrumbsSchema ? ' itemprop="itemListElement" itemscope itemtype="https://schema.org/ListItem"' : '';
@endphp
<nav{!! BlockSupports::wrapperAttrs( $attributes, $baseClasses ) !!} aria-label="{{ $ariaLabel }}">
@if ( empty( $trail ) )
<ol class="ap-breadcrumbs__list"{!! $listAttrs !!}></ol>
@else
<ol class="ap-breadcrumbs__list"{!! $listAttrs !!}>
@foreach ( $trail as $index => $item )
@php
$isLast = $index === count( $trail ) - 1;
$position = $index + 1;
@endphp
<li class="ap-breadcrumbs__item"{!! $listItemAttrs !!}>
@if ( $item['url'] && ! $item['current'] )
<a class="ap-breadcrumbs__link" href="{{ $item['url'] }}"@if ( $breadcrumbsSchema ) itemprop="item"@endif>
<span @if ( $breadcrumbsSchema ) itemprop="name"@endif>{{ $item['label'] }}</span>
</a>
@else
<span class="ap-breadcrumbs__current" @if ( $item['current'] ) aria-current="page"@endif @if ( $breadcrumbsSchema ) itemprop="name"@endif>{{ $item['label'] }}</span>
@endif
@if ( $breadcrumbsSchema )
<meta itemprop="position" content="{{ $position }}" />
@endif
@if ( ! $isLast )
<span class="ap-breadcrumbs__separator" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="{{ $separatorPath }}"></path></svg>
</span>
@endif
</li>
@endforeach
</ol>
@endif
</nav>
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,85 @@ public function render( array $attrs )
->and( $html )->toContain( 'data-severity="info"' );
} );

it( 'renders the artisanpack/breadcrumbs block with a resolved trail and schema microdata', function () {
$tree = [
makeBlock( 'artisanpack/breadcrumbs', [
'separatorIcon' => 'chevron-right',
'breadcrumbsSchema' => true,
'_resolvedTrail' => [
[ 'label' => 'Home', 'url' => '/' ],
[ 'label' => 'Blog', 'url' => '/blog' ],
[ 'label' => 'Hello World', 'current' => true ],
],
] ),
];

$html = makeRenderer()->render( $tree );

expect( $html )->toContain( '<nav' )
->and( $html )->toContain( 'ap-breadcrumbs' )
->and( $html )->toContain( 'aria-label="Breadcrumb"' )
->and( $html )->toContain( 'itemtype="https://schema.org/BreadcrumbList"' )
->and( $html )->toContain( 'itemtype="https://schema.org/ListItem"' )
->and( $html )->toContain( '<a class="ap-breadcrumbs__link" href="/"' )
->and( $html )->toContain( '<a class="ap-breadcrumbs__link" href="/blog"' )
->and( $html )->toContain( 'Hello World' )
->and( $html )->toContain( 'aria-current="page"' )
->and( $html )->toContain( 'itemprop="position" content="1"' )
->and( $html )->toContain( 'itemprop="position" content="3"' )
->and( $html )->toContain( '<svg' )
->and( $html )->toContain( 'd="m9 6 6 6-6 6"' );
} );

it( 'falls back to safe defaults when breadcrumbs separator is invalid and omits schema when disabled', function () {
$tree = [
makeBlock( 'artisanpack/breadcrumbs', [
'separatorIcon' => 'spinning-rocket',
'breadcrumbsSchema' => false,
'_resolvedTrail' => [
[ 'label' => 'Home', 'url' => '/' ],
[ 'label' => 'Page', 'current' => true ],
],
] ),
];

$html = makeRenderer()->render( $tree );

expect( $html )->toContain( 'd="m9 6 6 6-6 6"' )
->and( $html )->not->toContain( 'schema.org/BreadcrumbList' )
->and( $html )->not->toContain( 'itemprop="position"' );
} );

it( 'drops unsafe URLs from breadcrumbs trail entries', function () {
$tree = [
makeBlock( 'artisanpack/breadcrumbs', [
'_resolvedTrail' => [
[ 'label' => 'Home', 'url' => '/' ],
[ 'label' => 'XSS', 'url' => 'javascript:alert(1)' ],
[ 'label' => 'Page', 'current' => true ],
],
] ),
];

$html = makeRenderer()->render( $tree );

expect( $html )->not->toContain( 'javascript:' )
->and( $html )->not->toContain( 'href="javascript:alert(1)"' )
->and( $html )->toContain( 'href="/"' )
->and( $html )->toContain( '>XSS<' );
} );

it( 'renders an empty list when breadcrumbs trail is missing', function () {
$tree = [
makeBlock( 'artisanpack/breadcrumbs', [] ),
];

$html = makeRenderer()->render( $tree );

expect( $html )->toContain( 'ap-breadcrumbs__list' )
->and( $html )->not->toContain( '<li class="ap-breadcrumbs__item' );
} );

describe( 'Keystone #50 — block-supports compilation on the rendered HTML', function (): void {
it( 'renders a custom background color on core/group as both class marker and inline style', function (): void {
$tree = [ makeBlock( 'core/group', [
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
/**
* React renderer for the `artisanpack/breadcrumbs` block (CW0 pilot — #496).
*
* Mirrors the Blade partial at
* `packages/visual-editor-renderer-blade/resources/views/blocks/artisanpack/breadcrumbs.blade.php`
* and the Vue renderer so every environment emits identical markup. The
* trail itself is server-resolved and arrives on the `_resolvedTrail`
* attribute as an array of `{ label, url?, current? }` entries; this
* renderer is responsible for the wrapper, separator chevron, and
* (optionally) the schema.org BreadcrumbList microdata.
*/

import type { ReactElement } from 'react';

import { attrArray, attrBoolean, attrString, classList } from '../../support/attributes';
import { safeUrl } from '../../support/urlSanitizer';
import type { BlockRendererProps } from '../../types';

type SeparatorIconName =
| 'arrow-right'
| 'chevron-right'
| 'chevron-double-right'
| 'long-arrow-right';

const VALID_SEPARATORS: ReadonlyArray<SeparatorIconName> = [
'arrow-right',
'chevron-right',
'chevron-double-right',
'long-arrow-right',
];

const SEPARATOR_PATHS: Readonly<Record<SeparatorIconName, string>> = {
'arrow-right': 'M5 12h14m-6-6 6 6-6 6',
'chevron-right': 'm9 6 6 6-6 6',
'chevron-double-right': 'm6 6 6 6-6 6m6-12 6 6-6 6',
'long-arrow-right': 'M3 12h17m-5-5 5 5-5 5',
};

interface TrailItem {
readonly label: string;
readonly url: string | null;
readonly current: boolean;
}

function normalizeSeparator(value: unknown): SeparatorIconName {
const raw = attrString(value, 'chevron-right');
return (VALID_SEPARATORS as ReadonlyArray<string>).includes(raw)
? (raw as SeparatorIconName)
: 'chevron-right';
}

function normalizeTrail(value: unknown): ReadonlyArray<TrailItem> {
const items: TrailItem[] = [];

for (const entry of attrArray(value)) {
if (entry === null || typeof entry !== 'object') {
continue;
}

const record = entry as Record<string, unknown>;
const label = attrString(record.label);

if (label === '') {
continue;
}

const sanitizedUrl = safeUrl(record.url);

items.push({
label,
url: sanitizedUrl === '' ? null : sanitizedUrl,
current: attrBoolean(record.current, false),
});
}

return items;
}

export function BreadcrumbsBlock({ attributes }: BlockRendererProps): ReactElement {
const separatorIcon = normalizeSeparator(attributes.separatorIcon);
const breadcrumbsSchema = attrBoolean(attributes.breadcrumbsSchema, true);
const trail = normalizeTrail(attributes._resolvedTrail);
const ariaLabel = attrString(attributes.ariaLabel, 'Breadcrumb');
const className = attrString(attributes.className);

const wrapperClasses = classList(['ap-breadcrumbs', className]);
const separatorPath = SEPARATOR_PATHS[separatorIcon];

const listProps = breadcrumbsSchema
? {
className: 'ap-breadcrumbs__list',
itemScope: true,
itemType: 'https://schema.org/BreadcrumbList',
}
: { className: 'ap-breadcrumbs__list' };

return (
<nav className={wrapperClasses} aria-label={ariaLabel}>
<ol {...listProps}>
{trail.map((item, index) => {
const isLast = index === trail.length - 1;
const position = index + 1;

const itemProps = breadcrumbsSchema
? {
className: 'ap-breadcrumbs__item',
itemProp: 'itemListElement',
itemScope: true,
itemType: 'https://schema.org/ListItem',
}
: { className: 'ap-breadcrumbs__item' };

return (
<li key={`${position}-${item.label}`} {...itemProps}>
{item.url !== null && !item.current ? (
<a
className="ap-breadcrumbs__link"
href={item.url}
{...(breadcrumbsSchema ? { itemProp: 'item' } : {})}
>
<span
{...(breadcrumbsSchema ? { itemProp: 'name' } : {})}
>
{item.label}
</span>
</a>
) : (
<span
className="ap-breadcrumbs__current"
{...(item.current ? { 'aria-current': 'page' as const } : {})}
{...(breadcrumbsSchema ? { itemProp: 'name' } : {})}
>
{item.label}
</span>
)}
{breadcrumbsSchema && (
<meta itemProp="position" content={String(position)} />
)}
{!isLast && (
<span
className="ap-breadcrumbs__separator"
aria-hidden="true"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="1em"
height="1em"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
<path d={separatorPath} />
</svg>
</span>
)}
</li>
);
})}
</ol>
</nav>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
*/

import { registerBlockRenderer } from '../registry';
import { BreadcrumbsBlock } from './artisanpack/breadcrumbs';
import { CalloutBlock } from './artisanpack/callout';
import { LoginoutBlock } from './artisanpack/loginout';
import {
Expand Down Expand Up @@ -210,6 +211,7 @@ const CORE_BLOCKS: Record<string, BlockRenderer> = {
'artisanpack/navigation': NavigationBlock,
'core/navigation-link': NavigationLinkBlock,
'core/navigation-submenu': NavigationSubmenuBlock,
'artisanpack/breadcrumbs': BreadcrumbsBlock,
'artisanpack/callout': CalloutBlock,
// Post navigation / metadata family (#520) — same renderer for both
// namespaces; PostResolver stamps the same `_resolved*` attributes.
Expand Down
Loading