diff --git a/packages/renderer-parity.json b/packages/renderer-parity.json index 6d2a6cfa2..eef52c67f 100644 --- a/packages/renderer-parity.json +++ b/packages/renderer-parity.json @@ -55,6 +55,7 @@ "core/post-terms", "core/read-more", "core/term-description", + "artisanpack/breadcrumbs", "artisanpack/callout", "artisanpack/paragraph", "artisanpack/heading", diff --git a/packages/visual-editor-renderer-blade/resources/views/blocks/artisanpack/breadcrumbs.blade.php b/packages/visual-editor-renderer-blade/resources/views/blocks/artisanpack/breadcrumbs.blade.php new file mode 100644 index 000000000..2f26e42f4 --- /dev/null +++ b/packages/visual-editor-renderer-blade/resources/views/blocks/artisanpack/breadcrumbs.blade.php @@ -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 + + @if ( empty( $trail ) ) +
    + @else +
      + @foreach ( $trail as $index => $item ) + @php + $isLast = $index === count( $trail ) - 1; + $position = $index + 1; + @endphp +
    1. + @if ( $item['url'] && ! $item['current'] ) + + {{ $item['label'] }} + + @else + {{ $item['label'] }} + @endif + @if ( $breadcrumbsSchema ) + + @endif + @if ( ! $isLast ) + + @endif +
    2. + @endforeach +
    + @endif + diff --git a/packages/visual-editor-renderer-blade/tests/Unit/BlockRendererTest.php b/packages/visual-editor-renderer-blade/tests/Unit/BlockRendererTest.php index f802a0b59..76158c137 100644 --- a/packages/visual-editor-renderer-blade/tests/Unit/BlockRendererTest.php +++ b/packages/visual-editor-renderer-blade/tests/Unit/BlockRendererTest.php @@ -526,6 +526,89 @@ 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( '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( 'and( $html )->toContain( '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( '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<' ) + // Non-current entries whose URL was sanitized away render as plain + // spans — never with the "current" class, which only marks the + // trail's final crumb. + ->and( substr_count( $html, 'ap-breadcrumbs__current' ) )->toBe( 1 ); +} ); + +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( '
  1. + + {item.label} + + + ) : ( + + {item.label} + + )} + {breadcrumbsSchema && ( + + )} + {!isLast && ( + + )} +
  2. + ); + })} + + + ); +} diff --git a/packages/visual-editor-renderer-react/src/blocks/registerCoreBlocks.ts b/packages/visual-editor-renderer-react/src/blocks/registerCoreBlocks.ts index c749da06e..e0b8ed6ff 100644 --- a/packages/visual-editor-renderer-react/src/blocks/registerCoreBlocks.ts +++ b/packages/visual-editor-renderer-react/src/blocks/registerCoreBlocks.ts @@ -12,6 +12,7 @@ */ import { registerBlockRenderer } from '../registry'; +import { BreadcrumbsBlock } from './artisanpack/breadcrumbs'; import { CalloutBlock } from './artisanpack/callout'; import { LoginoutBlock } from './artisanpack/loginout'; import { @@ -210,6 +211,7 @@ const CORE_BLOCKS: Record = { '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. diff --git a/packages/visual-editor-renderer-react/tests/BlockTree.test.tsx b/packages/visual-editor-renderer-react/tests/BlockTree.test.tsx index bcf94d55b..cd1f5e710 100644 --- a/packages/visual-editor-renderer-react/tests/BlockTree.test.tsx +++ b/packages/visual-editor-renderer-react/tests/BlockTree.test.tsx @@ -616,6 +616,82 @@ describe('Core design blocks', () => { expect(html).toContain('ap-callout--info'); expect(html).toContain('data-severity="info"'); }); + + it('renders the artisanpack/breadcrumbs block with a resolved trail and schema microdata', () => { + const tree = [ + makeBlock('artisanpack/breadcrumbs', { + separatorIcon: 'chevron-right', + breadcrumbsSchema: true, + _resolvedTrail: [ + { label: 'Home', url: '/' }, + { label: 'Blog', url: '/blog' }, + { label: 'Hello World', current: true }, + ], + }), + ]; + + const html = renderTree(tree); + + expect(html).toContain(' { + const tree = [ + makeBlock('artisanpack/breadcrumbs', { + separatorIcon: 'spinning-rocket', + breadcrumbsSchema: false, + _resolvedTrail: [ + { label: 'Home', url: '/' }, + { label: 'Page', current: true }, + ], + }), + ]; + + const html = renderTree(tree); + + expect(html).toContain('d="m9 6 6 6-6 6"'); + expect(html).not.toContain('schema.org/BreadcrumbList'); + expect(html).not.toContain('itemprop="position"'); + }); + + it('drops unsafe URLs from breadcrumbs trail entries', () => { + const tree = [ + makeBlock('artisanpack/breadcrumbs', { + _resolvedTrail: [ + { label: 'Home', url: '/' }, + { label: 'XSS', url: 'javascript:alert(1)' }, + { label: 'Page', current: true }, + ], + }), + ]; + + const html = renderTree(tree); + + expect(html).not.toContain('javascript:'); + expect(html).toContain('href="/"'); + expect(html).toContain('>XSS<'); + expect((html.match(/ap-breadcrumbs__current/g) ?? []).length).toBe(1); + }); + + it('renders an empty breadcrumbs list when the trail is missing', () => { + const tree = [makeBlock('artisanpack/breadcrumbs', {})]; + + const html = renderTree(tree); + + expect(html).toContain('ap-breadcrumbs__list'); + expect(html).not.toContain('
  3. ) => void; +} + +const SEPARATOR_LABELS: Readonly> = { + 'arrow-right': 'Arrow Right', + 'chevron-right': 'Chevron Right', + 'chevron-double-right': 'Chevron Double Right', + 'long-arrow-right': 'Long Arrow Right', +}; + +const SEPARATOR_OPTIONS = SEPARATOR_ICON_NAMES.map((value) => ({ + label: SEPARATOR_LABELS[value], + value, +})); + +function isSeparatorIconName(value: string): value is SeparatorIconName { + return (SEPARATOR_ICON_NAMES as ReadonlyArray).includes(value); +} + +export default function BreadcrumbsEdit({ + attributes, + setAttributes, +}: BreadcrumbsEditProps): ReactElement { + const { separatorIcon, breadcrumbsSchema } = attributes; + + const blockProps = useBlockProps({ + className: 'ap-breadcrumbs', + }); + + const stub: ReadonlyArray = [ + __('Home', TEXT_DOMAIN), + __('Section', TEXT_DOMAIN), + __('Current Page', TEXT_DOMAIN), + ]; + + // The block's stylesheet is imported as a side effect in index.ts so + // it lives in the parent document, but the editor's `BlockCanvas` + // mounts inside a sandboxed iframe that none of those rules reach. + // Inline the layout-critical rules here so authors get an accurate + // preview without expanding scope into `canvas-styles.ts`. The + // sibling `breadcrumbs.css` keeps shipping for the public frontend. + const listStyle: React.CSSProperties = { + display: 'flex', + flexWrap: 'wrap', + alignItems: 'center', + gap: '0.5rem', + margin: 0, + padding: 0, + listStyle: 'none', + }; + const itemStyle: React.CSSProperties = { + display: 'inline-flex', + alignItems: 'center', + gap: '0.5rem', + }; + const separatorStyle: React.CSSProperties = { + display: 'inline-flex', + alignItems: 'center', + opacity: 0.6, + }; + + return ( + <> + + + ({ + label: __(option.label, TEXT_DOMAIN), + value: option.value, + }))} + onChange={(value) => { + if (isSeparatorIconName(value)) { + setAttributes({ separatorIcon: value }); + } + }} + __nextHasNoMarginBottom + /> + setAttributes({ breadcrumbsSchema: next })} + __nextHasNoMarginBottom + /> + + + + + ); +} diff --git a/resources/js/visual-editor/blocks/breadcrumbs/index.ts b/resources/js/visual-editor/blocks/breadcrumbs/index.ts new file mode 100644 index 000000000..7afcacecd --- /dev/null +++ b/resources/js/visual-editor/blocks/breadcrumbs/index.ts @@ -0,0 +1,27 @@ +/** + * Breadcrumbs block entrypoint. + * + * Auto-discovered by `editor/custom-blocks.ts` and registered against + * `@wordpress/blocks.registerBlockType`. CW0 pilot of the crosswinds-blocks + * port (#496) — server-rendered display block: `edit` previews a stub + * trail so authors see the chosen separator immediately, and the real + * trail is produced at runtime by the renderers from the stamped + * `_resolvedTrail` attribute. Upstream had no transforms. + */ + +import metadata from './block.json'; +import edit from './edit'; +import save from './save'; +import icon from './inserter-icon'; + +import './breadcrumbs.css'; + +export { edit, save, metadata, icon }; + +export default { + name: metadata.name, + metadata, + edit, + save, + icon, +}; diff --git a/resources/js/visual-editor/blocks/breadcrumbs/inserter-icon.tsx b/resources/js/visual-editor/blocks/breadcrumbs/inserter-icon.tsx new file mode 100644 index 000000000..837d4d5cf --- /dev/null +++ b/resources/js/visual-editor/blocks/breadcrumbs/inserter-icon.tsx @@ -0,0 +1,24 @@ +/** + * Breadcrumbs — inserter icon. + * + * Inline SVG so the editor canvas does not have to load `dashicons.css`. + * A trail-of-pills shape reads as "breadcrumbs" at the inserter's small + * preview size. + */ + +import type { ReactElement } from 'react'; + +export default function BreadcrumbsInserterIcon(): ReactElement { + return ( + + ); +} diff --git a/resources/js/visual-editor/blocks/breadcrumbs/save.tsx b/resources/js/visual-editor/blocks/breadcrumbs/save.tsx new file mode 100644 index 000000000..5f59254f7 --- /dev/null +++ b/resources/js/visual-editor/blocks/breadcrumbs/save.tsx @@ -0,0 +1,12 @@ +/** + * Breadcrumbs — save component. + * + * Dynamic block: the trail is resolved server-side from the current + * request (the renderers consume the stamped `_resolvedTrail` attribute). + * Returning `null` from save is the Gutenberg convention for dynamic + * blocks — only the block delimiter and attributes are persisted. + */ + +export default function BreadcrumbsSave(): null { + return null; +} diff --git a/resources/js/visual-editor/blocks/breadcrumbs/separators.tsx b/resources/js/visual-editor/blocks/breadcrumbs/separators.tsx new file mode 100644 index 000000000..36142519b --- /dev/null +++ b/resources/js/visual-editor/blocks/breadcrumbs/separators.tsx @@ -0,0 +1,58 @@ +/** + * Inline SVG separator icons shared between the breadcrumbs edit + * component and the inserter icon. + * + * Inlining (instead of pulling Font Awesome like upstream did) keeps the + * editor bundle free of an extra runtime icon dependency and guarantees + * the saved markup is byte-identical across host environments — the same + * paths are baked into the Blade / React / Vue renderers so authors see + * the same chevron the public frontend renders. + */ + +import type { ReactElement } from 'react'; + +export type SeparatorIconName = + | 'arrow-right' + | 'chevron-right' + | 'chevron-double-right' + | 'long-arrow-right'; + +export const SEPARATOR_ICON_NAMES: ReadonlyArray = [ + 'arrow-right', + 'chevron-right', + 'chevron-double-right', + 'long-arrow-right', +]; + +export const SEPARATOR_ICON_PATHS: Readonly> = { + '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 SeparatorIconProps { + readonly name: SeparatorIconName; +} + +export function SeparatorIcon({ name }: SeparatorIconProps): ReactElement { + const d = SEPARATOR_ICON_PATHS[name] ?? SEPARATOR_ICON_PATHS['chevron-right']; + + return ( + + ); +}