diff --git a/CHANGELOG.md b/CHANGELOG.md index 546a43385..0dec20c45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,29 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Added +- **Per-post layout overrides on the Query Loop via post-variants + (#591).** New `artisanpack/post-variant` block, child of + `artisanpack/post-template`, declares an override template that + swaps in for posts matching its `matcher` attribute. Four matcher + kinds ship in V1: `position` (`first` / `last` / `nth:` / + `range:-`), `pattern` (`odd` / `even` / + `every-nth:[:start:]`), `meta` (`sticky`, `featured`, + `has-featured-image`, `author:`, `taxonomy::`), and + `custom` (`callback:` → `apve_query_variant_match_` + filter hook). A new "Post Variants" panel in the query inspector + lists, adds, reorders, and deletes variants. Static rules + (position / pattern) precompile to a 0-based `position → + variantOrder` map stored on the parent post-template as + `_compiledVariantMap` for O(1) lookup; dynamic rules (`meta`, + `custom`) resolve at render time via the new + `ArtisanPackUI\VisualEditor\Resources\VariantResolver`. Precedence + is fixed: instance > position > pattern > meta > custom > base, + with `priority` ascending as the tie-breaker. All three renderers + (Blade, React, Vue) consume the same inlined tree — variants are + stripped server-side by `QueryInliner`, so existing query loops + with no variants render identically to before. Items rendered via + a variant carry an extra `is-variant` class on their + `core/post-template-item` wrapper for downstream styling. - **Native flex layout panel on group / column / columns / grid-item (#595).** New `Flex Layout` + `Flex Item` inspector panels expose every CSS flexbox property — direction, wrap, justify, align-items, diff --git a/docs/Hooks-and-Events.md b/docs/Hooks-and-Events.md index 6c7c63952..1aeaa3494 100644 --- a/docs/Hooks-and-Events.md +++ b/docs/Hooks-and-Events.md @@ -129,6 +129,25 @@ See [[post-editor/Livewire Integration]] for the full Livewire recipe and [[post --- +### `apve_query_variant_match_` + +Resolve a `custom`-kind matcher on an `artisanpack/post-variant` block (see [[blocks/Post Variants]]). The filter name is composed from the variant's `matcher.value` of the form `callback:`. + +**Signature:** `bool $matches, object $post, array $context -> bool` + +```php +use function ArtisanPackUI\Hooks\addFilter; + +addFilter('apve_query_variant_match_premium', function (bool $matches, object $post, array $context): bool { + // $context => ['index' => int (0-based loop position), 'total' => int] + return true === ($post->is_premium ?? false); +}, 10, 3); +``` + +Return `true` to make the variant match the current post. Variant precedence is fixed (instance > position > pattern > meta > custom > base) so a callback can be overridden by a higher-tier rule. + +--- + ## Authorization gates The site editor's access surface is controlled by a single boot-time contract: diff --git a/docs/blocks/Post-Variants.md b/docs/blocks/Post-Variants.md new file mode 100644 index 000000000..b6de27999 --- /dev/null +++ b/docs/blocks/Post-Variants.md @@ -0,0 +1,106 @@ +# Post Variants (Query Loop) + +Per-post layout overrides for the Query Loop block. A query can +declare any number of *variants*; each variant has a matcher rule and +its own block template. At render time, each post in the loop is +checked against the variants and rendered with the winning variant — +falling back to the base Post Template when no variant matches. + +## Why + +Pre-Gutenberg PHP gave authors full control via `the_loop()` and CSS +`:nth-child` — make the first post a hero card while the rest render +as a compact list, alternate odd/even card styles, give sticky posts a +different layout. Gutenberg's Query Loop renders every post with the +same Post Template. Variants close that gap. + +## Block: `artisanpack/post-variant` + +Nested under `artisanpack/post-template`. The variant's `innerBlocks` +are the override template. Attributes: + +| Attribute | Type | Notes | +|-----------|------|-------| +| `matcher` | object `{ kind, value }` | The rule. See "Matcher kinds" below. | +| `priority` | number (default `10`) | Tie-breaker inside a precedence tier. Lower wins. | +| `label` | string | Optional human label shown in the "Post Variants" panel. | + +## Matcher kinds + +| Kind | Examples | Where it resolves | +|------|----------|-------------------| +| `position` | `first`, `last`, `nth:3`, `range:4-6`, `instance:` | Compiled to a `position → variantOrder` map at save time. | +| `pattern` | `odd`, `even`, `every-nth:3`, `every-nth:3:start:2` | Same fast path as `position`. | +| `meta` | `sticky`, `featured`, `has-featured-image`, `author:42`, `taxonomy:category:news` | Walked at render time — needs the post object. | +| `custom` | `callback:` | Resolved server-side via `apve_query_variant_match_` filter. | + +`every-nth:` follows CSS `:nth-child(n)` semantics — the +first match is at position `step` (e.g. `every-nth:3` → posts 3, 6, +9). Add `:start:` to shift (`every-nth:3:start:2` → posts 2, +5, 8). + +## Precedence + +For each post the renderer picks the first match in this fixed order: + +1. `instance` — `position` matchers with the `instance:` prefix (canvas click-to-edit) +2. `position` — `first`, `last`, `nth`, `range` +3. `pattern` — `odd`, `even`, `every-nth` +4. `meta` — structural metadata +5. `custom` — callback hooks +6. Base `artisanpack/post-template` content + +Inside a tier, ties break on `priority` (ascending) then document +order. + +## Editor UX + +A "Post Variants" panel in the query block's right-sidebar inspector +lists every variant. Per-row controls: select-to-edit (focuses the +variant's InnerBlocks in the canvas), move up/down (re-orders), +delete. "Add" buttons add a new variant of each kind. + +When a variant is selected its own inspector exposes the matcher kind ++ value, an optional label, and the priority. + +## Render-side resolution (hybrid) + +Save-time compilation walks `position` and `pattern` rules and writes +a 0-based `index → variantOrder` map to the parent post-template's +`_compiledVariantMap` attribute. The server-side `QueryInliner` reads +that map first for O(1) lookup. When the map misses (older saves, +`meta`, `custom`), the resolver walks variants in precedence order and +evaluates matchers against the post. + +Items rendered via a variant carry an extra `is-variant` class on +their `core/post-template-item` wrapper for downstream styling. + +## Custom matchers (PHP filter) + +Register a custom rule by name and resolve it server-side: + +```php +use function ArtisanPackUI\Hooks\addFilter; + +addFilter( 'apve_query_variant_match_premium', function ( bool $matches, object $post, array $context ): bool { + // $context provides ['index' => int, 'total' => int] + return true === ( $post->is_premium ?? false ) && 0 === $context['index']; +}, 10, 3 ); +``` + +Then set the variant's matcher to `{ kind: 'custom', value: 'callback:premium' }`. + +## Backward compatibility + +Purely additive. Query loops with zero variants render identically to +before — the inliner's variant-detection step is a no-op when no +`artisanpack/post-variant` children are present, and the precompiled +map attribute is stripped from the rendered tree. + +## Renderer parity + +All three renderers register the block (the React/Vue components are +pass-through; Blade is a recursive render-block include) so the parity +check stays green, but in practice the server-side `QueryInliner` +strips variants from the rendered tree — they only ever materialize +as the per-post template clone. diff --git a/packages/renderer-parity.json b/packages/renderer-parity.json index 7580d153a..2e87ba078 100644 --- a/packages/renderer-parity.json +++ b/packages/renderer-parity.json @@ -106,6 +106,7 @@ "artisanpack/term-description", "artisanpack/query", "artisanpack/post-template", + "artisanpack/post-variant", "artisanpack/comments", "artisanpack/comment-template", "artisanpack/comment-author-avatar", diff --git a/packages/visual-editor-renderer-blade/resources/views/blocks/artisanpack/post-variant.blade.php b/packages/visual-editor-renderer-blade/resources/views/blocks/artisanpack/post-variant.blade.php new file mode 100644 index 000000000..30c5eaad9 --- /dev/null +++ b/packages/visual-editor-renderer-blade/resources/views/blocks/artisanpack/post-variant.blade.php @@ -0,0 +1,15 @@ +{{-- + artisanpack/post-variant (#591). + + The server-side QueryInliner strips post-variant blocks from the + rendered tree before the renderer sees them — variants are + template overrides, not blocks that emit markup. This view exists + to satisfy the renderer parity check and to provide a safe + pass-through if a variant ever reaches the renderer (e.g. a + host-side render of an un-inlined tree). +--}} +@if ( isset( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) + @foreach ( $block['innerBlocks'] as $innerBlock ) + @include('visual-editor-renderer-blade::render-block', [ 'block' => $innerBlock ]) + @endforeach +@endif diff --git a/packages/visual-editor-renderer-react/src/blocks/core/query.tsx b/packages/visual-editor-renderer-react/src/blocks/core/query.tsx index 3c9e2ea0b..11b0c8bdb 100644 --- a/packages/visual-editor-renderer-react/src/blocks/core/query.tsx +++ b/packages/visual-editor-renderer-react/src/blocks/core/query.tsx @@ -72,6 +72,19 @@ export function PostTemplateBlock({ attributes, children }: BlockRendererProps): return
    {children}
; } +/** + * `artisanpack/post-variant` (#591) is stripped from the inner-block + * tree by the server-side `QueryInliner` before the renderer ever + * walks it: variant children survive only as the per-iteration + * template clone, never as their own block. The renderer still + * registers the block so the parity check stays green and so any + * client-rendered preview tree (which can include un-inlined variants) + * has somewhere to fall through. + */ +export function PostVariantBlock({ children }: BlockRendererProps): JSX.Element { + return <>{children}; +} + export function PostTemplateItemBlock({ attributes, children }: BlockRendererProps): JSX.Element { // Coerce numeric strings ("123") to numbers so the `post-{id}` id stamps // regardless of whether the host serialized the attribute as a number or a diff --git a/packages/visual-editor-renderer-react/src/blocks/registerCoreBlocks.ts b/packages/visual-editor-renderer-react/src/blocks/registerCoreBlocks.ts index b931abb11..6a9b3d4d4 100644 --- a/packages/visual-editor-renderer-react/src/blocks/registerCoreBlocks.ts +++ b/packages/visual-editor-renderer-react/src/blocks/registerCoreBlocks.ts @@ -117,7 +117,7 @@ import { ReadMoreBlock, TermDescriptionBlock, } from './core/postNavigation'; -import { PostTemplateBlock, PostTemplateItemBlock, QueryBlock } from './core/query'; +import { PostTemplateBlock, PostTemplateItemBlock, PostVariantBlock, QueryBlock } from './core/query'; import { SiteLogoBlock, SiteTaglineBlock, SiteTitleBlock } from './core/siteContext'; import { SyncedPatternBlock } from './core/syncedPattern'; import { TemplatePartBlock } from './core/templatePart'; @@ -207,6 +207,7 @@ const CORE_BLOCKS: Record = { 'core/post-template': PostTemplateBlock, 'artisanpack/query': QueryBlock, 'artisanpack/post-template': PostTemplateBlock, + 'artisanpack/post-variant': PostVariantBlock, 'core/post-template-item': PostTemplateItemBlock, 'core/post-title': PostTitleBlock, 'artisanpack/post-title': PostTitleBlock, diff --git a/packages/visual-editor-renderer-vue/src/blocks/core/query.ts b/packages/visual-editor-renderer-vue/src/blocks/core/query.ts index ffc8df615..343e6f2be 100644 --- a/packages/visual-editor-renderer-vue/src/blocks/core/query.ts +++ b/packages/visual-editor-renderer-vue/src/blocks/core/query.ts @@ -79,6 +79,21 @@ export const PostTemplateBlock = defineComponent({ }, }); +/** + * `artisanpack/post-variant` (#591) is stripped from the inner-block + * tree by the server-side `QueryInliner` before render — the variant's + * children survive only as the per-iteration clone. The renderer + * registers the block as a pass-through so the parity check stays + * green and so any client-rendered preview tree has a fall-through. + */ +export const PostVariantBlock = defineComponent({ + name: 'PostVariantBlock', + props: blockRendererProps, + setup(_props, { slots }) { + return () => slots.default?.(); + }, +}); + export const PostTemplateItemBlock = defineComponent({ name: 'PostTemplateItemBlock', props: blockRendererProps, diff --git a/packages/visual-editor-renderer-vue/src/blocks/registerCoreBlocks.ts b/packages/visual-editor-renderer-vue/src/blocks/registerCoreBlocks.ts index 60e298bb4..7c945a39a 100644 --- a/packages/visual-editor-renderer-vue/src/blocks/registerCoreBlocks.ts +++ b/packages/visual-editor-renderer-vue/src/blocks/registerCoreBlocks.ts @@ -117,7 +117,7 @@ import { ReadMoreBlock, TermDescriptionBlock, } from './core/postNavigation'; -import { PostTemplateBlock, PostTemplateItemBlock, QueryBlock } from './core/query'; +import { PostTemplateBlock, PostTemplateItemBlock, PostVariantBlock, QueryBlock } from './core/query'; import { SiteLogoBlock, SiteTaglineBlock, SiteTitleBlock } from './core/siteContext'; import { SyncedPatternBlock } from './core/syncedPattern'; import { TemplatePartBlock } from './core/templatePart'; @@ -206,6 +206,7 @@ const CORE_BLOCKS: Record = { 'core/post-template': PostTemplateBlock, 'artisanpack/query': QueryBlock, 'artisanpack/post-template': PostTemplateBlock, + 'artisanpack/post-variant': PostVariantBlock, 'core/post-template-item': PostTemplateItemBlock, 'core/block': SyncedPatternBlock, 'core/post-title': PostTitleBlock, diff --git a/resources/js/visual-editor/blocks/post-template/block.json b/resources/js/visual-editor/blocks/post-template/block.json index eb304a3b5..3f02d1a21 100644 --- a/resources/js/visual-editor/blocks/post-template/block.json +++ b/resources/js/visual-editor/blocks/post-template/block.json @@ -17,6 +17,10 @@ "columns": { "type": "number", "default": 3 + }, + "_compiledVariantMap": { + "type": "object", + "default": {} } }, "usesContext": [ diff --git a/resources/js/visual-editor/blocks/post-variant/block.json b/resources/js/visual-editor/blocks/post-variant/block.json new file mode 100644 index 000000000..260c5a269 --- /dev/null +++ b/resources/js/visual-editor/blocks/post-variant/block.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 3, + "name": "artisanpack/post-variant", + "title": "Post Variant", + "category": "theme", + "parent": [ + "artisanpack/post-template" + ], + "description": "Per-position override template for the Query Loop. Posts matching the variant's rule render with the variant's inner blocks instead of the base Post Template.", + "textdomain": "artisanpack-visual-editor", + "attributes": { + "matcher": { + "type": "object", + "default": { + "kind": "position", + "value": "first" + } + }, + "priority": { + "type": "number", + "default": 10 + }, + "label": { + "type": "string" + } + }, + "usesContext": [ + "queryId", + "query", + "displayLayout", + "postType" + ], + "supports": { + "html": false, + "reusable": false, + "layout": false, + "inserter": false + } +} diff --git a/resources/js/visual-editor/blocks/post-variant/edit.tsx b/resources/js/visual-editor/blocks/post-variant/edit.tsx new file mode 100644 index 000000000..1c9501354 --- /dev/null +++ b/resources/js/visual-editor/blocks/post-variant/edit.tsx @@ -0,0 +1,229 @@ +/** + * Post Variant — edit component (#591). + * + * Inspector exposes the matcher kind + value, an optional human label, + * and a priority for tie-breaking inside a precedence tier. The body + * is `` — the variant template that overrides the base + * post-template for matching posts. + */ + +import type { ReactElement } from 'react'; +import { + InnerBlocks, + InspectorControls, + useBlockProps, +} from '@wordpress/block-editor'; +import { + Notice, + PanelBody, + RangeControl, + SelectControl, + TextControl, +} from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; + +import { TEXT_DOMAIN } from '../../vendor/i18n'; +import type { Matcher, MatcherKind } from '../../editor/variant-matcher'; + +interface PostVariantAttributes { + readonly matcher?: Matcher; + readonly priority?: number; + readonly label?: string; +} + +interface PostVariantEditProps { + attributes: PostVariantAttributes; + setAttributes: ( changes: Partial ) => void; +} + +const POSITION_PRESETS: ReadonlyArray<{ value: string; label: string }> = [ + { value: 'first', label: 'First post' }, + { value: 'last', label: 'Last post' }, + { value: 'nth:2', label: '2nd post' }, + { value: 'nth:3', label: '3rd post' }, + { value: 'range:1-3', label: 'Posts 1–3' }, +]; + +const PATTERN_PRESETS: ReadonlyArray<{ value: string; label: string }> = [ + { value: 'odd', label: 'Odd posts' }, + { value: 'even', label: 'Even posts' }, + { value: 'every-nth:3', label: 'Every 3rd post' }, + { value: 'every-nth:4', label: 'Every 4th post' }, +]; + +const META_PRESETS: ReadonlyArray<{ value: string; label: string }> = [ + { value: 'sticky', label: 'Sticky posts' }, + { value: 'featured', label: 'Featured posts' }, + { value: 'has-featured-image', label: 'Posts with a featured image' }, +]; + +function getMatcher( attributes: PostVariantAttributes ): Matcher { + if ( + attributes.matcher && + typeof attributes.matcher.kind === 'string' && + typeof attributes.matcher.value === 'string' + ) { + return attributes.matcher; + } + return { kind: 'position', value: 'first' }; +} + +function defaultValueForKind( kind: MatcherKind ): string { + switch ( kind ) { + case 'position': + return 'first'; + case 'pattern': + return 'odd'; + case 'meta': + return 'sticky'; + case 'custom': + return 'callback:'; + } +} + +export default function PostVariantEdit( { + attributes, + setAttributes, +}: PostVariantEditProps ): ReactElement { + const matcher = getMatcher( attributes ); + const priority = typeof attributes.priority === 'number' ? attributes.priority : 10; + const label = typeof attributes.label === 'string' ? attributes.label : ''; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const blockProps = ( useBlockProps as any )( { + className: 'wp-block-artisanpack-post-variant', + 'data-variant-kind': matcher.kind, + 'data-variant-value': matcher.value, + } ); + + const setMatcher = ( next: Matcher ): void => { + setAttributes( { matcher: next } ); + }; + + const handleKindChange = ( raw: string ): void => { + const kind = raw as MatcherKind; + if ( kind === matcher.kind ) { + return; + } + setMatcher( { kind, value: defaultValueForKind( kind ) } as Matcher ); + }; + + const presets = + matcher.kind === 'position' + ? POSITION_PRESETS + : matcher.kind === 'pattern' + ? PATTERN_PRESETS + : matcher.kind === 'meta' + ? META_PRESETS + : null; + + const summary = buildSummary( matcher, label ); + + return ( +
+ + + setAttributes( { label: value } ) } + help={ __( 'Shown in the parent query block\'s "Post Variants" panel.', TEXT_DOMAIN ) } + /> + + { presets !== null && ( + p.value === matcher.value ) + ? matcher.value + : '' + } + options={ [ + { value: '', label: __( 'Custom value…', TEXT_DOMAIN ) }, + ...presets.map( ( preset ) => ( { + value: preset.value, + label: __( preset.label, TEXT_DOMAIN ), + } ) ), + ] } + onChange={ ( value: string ) => { + if ( '' === value ) { + return; + } + setMatcher( { kind: matcher.kind, value } as Matcher ); + } } + /> + ) } + + setMatcher( { kind: matcher.kind, value } as Matcher ) + } + help={ helpFor( matcher.kind ) } + /> + + setAttributes( { priority: value ?? 10 } ) + } + min={ 0 } + max={ 100 } + help={ __( 'Lower numbers run first when variants share a precedence tier.', TEXT_DOMAIN ) } + /> + { matcher.kind === 'custom' && ( + + { __( 'Custom matchers are resolved server-side by the apve_query_variant_match_ filter hook.', TEXT_DOMAIN ) } + + ) } + + + + +
+ ); +} + +function helpFor( kind: MatcherKind ): string { + switch ( kind ) { + case 'position': + return __( 'first | last | nth: | range:-', TEXT_DOMAIN ); + case 'pattern': + return __( 'odd | even | every-nth: | every-nth::start:', TEXT_DOMAIN ); + case 'meta': + return __( 'sticky | featured | has-featured-image | author: | taxonomy::', TEXT_DOMAIN ); + case 'custom': + return __( 'callback: — resolved by apve_query_variant_match_.', TEXT_DOMAIN ); + } +} + +function buildSummary( matcher: Matcher, label: string ): string { + const tag = `${ matcher.kind }:${ matcher.value }`; + if ( label && '' !== label ) { + return `${ label } · ${ tag }`; + } + return tag; +} diff --git a/resources/js/visual-editor/blocks/post-variant/index.ts b/resources/js/visual-editor/blocks/post-variant/index.ts new file mode 100644 index 000000000..a5e3c0a61 --- /dev/null +++ b/resources/js/visual-editor/blocks/post-variant/index.ts @@ -0,0 +1,22 @@ +/** + * Post Variant block entrypoint (#591). + * + * Auto-discovered by `editor/custom-blocks.ts`. Child block of + * `artisanpack/post-template`: declares an override template that the + * server-side `QueryInliner` swaps in for posts matching its rule. + */ + +import metadata from './block.json'; +import edit from './edit'; +import save from './save'; +import icon from './inserter-icon'; + +export { edit, save, metadata, icon }; + +export default { + name: metadata.name, + metadata, + edit, + save, + icon, +}; diff --git a/resources/js/visual-editor/blocks/post-variant/inserter-icon.tsx b/resources/js/visual-editor/blocks/post-variant/inserter-icon.tsx new file mode 100644 index 000000000..687444692 --- /dev/null +++ b/resources/js/visual-editor/blocks/post-variant/inserter-icon.tsx @@ -0,0 +1,20 @@ +/** + * Post Variant — inserter icon. Stacked-layers glyph signalling the + * override-template nature of the block. + */ + +import type { ReactElement } from 'react'; + +export default function PostVariantInserterIcon(): ReactElement { + return ( + + ); +} diff --git a/resources/js/visual-editor/blocks/post-variant/save.tsx b/resources/js/visual-editor/blocks/post-variant/save.tsx new file mode 100644 index 000000000..8918df6a1 --- /dev/null +++ b/resources/js/visual-editor/blocks/post-variant/save.tsx @@ -0,0 +1,20 @@ +/** + * Post Variant — save component. + * + * Dynamic-only: the server-side `QueryInliner` reads the variant's + * serialized `innerBlocks` and clones them per matching post. The + * client save emits a wrapper so the parent post-template's saved + * tree can round-trip the variant's children verbatim. + */ + +import type { ReactElement } from 'react'; +import { useBlockProps, useInnerBlocksProps } from '@wordpress/block-editor'; + +export default function PostVariantSave(): ReactElement { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const blockProps = ( useBlockProps as any ).save(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const innerBlocksProps = ( useInnerBlocksProps as any ).save( blockProps ); + + return
; +} diff --git a/resources/js/visual-editor/blocks/query/edit.tsx b/resources/js/visual-editor/blocks/query/edit.tsx index 7db18e4cb..bc63f752c 100644 --- a/resources/js/visual-editor/blocks/query/edit.tsx +++ b/resources/js/visual-editor/blocks/query/edit.tsx @@ -30,6 +30,7 @@ import { __ } from '@wordpress/i18n'; import { useQueryPreview } from '../../editor/use-query-preview'; import { TEXT_DOMAIN } from '../../vendor/i18n'; +import PostVariantsPanel from './post-variants-panel'; interface QueryEditProps { attributes: Record; @@ -190,6 +191,10 @@ export default function QueryEdit( { + diff --git a/resources/js/visual-editor/blocks/query/post-variants-panel.css b/resources/js/visual-editor/blocks/query/post-variants-panel.css new file mode 100644 index 000000000..9c34fcb36 --- /dev/null +++ b/resources/js/visual-editor/blocks/query/post-variants-panel.css @@ -0,0 +1,72 @@ +/* + * Post Variants panel (#591). Scoped to the inspector so it picks up + * the WordPress Components 13px panel base font-size — the canvas + * default of 16px otherwise leaks into the panel and oversizes + * everything (hint paragraph, action buttons, row labels). + */ + +.ap-post-variants-panel__hint { + font-size: 12px; + line-height: 1.4; + color: rgb(117, 117, 117); + margin: 0 0 12px; +} + +.ap-post-variants-panel__list { + list-style: none; + margin: 0 0 12px; + padding: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.ap-post-variants-panel__row { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + background: rgb(247, 247, 247); + border-radius: 2px; + font-size: 12px; +} + +.ap-post-variants-panel__row-label { + flex: 1; + background: transparent; + border: 0; + text-align: left; + cursor: pointer; + padding: 0; + font-size: 12px; + line-height: 1.3; + color: inherit; +} + +.ap-post-variants-panel__row-label:focus-visible { + outline: 2px solid var(--wp-admin-theme-color, #007cba); + outline-offset: 1px; +} + +.ap-post-variants-panel__row-rule { + color: rgb(117, 117, 117); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11px; +} + +.ap-post-variants-panel__row-actions { + display: flex; + gap: 2px; + flex: 0 0 auto; +} + +.ap-post-variants-panel__add { + display: flex; + flex-direction: column; + gap: 4px; + align-items: stretch; +} + +.ap-post-variants-panel__add .components-button { + justify-content: center; +} diff --git a/resources/js/visual-editor/blocks/query/post-variants-panel.tsx b/resources/js/visual-editor/blocks/query/post-variants-panel.tsx new file mode 100644 index 000000000..beca063f4 --- /dev/null +++ b/resources/js/visual-editor/blocks/query/post-variants-panel.tsx @@ -0,0 +1,303 @@ +/** + * "Post Variants" panel for the Query Loop inspector (#591). + * + * - Lists every `artisanpack/post-variant` block under the query's + * nested `post-template`. + * - Add / select / delete / reorder via `core/block-editor` dispatch. + * - Side-effect: writes the precompiled `position → variantId` map + * onto the parent `post-template`'s `_compiledVariantMap` attribute + * whenever variants change. The map is consumed by the server-side + * inliner as the O(1) lookup for static (position / pattern) rules. + */ + +import { useEffect, useMemo, type ReactElement } from 'react'; +import { Button, PanelBody } from '@wordpress/components'; +import { useDispatch, useSelect } from '@wordpress/data'; +import { createBlock } from '@wordpress/blocks'; +import { __ } from '@wordpress/i18n'; + +import { TEXT_DOMAIN } from '../../vendor/i18n'; +import { + type Matcher, + type VariantDescriptor, + compileStaticMap, +} from '../../editor/variant-matcher'; + +import './post-variants-panel.css'; + +interface BlockEditorBlock { + clientId: string; + name: string; + attributes: Record; + innerBlocks?: BlockEditorBlock[]; +} + +interface CoreBlockEditorSelect { + getBlocks: ( clientId?: string ) => BlockEditorBlock[]; +} + +interface CoreBlockEditorDispatch { + insertBlock: ( block: unknown, index?: number, rootClientId?: string ) => void; + moveBlockToPosition: ( + clientId: string, + fromRootClientId: string, + toRootClientId: string, + index: number + ) => void; + removeBlock: ( clientId: string ) => void; + selectBlock: ( clientId: string ) => void; + updateBlockAttributes: ( clientId: string, attributes: Record ) => void; +} + +interface PostVariantsPanelProps { + readonly queryClientId: string; + readonly previewTotal: number; +} + +interface VariantRow { + readonly clientId: string; + readonly descriptor: VariantDescriptor; +} + +function readMatcher( attrs: Record ): Matcher { + const raw = attrs.matcher; + if ( + raw !== null && + typeof raw === 'object' && + ! Array.isArray( raw ) && + typeof ( raw as { kind?: unknown } ).kind === 'string' && + typeof ( raw as { value?: unknown } ).value === 'string' + ) { + return raw as Matcher; + } + return { kind: 'position', value: 'first' }; +} + +export default function PostVariantsPanel( { + queryClientId, + previewTotal, +}: PostVariantsPanelProps ): ReactElement { + // Subscribe via a stable string signature so the panel only + // re-renders when something it actually cares about changes — NOT + // every time the parent dispatches an unrelated attribute update. + // Without this, writing `_compiledVariantMap` back to the + // post-template (the effect below) would feed back through the + // store, produce a new `variantRows` reference, recompute the map, + // and re-run the effect forever (#591 — manual-test infinite loop). + const subscription = useSelect( + ( select ) => { + const store = select( 'core/block-editor' ) as unknown as CoreBlockEditorSelect; + const queryChildren = store.getBlocks( queryClientId ); + const postTemplate = queryChildren.find( + ( child ) => + child.name === 'artisanpack/post-template' || + child.name === 'core/post-template' + ); + if ( ! postTemplate ) { + return { + postTemplateClientId: null as string | null, + variantRows: [] as VariantRow[], + storedMapSignature: '', + }; + } + const variantBlocks = ( postTemplate.innerBlocks ?? [] ).filter( + ( child ) => child.name === 'artisanpack/post-variant' + ); + const rows: VariantRow[] = variantBlocks.map( ( block, idx ) => { + const attrs = block.attributes ?? {}; + const matcher = readMatcher( attrs ); + const priority = + typeof attrs.priority === 'number' ? ( attrs.priority as number ) : 10; + const label = + typeof attrs.label === 'string' ? ( attrs.label as string ) : undefined; + return { + clientId: block.clientId, + descriptor: { + order: idx, + matcher, + priority, + label, + }, + }; + } ); + const storedMap = + ( postTemplate.attributes as { _compiledVariantMap?: unknown } ) + ._compiledVariantMap; + return { + postTemplateClientId: postTemplate.clientId, + variantRows: rows, + storedMapSignature: JSON.stringify( storedMap ?? {} ), + }; + }, + [ queryClientId ] + ); + + const { postTemplateClientId, variantRows, storedMapSignature } = subscription; + + const { + insertBlock, + moveBlockToPosition, + removeBlock, + selectBlock, + updateBlockAttributes, + } = useDispatch( 'core/block-editor' ) as unknown as CoreBlockEditorDispatch; + + // Compile static rules into a position → variantOrder map and + // write it onto the post-template. Indexed by 0-based loop + // position to match the server-side inliner's `foreach ( + // $results as $index => $post )`. + // + // We compare the compiled signature to what's already stored on + // the post-template before dispatching — otherwise the dispatched + // attribute change re-runs `useSelect`, produces a new + // `variantRows` reference, recompiles the map (identical bytes, + // new object identity), and the effect fires again forever. + const { compiledMap, compiledSignature } = useMemo( () => { + // `previewTotal` is the count from the editor preview pipeline. + // Pass it through verbatim — the static map only carries + // `instance:` rules now, so a zero / loading preview just + // produces an empty map and the render-time resolver handles + // everything else with the live total. + const map = compileStaticMap( + variantRows.map( ( row ) => row.descriptor ), + previewTotal + ); + return { compiledMap: map, compiledSignature: JSON.stringify( map ) }; + }, [ variantRows, previewTotal ] ); + + useEffect( () => { + if ( postTemplateClientId === null ) { + return; + } + if ( compiledSignature === storedMapSignature ) { + return; + } + updateBlockAttributes( postTemplateClientId, { + _compiledVariantMap: compiledMap, + } ); + }, [ + compiledMap, + compiledSignature, + storedMapSignature, + postTemplateClientId, + updateBlockAttributes, + ] ); + + const handleAdd = ( kind: Matcher[ 'kind' ] ): void => { + if ( postTemplateClientId === null ) { + return; + } + const block = createBlock( 'artisanpack/post-variant', { + matcher: defaultMatcherFor( kind ), + priority: 10, + } ); + insertBlock( block, variantRows.length, postTemplateClientId ); + }; + + const handleMove = ( clientId: string, currentIndex: number, delta: number ): void => { + if ( postTemplateClientId === null ) { + return; + } + const target = currentIndex + delta; + if ( target < 0 || target >= variantRows.length ) { + return; + } + moveBlockToPosition( + clientId, + postTemplateClientId, + postTemplateClientId, + target + ); + }; + + return ( + + { variantRows.length === 0 && ( +

+ { __( + 'Variants let specific posts in the loop render with a different template (e.g. make the first post a hero card while the rest render as a list).', + TEXT_DOMAIN + ) } +

+ ) } +
    + { variantRows.map( ( row, idx ) => ( +
  • + +
    + + + +
    +
  • + ) ) } +
+
+ + + + +
+
+ ); +} + +function defaultMatcherFor( kind: Matcher[ 'kind' ] ): Matcher { + switch ( kind ) { + case 'position': + return { kind: 'position', value: 'first' }; + case 'pattern': + return { kind: 'pattern', value: 'odd' }; + case 'meta': + return { kind: 'meta', value: 'sticky' }; + case 'custom': + return { kind: 'custom', value: 'callback:' }; + } +} diff --git a/resources/js/visual-editor/editor/__tests__/variant-matcher.test.ts b/resources/js/visual-editor/editor/__tests__/variant-matcher.test.ts new file mode 100644 index 000000000..a1f229fbe --- /dev/null +++ b/resources/js/visual-editor/editor/__tests__/variant-matcher.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from 'vitest'; + +import { + type VariantDescriptor, + compileStaticMap, + matchMeta, + matchPattern, + matchPosition, + resolveVariant, + sortVariants, +} from '../variant-matcher'; + +describe( 'matchPosition', () => { + it( 'matches first / last / nth / range', () => { + expect( matchPosition( { kind: 'position', value: 'first' }, 0, 5 ) ).toBe( true ); + expect( matchPosition( { kind: 'position', value: 'first' }, 1, 5 ) ).toBe( false ); + expect( matchPosition( { kind: 'position', value: 'last' }, 4, 5 ) ).toBe( true ); + expect( matchPosition( { kind: 'position', value: 'last' }, 3, 5 ) ).toBe( false ); + expect( matchPosition( { kind: 'position', value: 'nth:3' }, 2, 5 ) ).toBe( true ); + expect( matchPosition( { kind: 'position', value: 'range:2-4' }, 1, 5 ) ).toBe( true ); + expect( matchPosition( { kind: 'position', value: 'range:2-4' }, 4, 5 ) ).toBe( false ); + } ); + + it( 'ignores instance: matchers (handled in static-map compile)', () => { + expect( matchPosition( { kind: 'position', value: 'instance:abc' }, 0, 5 ) ).toBe( false ); + } ); +} ); + +describe( 'matchPattern', () => { + it( 'matches odd / even / every-nth', () => { + expect( matchPattern( { kind: 'pattern', value: 'odd' }, 0 ) ).toBe( true ); + expect( matchPattern( { kind: 'pattern', value: 'odd' }, 1 ) ).toBe( false ); + expect( matchPattern( { kind: 'pattern', value: 'even' }, 1 ) ).toBe( true ); + expect( matchPattern( { kind: 'pattern', value: 'every-nth:3' }, 2 ) ).toBe( true ); + expect( matchPattern( { kind: 'pattern', value: 'every-nth:3:start:2' }, 1 ) ).toBe( true ); + expect( matchPattern( { kind: 'pattern', value: 'every-nth:3:start:2' }, 4 ) ).toBe( true ); + } ); +} ); + +describe( 'matchMeta', () => { + it( 'matches sticky / featured / has-featured-image / author / taxonomy', () => { + expect( matchMeta( { kind: 'meta', value: 'sticky' }, { sticky: true } ) ).toBe( true ); + expect( matchMeta( { kind: 'meta', value: 'sticky' }, { sticky: false } ) ).toBe( false ); + expect( matchMeta( { kind: 'meta', value: 'featured' }, { featured: true } ) ).toBe( true ); + expect( matchMeta( { kind: 'meta', value: 'has-featured-image' }, { hasFeaturedImage: true } ) ).toBe( true ); + expect( matchMeta( { kind: 'meta', value: 'author:42' }, { authorId: 42 } ) ).toBe( true ); + expect( matchMeta( { kind: 'meta', value: 'author:42' }, { authorId: 7 } ) ).toBe( false ); + expect( + matchMeta( + { kind: 'meta', value: 'taxonomy:category:news' }, + { taxonomies: { category: [ 'news', 'tech' ] } } + ) + ).toBe( true ); + expect( + matchMeta( + { kind: 'meta', value: 'taxonomy:category:news' }, + { taxonomies: { category: [ 'tech' ] } } + ) + ).toBe( false ); + } ); +} ); + +describe( 'sortVariants precedence', () => { + it( 'sorts instance > position > pattern > meta > custom', () => { + const variants: VariantDescriptor[] = [ + { order: 0, priority: 10, matcher: { kind: 'meta', value: 'sticky' } }, + { order: 1, priority: 10, matcher: { kind: 'pattern', value: 'odd' } }, + { order: 2, priority: 10, matcher: { kind: 'custom', value: 'callback:x' } }, + { order: 3, priority: 10, matcher: { kind: 'position', value: 'first' } }, + { order: 4, priority: 10, matcher: { kind: 'position', value: 'instance:1' } }, + ]; + const sorted = sortVariants( variants ).map( ( v ) => v.matcher.kind + ':' + v.matcher.value ); + expect( sorted ).toEqual( [ + 'position:instance:1', + 'position:first', + 'pattern:odd', + 'meta:sticky', + 'custom:callback:x', + ] ); + } ); + + it( 'breaks ties on priority then document order', () => { + const variants: VariantDescriptor[] = [ + { order: 1, priority: 10, matcher: { kind: 'position', value: 'first' } }, + { order: 2, priority: 5, matcher: { kind: 'position', value: 'first' } }, + { order: 0, priority: 10, matcher: { kind: 'position', value: 'first' } }, + ]; + const sorted = sortVariants( variants ).map( ( v ) => v.order ); + expect( sorted ).toEqual( [ 2, 0, 1 ] ); + } ); +} ); + +describe( 'compileStaticMap', () => { + it( 'returns an empty map when total is 0', () => { + const variants: VariantDescriptor[] = [ + { order: 0, priority: 10, matcher: { kind: 'position', value: 'last' } }, + { order: 1, priority: 10, matcher: { kind: 'pattern', value: 'odd' } }, + { order: 2, priority: 10, matcher: { kind: 'position', value: 'instance:1' } }, + ]; + expect( compileStaticMap( variants, 0 ) ).toEqual( {} ); + } ); + + it( 'only compiles instance: matchers — position / pattern resolve at render time', () => { + const variants: VariantDescriptor[] = [ + { order: 0, priority: 10, matcher: { kind: 'position', value: 'first' } }, + { order: 1, priority: 10, matcher: { kind: 'pattern', value: 'odd' } }, + { order: 2, priority: 10, matcher: { kind: 'position', value: 'last' } }, + { order: 3, priority: 10, matcher: { kind: 'position', value: 'instance:2' } }, + ]; + const map = compileStaticMap( variants, 4 ); + expect( map ).toEqual( { 1: 3 } ); + } ); + + it( 'leaves meta / custom matchers out of the static map', () => { + const variants: VariantDescriptor[] = [ + { order: 0, priority: 10, matcher: { kind: 'meta', value: 'sticky' } }, + { order: 1, priority: 10, matcher: { kind: 'custom', value: 'callback:x' } }, + ]; + expect( compileStaticMap( variants, 3 ) ).toEqual( {} ); + } ); +} ); + +describe( 'resolveVariant', () => { + it( 'returns the static-map hit when present', () => { + const map = { 0: 4 }; + expect( resolveVariant( 0, 3, {}, [], map ) ).toBe( 4 ); + } ); + + it( 'walks position matchers against the live total when the static map missed', () => { + const variants: VariantDescriptor[] = [ + { order: 0, priority: 10, matcher: { kind: 'position', value: 'last' } }, + ]; + // total=3 → "last" matches index 2 + expect( resolveVariant( 2, 3, {}, variants, {} ) ).toBe( 0 ); + // total=5 → "last" matches index 4, not 2 + expect( resolveVariant( 2, 5, {}, variants, {} ) ).toBe( null ); + } ); + + it( 'walks pattern matchers when the static map missed', () => { + const variants: VariantDescriptor[] = [ + { order: 1, priority: 10, matcher: { kind: 'pattern', value: 'odd' } }, + ]; + expect( resolveVariant( 0, 5, {}, variants, {} ) ).toBe( 1 ); + expect( resolveVariant( 1, 5, {}, variants, {} ) ).toBe( null ); + } ); + + it( 'walks meta matchers when the static map missed', () => { + const variants: VariantDescriptor[] = [ + { order: 2, priority: 10, matcher: { kind: 'meta', value: 'sticky' } }, + ]; + expect( resolveVariant( 1, 3, { sticky: true }, variants, {} ) ).toBe( 2 ); + expect( resolveVariant( 1, 3, { sticky: false }, variants, {} ) ).toBe( null ); + } ); + + it( 'honors precedence when multiple matchers would hit at render time', () => { + const variants: VariantDescriptor[] = [ + { order: 0, priority: 10, matcher: { kind: 'meta', value: 'sticky' } }, + { order: 1, priority: 10, matcher: { kind: 'position', value: 'first' } }, + ]; + // First post is sticky AND in position 1 — position wins (higher tier). + expect( resolveVariant( 0, 3, { sticky: true }, variants, {} ) ).toBe( 1 ); + } ); + + it( 'never resolves custom matchers client-side', () => { + const variants: VariantDescriptor[] = [ + { order: 0, priority: 10, matcher: { kind: 'custom', value: 'callback:x' } }, + ]; + expect( resolveVariant( 0, 1, {}, variants, {} ) ).toBe( null ); + } ); +} ); diff --git a/resources/js/visual-editor/editor/variant-matcher.ts b/resources/js/visual-editor/editor/variant-matcher.ts new file mode 100644 index 000000000..0e303d7f8 --- /dev/null +++ b/resources/js/visual-editor/editor/variant-matcher.ts @@ -0,0 +1,330 @@ +/** + * Post-variant matcher engine (#591). + * + * Shared spec consumed by: + * - save-time compilation (writes a `position → variantId` map onto + * the parent `post-template`), + * - canvas preview (applies the matching variant to each preview + * post in the editor), + * - server renderer (`VariantResolver.php` mirrors this logic). + * + * Precedence (fixed): + * 1. instance (`position.kind === 'instance'`) + * 2. position (first, last, nth, range) + * 3. pattern (odd, even, every-nth) + * 4. meta (sticky, featured, taxonomy, author, has-featured-image) + * 5. custom (callback hooks — server-side only) + * 6. base template (no variant matched) + * + * Ties inside a tier break on `priority` (lower wins), then document + * order. + */ + +export type MatcherKind = 'position' | 'pattern' | 'meta' | 'custom'; + +export interface PositionMatcher { + readonly kind: 'position'; + /** + * `first` | `last` | `nth:` | `range:-` | `instance:`. + * Indices are 1-based to match author intuition ("first" === 1). + */ + readonly value: string; +} + +export interface PatternMatcher { + readonly kind: 'pattern'; + /** + * `odd` | `even` | `every-nth:` | `every-nth::start:`. + * Offset is 1-based (start of loop). + */ + readonly value: string; +} + +export interface MetaMatcher { + readonly kind: 'meta'; + /** + * `sticky` | `featured` | `has-featured-image` | + * `taxonomy::` | `author:`. + */ + readonly value: string; +} + +export interface CustomMatcher { + readonly kind: 'custom'; + /** `callback:` — resolved by `apve_query_variant_match_`. */ + readonly value: string; +} + +export type Matcher = PositionMatcher | PatternMatcher | MetaMatcher | CustomMatcher; + +export interface VariantDescriptor { + /** + * 0-based position of the variant within the parent post-template's + * inner blocks. Doubles as a stable identifier — clientIds aren't + * round-tripped through save/serialize, so the compiled map + * references variants by document order. + */ + readonly order: number; + readonly matcher: Matcher; + readonly priority: number; + readonly label?: string; +} + +const TIER_RANK: Record = { + position: 2, + pattern: 3, + meta: 4, + custom: 5, +}; + +const INSTANCE_TIER = 1; + +function isInstance( matcher: Matcher ): boolean { + return matcher.kind === 'position' && matcher.value.startsWith( 'instance:' ); +} + +function tierOf( matcher: Matcher ): number { + return isInstance( matcher ) ? INSTANCE_TIER : TIER_RANK[ matcher.kind ]; +} + +/** + * Sort variants by precedence tier → priority → document order. + */ +export function sortVariants( variants: ReadonlyArray ): VariantDescriptor[] { + return [ ...variants ].sort( ( a, b ) => { + const tierDiff = tierOf( a.matcher ) - tierOf( b.matcher ); + if ( tierDiff !== 0 ) { + return tierDiff; + } + const priorityDiff = a.priority - b.priority; + if ( priorityDiff !== 0 ) { + return priorityDiff; + } + return a.order - b.order; + } ); +} + +/** + * Parse `every-nth:` or `every-nth::start:`. + */ +function parseEveryNth( value: string ): { step: number; offset: number } | null { + const parts = value.split( ':' ); + if ( parts[ 0 ] !== 'every-nth' ) { + return null; + } + const step = Number.parseInt( parts[ 1 ] ?? '', 10 ); + if ( ! Number.isFinite( step ) || step < 1 ) { + return null; + } + // Default offset = step so `every-nth:3` matches positions 3, 6, 9 + // (CSS `:nth-child(3n)` semantics). Explicit `:start:` shifts to + // , , , … + let offset = step; + if ( parts[ 2 ] === 'start' ) { + const parsed = Number.parseInt( parts[ 3 ] ?? '', 10 ); + if ( Number.isFinite( parsed ) && parsed >= 1 ) { + offset = parsed; + } + } + return { step, offset }; +} + +/** + * Parse `nth:` → 1-based position; `range:-` → inclusive + * 1-based range; `first` / `last` → fixed positions (last needs the + * total count, returned as `last`). + */ +function parsePosition( value: string ): { kind: 'first' | 'last' | 'nth' | 'range'; n?: number; from?: number; to?: number } | null { + if ( 'first' === value ) { + return { kind: 'first' }; + } + if ( 'last' === value ) { + return { kind: 'last' }; + } + if ( value.startsWith( 'nth:' ) ) { + const n = Number.parseInt( value.slice( 4 ), 10 ); + if ( Number.isFinite( n ) && n >= 1 ) { + return { kind: 'nth', n }; + } + return null; + } + if ( value.startsWith( 'range:' ) ) { + const [ from, to ] = value.slice( 6 ).split( '-' ).map( ( raw ) => Number.parseInt( raw, 10 ) ); + if ( Number.isFinite( from ) && Number.isFinite( to ) && from >= 1 && to >= from ) { + return { kind: 'range', from, to }; + } + return null; + } + return null; +} + +/** + * Does a `position` matcher match the given 0-based loop index? + * `total` is required for the `last` keyword. + */ +export function matchPosition( matcher: PositionMatcher, index: number, total: number ): boolean { + if ( matcher.value.startsWith( 'instance:' ) ) { + // `instance:` matchers are looked up by saved index map, not by + // walking — see `compileStaticMap`. + return false; + } + const parsed = parsePosition( matcher.value ); + if ( null === parsed ) { + return false; + } + const position1 = index + 1; + switch ( parsed.kind ) { + case 'first': + return 1 === position1; + case 'last': + return total >= 1 && position1 === total; + case 'nth': + return position1 === parsed.n; + case 'range': + return position1 >= ( parsed.from ?? 0 ) && position1 <= ( parsed.to ?? 0 ); + default: + return false; + } +} + +/** + * Does a `pattern` matcher match the given 0-based loop index? + */ +export function matchPattern( matcher: PatternMatcher, index: number ): boolean { + const position1 = index + 1; + if ( 'odd' === matcher.value ) { + return 1 === position1 % 2; + } + if ( 'even' === matcher.value ) { + return 0 === position1 % 2; + } + const everyNth = parseEveryNth( matcher.value ); + if ( null !== everyNth ) { + return position1 >= everyNth.offset && 0 === ( position1 - everyNth.offset ) % everyNth.step; + } + return false; +} + +/** + * Loose structural metadata describing a preview post in the editor. + * Mirrors what the canvas preview pipeline can derive from the + * `useQueryPreview` payload. Server-side meta evaluation lives in + * `VariantResolver.php` and reads the same conceptual fields off the + * post model. + */ +export interface PreviewPostMeta { + readonly sticky?: boolean; + readonly featured?: boolean; + readonly hasFeaturedImage?: boolean; + readonly authorId?: number; + readonly taxonomies?: Readonly>>; +} + +export function matchMeta( matcher: MetaMatcher, post: PreviewPostMeta ): boolean { + if ( 'sticky' === matcher.value ) { + return true === post.sticky; + } + if ( 'featured' === matcher.value ) { + return true === post.featured; + } + if ( 'has-featured-image' === matcher.value ) { + return true === post.hasFeaturedImage; + } + if ( matcher.value.startsWith( 'author:' ) ) { + const id = Number.parseInt( matcher.value.slice( 7 ), 10 ); + return Number.isFinite( id ) && post.authorId === id; + } + if ( matcher.value.startsWith( 'taxonomy:' ) ) { + const parts = matcher.value.split( ':' ); + const tax = parts[ 1 ]; + const slug = parts[ 2 ]; + if ( ! tax || ! slug || ! post.taxonomies ) { + return false; + } + const terms = post.taxonomies[ tax ]; + return Array.isArray( terms ) && terms.includes( slug ); + } + return false; +} + +/** + * Compile `instance:` position matchers into a sparse `index → + * variantOrder` map. + * + * **Only `instance:` rules go in the static map.** Earlier + * iterations also baked `position` (first/last/nth/range) and + * `pattern` (odd/even/every-nth) rules here, but those depend on the + * loop's `total` and become stale the moment the live query returns + * a different count than what was previewed at save time — + * `position:last` would point at the wrong index, etc. So we keep the + * map narrowly scoped to the canvas click-to-edit pins (which are + * fixed by definition), and `resolveVariant` walks all other matchers + * at render time with the actual `total` and `post`. + * + * Variants are walked in precedence order so `instance:` wins over + * any other rule that would also target the same index. + */ +export function compileStaticMap( + variants: ReadonlyArray, + total: number +): Record { + const sorted = sortVariants( variants ); + const map: Record = {}; + + for ( const variant of sorted ) { + if ( ! isInstance( variant.matcher ) ) { + continue; + } + // instance: form — value is a 1-based loop position. + const raw = variant.matcher.value.slice( 'instance:'.length ); + const idx1 = Number.parseInt( raw, 10 ); + if ( ! Number.isFinite( idx1 ) || idx1 < 1 || idx1 > total ) { + continue; + } + const i0 = idx1 - 1; + if ( map[ i0 ] === undefined ) { + map[ i0 ] = variant.order; + } + } + + return map; +} + +/** + * Editor-side runtime resolver: given the precompiled static map and + * the full variant list, return the winning variant id (or `null` for + * "use the base template") for a single loop iteration. Walks `meta` + * matchers when the static map missed; `custom` matchers are + * server-only and intentionally not evaluated here. + */ +export function resolveVariant( + index: number, + total: number, + post: PreviewPostMeta, + variants: ReadonlyArray, + staticMap: Record +): number | null { + const fromStatic = staticMap[ index ]; + if ( typeof fromStatic === 'number' ) { + return fromStatic; + } + const sorted = sortVariants( variants ); + for ( const variant of sorted ) { + if ( isInstance( variant.matcher ) ) { + // instance: only resolves via the static map; if it + // missed there it doesn't match this iteration. + continue; + } + if ( variant.matcher.kind === 'position' && matchPosition( variant.matcher, index, total ) ) { + return variant.order; + } + if ( variant.matcher.kind === 'pattern' && matchPattern( variant.matcher, index ) ) { + return variant.order; + } + if ( variant.matcher.kind === 'meta' && matchMeta( variant.matcher, post ) ) { + return variant.order; + } + // `custom` is server-side only; never resolved in the editor. + } + return null; +} diff --git a/src/Resources/QueryInliner.php b/src/Resources/QueryInliner.php index 46aa0c765..f45d63dc6 100644 --- a/src/Resources/QueryInliner.php +++ b/src/Resources/QueryInliner.php @@ -68,7 +68,12 @@ class QueryInliner public function __construct( protected Container $container, protected PostResolver $postResolver, - ) {} + protected ?VariantResolver $variantResolver = null, + ) { + if ( null === $this->variantResolver ) { + $this->variantResolver = new VariantResolver(); + } + } /** * Host post passed through `inline()` so the related-posts expansion @@ -603,6 +608,27 @@ protected function expandQuery( array $block ): array return $this->expandFlat( $block, $attributes, $queryInner, $results, $paginator ); } + // Variants (#591): an `artisanpack/post-variant` child of the + // post-template is NOT part of the per-iteration template — + // it's a per-post override template. Pull variants out of the + // iteration template, prime the resolver, and the loop below + // asks the resolver per post which template to use. + [ $baseTemplate, $variantBlocks ] = $this->extractVariants( $iterationTemplate ); + + $postTemplateAttrs = isset( $queryInner[ $postTemplateIndex ]['attributes'] ) + && is_array( $queryInner[ $postTemplateIndex ]['attributes'] ) + ? $queryInner[ $postTemplateIndex ]['attributes'] + : []; + + $compiledMap = isset( $postTemplateAttrs['_compiledVariantMap'] ) + && is_array( $postTemplateAttrs['_compiledVariantMap'] ) + ? $postTemplateAttrs['_compiledVariantMap'] + : []; + + $resultsList = is_array( $results ) ? array_values( $results ) : iterator_to_array( $results, false ); + + $this->variantResolver->prime( $variantBlocks, $compiledMap, count( $resultsList ) ); + // Expand: clone the iteration template once per result, stamp // _resolved* attributes, and wrap each iteration in a synthetic // `core/post-template-item` block. The renderers turn that into @@ -612,15 +638,20 @@ protected function expandQuery( array $block ): array // single-item lists. $expandedIterations = []; - foreach ( $results as $post ) { + foreach ( $resultsList as $loopIndex => $post ) { if ( ! is_object( $post ) ) { continue; } - $postId = isset( $post->id ) ? (int) $post->id : 0; + $postId = isset( $post->id ) ? (int) $post->id : 0; $iterationBlocks = []; - foreach ( $iterationTemplate as $tmplChild ) { + $variantOrder = $this->variantResolver->resolve( $loopIndex, $post ); + $activeTmpl = null !== $variantOrder + ? $this->variantResolver->innerBlocksFor( $variantOrder, $variantBlocks ) + : $baseTemplate; + + foreach ( $activeTmpl as $tmplChild ) { if ( ! is_array( $tmplChild ) ) { continue; } @@ -638,6 +669,7 @@ protected function expandQuery( array $block ): array 'postId' => $postId, 'className' => 'post-' . $postId . ' post' + . ( null !== $variantOrder ? ' is-variant' : '' ) . ' type-' . ( isset( $post->post_type ) ? (string) $post->post_type : ( isset( $post->type ) ? (string) $post->type : 'post' ) ) . ' status-' . ( isset( $post->status ) && is_object( $post->status ) && isset( $post->status->value ) ? (string) $post->status->value : ( isset( $post->status ) && is_string( $post->status ) ? $post->status : 'publish' ) ), ], @@ -987,6 +1019,40 @@ protected function expandFlat( array $block, array $attributes, array $template, ] ); } + /** + * Split a post-template's inner-block tree into the base iteration + * template (everything that is NOT a `post-variant`) and the list + * of `artisanpack/post-variant` overrides. Variants stay in their + * saved document order so the resolver's `order` index lines up + * with what the editor's `_compiledVariantMap` was built against. + * + * @param array> $template + * + * @return array{0: array>, 1: array>} + */ + protected function extractVariants( array $template ): array + { + $base = []; + $variants = []; + + foreach ( $template as $child ) { + if ( ! is_array( $child ) ) { + continue; + } + + $childName = isset( $child['name'] ) && is_string( $child['name'] ) ? $child['name'] : ''; + + if ( 'artisanpack/post-variant' === $childName ) { + $variants[] = $child; + continue; + } + + $base[] = $child; + } + + return [ $base, $variants ]; + } + protected function markFailed( array $block, string $reason ): array { $attributes = isset( $block['attributes'] ) && is_array( $block['attributes'] ) ? $block['attributes'] : []; diff --git a/src/Resources/VariantResolver.php b/src/Resources/VariantResolver.php new file mode 100644 index 000000000..a964f3a9b --- /dev/null +++ b/src/Resources/VariantResolver.php @@ -0,0 +1,489 @@ +` → `apve_query_variant_match_` filter) + * 6. base post-template (no variant matched) + * + * Ties inside a tier break on `priority` ascending then document order. + * + * The precompiled map is consulted first for O(1) lookup of static + * (position / pattern) rules — saves walking N variants per post. The + * map is built editor-side via `compileStaticMap` and stored on the + * parent `artisanpack/post-template` as `_compiledVariantMap`. When + * the map is absent (older saves, hand-edited content, server-side + * pattern expansion), the resolver falls back to walking variants and + * evaluating their matchers directly. + * + * @package ArtisanPack_UI + * @subpackage VisualEditor + * + * @author Jacob Martella + * + * @since 1.2.0 + */ + +declare( strict_types=1 ); + +namespace ArtisanPackUI\VisualEditor\Resources; + +class VariantResolver +{ + protected const TIER_RANK = [ + 'position' => 2, + 'pattern' => 3, + 'meta' => 4, + 'custom' => 5, + ]; + + protected const INSTANCE_TIER = 1; + + /** + * @var array> + */ + protected array $variants = []; + + /** + * Variants sorted once in {@see prime()} by precedence tier → + * priority → document order. Reused for every {@see resolve()} + * call on the same primed batch. + * + * @var array> + */ + protected array $sortedVariants = []; + + /** + * @var array + */ + protected array $compiledMap = []; + + protected int $total = 0; + + /** + * Prime the resolver with the parsed variant blocks, the precompiled + * static map (if any), and the total result count. Called once per + * query expansion. + * + * @param array> $variants Raw variant block + * arrays, in their + * saved document order. + * @param array $compiledMap Position → variantId map + * from the post-template + * attrs. + */ + public function prime( array $variants, array $compiledMap, int $total ): void + { + $this->variants = $this->describeVariants( $variants ); + $this->compiledMap = $this->normalizeMap( $compiledMap ); + $this->total = $total; + + $this->sortedVariants = $this->variants; + + usort( $this->sortedVariants, function ( array $a, array $b ): int { + $tierDiff = $a['tier'] - $b['tier']; + + if ( 0 !== $tierDiff ) { + return $tierDiff; + } + + $priorityDiff = $a['priority'] - $b['priority']; + + if ( 0 !== $priorityDiff ) { + return $priorityDiff; + } + + return $a['order'] - $b['order']; + } ); + } + + /** + * Resolve the winning variant index (into the `variants` array + * primed via {@see prime()}) for one loop iteration. Returns + * `null` when no variant matches. + */ + public function resolve( int $index, object $post ): ?int + { + // Static map wins first — it's the editor's precompiled + // answer for position / pattern rules. The map stores the + // variant's document-order index, which the inliner uses to + // read the original block array back. + if ( isset( $this->compiledMap[ $index ] ) ) { + $mapped = $this->compiledMap[ $index ]; + + if ( $mapped >= 0 && $mapped < count( $this->variants ) ) { + return $mapped; + } + } + + foreach ( $this->sortedVariants as $variant ) { + if ( $this->matches( $variant, $index, $post ) ) { + return $variant['order']; + } + } + + return null; + } + + /** + * Convenience: read the inner-block tree off the resolved variant + * descriptor. Caller passes the original variant arrays; the + * descriptor remembers them via `order`. + * + * @param array> $variants + * + * @return array> + */ + public function innerBlocksFor( int $order, array $variants ): array + { + $block = $variants[ $order ] ?? null; + + if ( ! is_array( $block ) ) { + return []; + } + + $inner = $block['innerBlocks'] ?? []; + + return is_array( $inner ) ? $inner : []; + } + + /** + * @param array> $variants + * + * @return array> + */ + protected function describeVariants( array $variants ): array + { + $out = []; + + foreach ( array_values( $variants ) as $order => $variant ) { + $attributes = isset( $variant['attributes'] ) && is_array( $variant['attributes'] ) + ? $variant['attributes'] + : []; + + $matcher = isset( $attributes['matcher'] ) && is_array( $attributes['matcher'] ) + ? $attributes['matcher'] + : [ 'kind' => 'position', 'value' => 'first' ]; + + $kind = isset( $matcher['kind'] ) && is_string( $matcher['kind'] ) + ? $matcher['kind'] + : 'position'; + + $value = isset( $matcher['value'] ) && is_string( $matcher['value'] ) + ? $matcher['value'] + : 'first'; + + $priority = isset( $attributes['priority'] ) && is_numeric( $attributes['priority'] ) + ? (int) $attributes['priority'] + : 10; + + $out[] = [ + 'order' => $order, + 'kind' => $kind, + 'value' => $value, + 'priority' => $priority, + 'tier' => $this->tierFor( $kind, $value ), + ]; + } + + return $out; + } + + protected function tierFor( string $kind, string $value ): int + { + if ( 'position' === $kind && str_starts_with( $value, 'instance:' ) ) { + return self::INSTANCE_TIER; + } + + return self::TIER_RANK[ $kind ] ?? 99; + } + + /** + * @param array $map + * + * @return array + */ + protected function normalizeMap( array $map ): array + { + $out = []; + + foreach ( $map as $key => $value ) { + if ( ! is_numeric( $key ) ) { + continue; + } + + if ( ! is_numeric( $value ) ) { + continue; + } + + $out[ (int) $key ] = (int) $value; + } + + return $out; + } + + /** + * @param array $variant + */ + protected function matches( array $variant, int $index, object $post ): bool + { + $kind = $variant['kind']; + $value = $variant['value']; + + switch ( $kind ) { + case 'position': + return $this->matchesPosition( $value, $index ); + case 'pattern': + return $this->matchesPattern( $value, $index ); + case 'meta': + return $this->matchesMeta( $value, $post ); + case 'custom': + return $this->matchesCustom( $value, $post, $index ); + } + + return false; + } + + protected function matchesPosition( string $value, int $index ): bool + { + if ( str_starts_with( $value, 'instance:' ) ) { + // `instance:` is treated as a fixed 1-based position + // match when the editor's static map is absent. + $raw = (int) substr( $value, strlen( 'instance:' ) ); + + return $raw >= 1 && ( $raw - 1 ) === $index; + } + + $position1 = $index + 1; + + if ( 'first' === $value ) { + return 1 === $position1; + } + + if ( 'last' === $value ) { + return $this->total >= 1 && $position1 === $this->total; + } + + if ( str_starts_with( $value, 'nth:' ) ) { + $n = (int) substr( $value, 4 ); + + return $n >= 1 && $position1 === $n; + } + + if ( str_starts_with( $value, 'range:' ) ) { + $range = substr( $value, 6 ); + $parts = explode( '-', $range ); + + if ( 2 !== count( $parts ) ) { + return false; + } + + $from = (int) $parts[0]; + $to = (int) $parts[1]; + + return $from >= 1 && $to >= $from && $position1 >= $from && $position1 <= $to; + } + + return false; + } + + protected function matchesPattern( string $value, int $index ): bool + { + $position1 = $index + 1; + + if ( 'odd' === $value ) { + return 1 === $position1 % 2; + } + + if ( 'even' === $value ) { + return 0 === $position1 % 2; + } + + if ( str_starts_with( $value, 'every-nth:' ) ) { + $parts = explode( ':', $value ); + $step = isset( $parts[1] ) ? (int) $parts[1] : 0; + + if ( $step < 1 ) { + return false; + } + + $offset = $step; + + if ( ( $parts[2] ?? null ) === 'start' && isset( $parts[3] ) ) { + $parsed = (int) $parts[3]; + + if ( $parsed >= 1 ) { + $offset = $parsed; + } + } + + return $position1 >= $offset && 0 === ( $position1 - $offset ) % $step; + } + + return false; + } + + protected function matchesMeta( string $value, object $post ): bool + { + if ( 'sticky' === $value ) { + return true === ( $post->sticky ?? false ) || true === ( $post->is_sticky ?? false ); + } + + if ( 'featured' === $value ) { + return true === ( $post->featured ?? false ) || true === ( $post->is_featured ?? false ); + } + + if ( 'has-featured-image' === $value ) { + $candidates = [ + $post->featured_image_id ?? null, + $post->featured_image ?? null, + $post->thumbnail_id ?? null, + ]; + + foreach ( $candidates as $candidate ) { + if ( null === $candidate ) { + continue; + } + + if ( is_int( $candidate ) && $candidate > 0 ) { + return true; + } + + if ( is_object( $candidate ) ) { + return true; + } + + if ( is_string( $candidate ) && '' !== $candidate ) { + return true; + } + } + + return false; + } + + if ( str_starts_with( $value, 'author:' ) ) { + $expected = (int) substr( $value, 7 ); + + if ( $expected < 1 ) { + return false; + } + + $authorId = null; + + if ( isset( $post->author_id ) && is_numeric( $post->author_id ) ) { + $authorId = (int) $post->author_id; + } elseif ( isset( $post->user_id ) && is_numeric( $post->user_id ) ) { + $authorId = (int) $post->user_id; + } elseif ( isset( $post->author ) && is_object( $post->author ) && isset( $post->author->id ) ) { + $authorId = (int) $post->author->id; + } + + return $authorId === $expected; + } + + if ( str_starts_with( $value, 'taxonomy:' ) ) { + $parts = explode( ':', $value, 3 ); + + if ( 3 !== count( $parts ) ) { + return false; + } + + [ , $taxonomy, $slug ] = $parts; + + if ( '' === $taxonomy || '' === $slug ) { + return false; + } + + return $this->postHasTerm( $post, $taxonomy, $slug ); + } + + return false; + } + + protected function matchesCustom( string $value, object $post, int $index ): bool + { + if ( ! str_starts_with( $value, 'callback:' ) ) { + return false; + } + + $name = trim( substr( $value, 9 ) ); + + if ( '' === $name || ! function_exists( 'ArtisanPackUI\\Hooks\\applyFilters' ) ) { + return false; + } + + $context = [ + 'index' => $index, + 'total' => $this->total, + ]; + + $result = \ArtisanPackUI\Hooks\applyFilters( + 'apve_query_variant_match_' . $name, + false, + $post, + $context + ); + + return true === $result; + } + + protected function postHasTerm( object $post, string $taxonomy, string $slug ): bool + { + // Map common taxonomy slugs to the loose relations posts expose. + $relations = []; + + switch ( $taxonomy ) { + case 'category': + $relations[] = 'categories'; + break; + case 'post_tag': + case 'tag': + $relations[] = 'tags'; + break; + default: + $relations[] = $taxonomy; + $relations[] = $taxonomy . 's'; + break; + } + + $relations[] = 'terms'; + + foreach ( $relations as $relation ) { + $collection = $post->{$relation} ?? null; + + if ( ! is_iterable( $collection ) ) { + continue; + } + + foreach ( $collection as $term ) { + if ( ! is_object( $term ) ) { + continue; + } + + $termTaxonomy = $term->taxonomy ?? null; + + if ( null !== $termTaxonomy && $termTaxonomy !== $taxonomy ) { + continue; + } + + $termSlug = $term->slug ?? null; + + if ( is_string( $termSlug ) && $termSlug === $slug ) { + return true; + } + } + } + + return false; + } +} diff --git a/src/VisualEditorServiceProvider.php b/src/VisualEditorServiceProvider.php index 2f7736983..53df1dd6e 100644 --- a/src/VisualEditorServiceProvider.php +++ b/src/VisualEditorServiceProvider.php @@ -564,6 +564,7 @@ protected function registerForkedBlocks(): void 'tag-cloud', 'query', 'post-template', + 'post-variant', ]; foreach ( $forkedBlocks as $block ) { diff --git a/tests/Unit/VisualEditor/Resources/QueryInlinerVariantsTest.php b/tests/Unit/VisualEditor/Resources/QueryInlinerVariantsTest.php new file mode 100644 index 000000000..aea9859ad --- /dev/null +++ b/tests/Unit/VisualEditor/Resources/QueryInlinerVariantsTest.php @@ -0,0 +1,278 @@ + 'artisanpack/post-variant', + 'attributes' => [ + 'matcher' => $matcher, + 'priority' => $priority, + ], + 'innerBlocks' => $innerBlocks, + ]; +} + +function queryWithVariants( array $baseInner, array $variants, array $compiledMap = [] ): array +{ + $postTemplateChildren = array_merge( $baseInner, $variants ); + + return [ + 'name' => 'core/query', + 'attributes' => [ 'query' => [ 'postType' => 'post', 'perPage' => 5 ] ], + 'innerBlocks' => [ + [ + 'name' => 'core/post-template', + 'attributes' => [ '_compiledVariantMap' => $compiledMap ], + 'innerBlocks' => $postTemplateChildren, + ], + ], + ]; +} + +function variantPostFixture( int $id, string $title, array $extras = [] ): object +{ + $post = new stdClass(); + $post->id = $id; + $post->title = $title; + $post->content = "

{$title}

"; + $post->permalink = "/posts/{$id}"; + $post->author = null; + $post->published_at = null; + $post->updated_at = null; + $post->featured_image_id = null; + + foreach ( $extras as $key => $value ) { + $post->{$key} = $value; + } + + return $post; +} + +beforeEach( function (): void { + $this->fake = new FakeQueryResolver(); + $this->app->instance( QueryResolverContract::class, $this->fake ); + + $this->inliner = new QueryInliner( $this->app, new PostResolver() ); +} ); + +it( 'renders identically when no variants are declared (backwards compat)', function () { + $this->fake->setItems( [ + variantPostFixture( 1, 'First' ), + variantPostFixture( 2, 'Second' ), + ] ); + + $base = [ + [ 'name' => 'core/post-title', 'attributes' => [], 'innerBlocks' => [] ], + ]; + + $tree = [ queryWithVariants( $base, [] ) ]; + $inlined = $this->inliner->inline( $tree ); + + $items = $inlined[0]['innerBlocks'][0]['innerBlocks']; + + expect( count( $items ) )->toBe( 2 ) + ->and( $items[0]['name'] )->toBe( 'core/post-template-item' ) + ->and( count( $items[0]['innerBlocks'] ) )->toBe( 1 ) + ->and( $items[0]['innerBlocks'][0]['name'] )->toBe( 'core/post-title' ) + // no-variant items must not get the `is-variant` class + ->and( str_contains( $items[0]['attributes']['className'], 'is-variant' ) )->toBeFalse() + ->and( str_contains( $items[1]['attributes']['className'], 'is-variant' ) )->toBeFalse(); +} ); + +it( 'swaps the first post template via a position:first variant', function () { + $this->fake->setItems( [ + variantPostFixture( 1, 'Hero' ), + variantPostFixture( 2, 'Listed' ), + ] ); + + $base = [ [ 'name' => 'core/post-title', 'attributes' => [], 'innerBlocks' => [] ] ]; + $variant = variantBlock( + [ 'kind' => 'position', 'value' => 'first' ], + [ [ 'name' => 'core/post-excerpt', 'attributes' => [], 'innerBlocks' => [] ] ] + ); + + $tree = [ queryWithVariants( $base, [ $variant ] ) ]; + $inlined = $this->inliner->inline( $tree ); + + $items = $inlined[0]['innerBlocks'][0]['innerBlocks']; + + expect( count( $items ) )->toBe( 2 ) + // variant template for the first post + ->and( $items[0]['innerBlocks'][0]['name'] )->toBe( 'core/post-excerpt' ) + // base template for the rest + ->and( $items[1]['innerBlocks'][0]['name'] )->toBe( 'core/post-title' ) + // is-variant class is stamped on the swapped iteration + ->and( str_contains( $items[0]['attributes']['className'], 'is-variant' ) )->toBeTrue() + ->and( str_contains( $items[1]['attributes']['className'], 'is-variant' ) )->toBeFalse(); +} ); + +it( 'matches odd / even pattern variants', function () { + $this->fake->setItems( [ + variantPostFixture( 1, 'A' ), + variantPostFixture( 2, 'B' ), + variantPostFixture( 3, 'C' ), + ] ); + + $base = [ [ 'name' => 'core/post-title', 'attributes' => [], 'innerBlocks' => [] ] ]; + $oddVariant = variantBlock( + [ 'kind' => 'pattern', 'value' => 'odd' ], + [ [ 'name' => 'core/post-content', 'attributes' => [], 'innerBlocks' => [] ] ] + ); + + $inlined = $this->inliner->inline( [ queryWithVariants( $base, [ $oddVariant ] ) ] ); + $items = $inlined[0]['innerBlocks'][0]['innerBlocks']; + + expect( $items[0]['innerBlocks'][0]['name'] )->toBe( 'core/post-content' ) // pos 1 odd + ->and( $items[1]['innerBlocks'][0]['name'] )->toBe( 'core/post-title' ) // pos 2 even -> base + ->and( $items[2]['innerBlocks'][0]['name'] )->toBe( 'core/post-content' ); // pos 3 odd +} ); + +it( 'matches meta:sticky variants by walking the variant list at render time', function () { + $this->fake->setItems( [ + variantPostFixture( 1, 'Pinned', [ 'sticky' => true ] ), + variantPostFixture( 2, 'Normal' ), + ] ); + + $base = [ [ 'name' => 'core/post-title', 'attributes' => [], 'innerBlocks' => [] ] ]; + $variant = variantBlock( + [ 'kind' => 'meta', 'value' => 'sticky' ], + [ [ 'name' => 'core/post-content', 'attributes' => [], 'innerBlocks' => [] ] ] + ); + + $inlined = $this->inliner->inline( [ queryWithVariants( $base, [ $variant ] ) ] ); + $items = $inlined[0]['innerBlocks'][0]['innerBlocks']; + + expect( $items[0]['innerBlocks'][0]['name'] )->toBe( 'core/post-content' ) + ->and( $items[1]['innerBlocks'][0]['name'] )->toBe( 'core/post-title' ); +} ); + +it( 'resolves custom matchers via the apve_query_variant_match_ filter hook', function () { + if ( ! function_exists( 'ArtisanPackUI\\Hooks\\applyFilters' ) ) { + test()->markTestSkipped( 'artisanpack-ui/hooks not loaded.' ); + return; + } + + $this->fake->setItems( [ + variantPostFixture( 1, 'Promo', [ 'is_promo' => true ] ), + variantPostFixture( 2, 'Regular' ), + ] ); + + \ArtisanPackUI\Hooks\addFilter( + 'apve_query_variant_match_premium', + fn ( $matches, $post ) => true === ( $post->is_promo ?? false ), + 10 + ); + + try { + $base = [ [ 'name' => 'core/post-title', 'attributes' => [], 'innerBlocks' => [] ] ]; + $variant = variantBlock( + [ 'kind' => 'custom', 'value' => 'callback:premium' ], + [ [ 'name' => 'core/post-content', 'attributes' => [], 'innerBlocks' => [] ] ] + ); + + $inlined = $this->inliner->inline( [ queryWithVariants( $base, [ $variant ] ) ] ); + $items = $inlined[0]['innerBlocks'][0]['innerBlocks']; + + expect( $items[0]['innerBlocks'][0]['name'] )->toBe( 'core/post-content' ) + ->and( $items[1]['innerBlocks'][0]['name'] )->toBe( 'core/post-title' ); + } finally { + \ArtisanPackUI\Hooks\removeAllFilters( 'apve_query_variant_match_premium' ); + } +} ); + +it( 'honors the precedence cascade: position > pattern > meta > custom', function () { + $this->fake->setItems( [ + variantPostFixture( 1, 'First', [ 'sticky' => true ] ), + variantPostFixture( 2, 'Second' ), + ] ); + + $base = [ [ 'name' => 'core/post-title', 'attributes' => [], 'innerBlocks' => [] ] ]; + + $positionVariant = variantBlock( + [ 'kind' => 'position', 'value' => 'first' ], + [ [ 'name' => 'core/post-excerpt', 'attributes' => [], 'innerBlocks' => [] ] ] + ); + $metaVariant = variantBlock( + [ 'kind' => 'meta', 'value' => 'sticky' ], + [ [ 'name' => 'core/post-content', 'attributes' => [], 'innerBlocks' => [] ] ] + ); + + // First post matches BOTH position:first AND meta:sticky. + // Position must win because it's a higher precedence tier. + $inlined = $this->inliner->inline( [ + queryWithVariants( $base, [ $metaVariant, $positionVariant ] ), + ] ); + + $items = $inlined[0]['innerBlocks'][0]['innerBlocks']; + + expect( $items[0]['innerBlocks'][0]['name'] )->toBe( 'core/post-excerpt' ); +} ); + +it( 'uses the precompiled _compiledVariantMap as the fast path for static matchers', function () { + $this->fake->setItems( [ + variantPostFixture( 1, 'A' ), + variantPostFixture( 2, 'B' ), + variantPostFixture( 3, 'C' ), + ] ); + + $base = [ [ 'name' => 'core/post-title', 'attributes' => [], 'innerBlocks' => [] ] ]; + $variant = variantBlock( + [ 'kind' => 'position', 'value' => 'first' ], + [ [ 'name' => 'core/post-content', 'attributes' => [], 'innerBlocks' => [] ] ] + ); + + // Map says: at loop index 2, use variant #0. Without the map, + // position:first would only match index 0, so map presence is + // observable in output. + $inlined = $this->inliner->inline( [ + queryWithVariants( $base, [ $variant ], [ 2 => 0 ] ), + ] ); + + $items = $inlined[0]['innerBlocks'][0]['innerBlocks']; + + expect( $items[0]['innerBlocks'][0]['name'] )->toBe( 'core/post-content' ) // matched via walk + ->and( $items[1]['innerBlocks'][0]['name'] )->toBe( 'core/post-title' ) + ->and( $items[2]['innerBlocks'][0]['name'] )->toBe( 'core/post-content' ); // matched via map +} ); + +it( 'breaks ties between same-tier variants on priority ascending then document order', function () { + $this->fake->setItems( [ + variantPostFixture( 1, 'A' ), + ] ); + + $base = [ [ 'name' => 'core/post-title', 'attributes' => [], 'innerBlocks' => [] ] ]; + + $higherPriority = variantBlock( + [ 'kind' => 'position', 'value' => 'first' ], + [ [ 'name' => 'core/post-excerpt', 'attributes' => [], 'innerBlocks' => [] ] ], + 20 + ); + $lowerPriority = variantBlock( + [ 'kind' => 'position', 'value' => 'first' ], + [ [ 'name' => 'core/post-content', 'attributes' => [], 'innerBlocks' => [] ] ], + 5 + ); + + // Both match index 0; lowerPriority (priority=5) wins. + $inlined = $this->inliner->inline( [ + queryWithVariants( $base, [ $higherPriority, $lowerPriority ] ), + ] ); + + $items = $inlined[0]['innerBlocks'][0]['innerBlocks']; + + expect( $items[0]['innerBlocks'][0]['name'] )->toBe( 'core/post-content' ); +} );