Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<n>` /
`range:<from>-<to>`), `pattern` (`odd` / `even` /
`every-nth:<step>[:start:<offset>]`), `meta` (`sticky`, `featured`,
`has-featured-image`, `author:<id>`, `taxonomy:<tax>:<slug>`), and
`custom` (`callback:<name>` → `apve_query_variant_match_<name>`
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,
Expand Down
19 changes: 19 additions & 0 deletions docs/Hooks-and-Events.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,25 @@ See [[post-editor/Livewire Integration]] for the full Livewire recipe and [[post

---

### `apve_query_variant_match_<name>`

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:<name>`.

**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:
Expand Down
106 changes: 106 additions & 0 deletions docs/blocks/Post-Variants.md
Original file line number Diff line number Diff line change
@@ -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:<n1>` | 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:<name>` | Resolved server-side via `apve_query_variant_match_<name>` filter. |

`every-nth:<step>` follows CSS `:nth-child(<step>n)` semantics — the
first match is at position `step` (e.g. `every-nth:3` → posts 3, 6,
9). Add `:start:<offset>` 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.
1 change: 1 addition & 0 deletions packages/renderer-parity.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@
"artisanpack/term-description",
"artisanpack/query",
"artisanpack/post-template",
"artisanpack/post-variant",
"artisanpack/comments",
"artisanpack/comment-template",
"artisanpack/comment-author-avatar",
Expand Down
Original file line number Diff line number Diff line change
@@ -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
13 changes: 13 additions & 0 deletions packages/visual-editor-renderer-react/src/blocks/core/query.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,19 @@ export function PostTemplateBlock({ attributes, children }: BlockRendererProps):
return <ul className={classes}>{children}</ul>;
}

/**
* `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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -207,6 +207,7 @@ const CORE_BLOCKS: Record<string, BlockRenderer> = {
'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,
Expand Down
15 changes: 15 additions & 0 deletions packages/visual-editor-renderer-vue/src/blocks/core/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -206,6 +206,7 @@ const CORE_BLOCKS: Record<string, BlockRenderer> = {
'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,
Expand Down
4 changes: 4 additions & 0 deletions resources/js/visual-editor/blocks/post-template/block.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@
"columns": {
"type": "number",
"default": 3
},
"_compiledVariantMap": {
"type": "object",
"default": {}
}
},
"usesContext": [
Expand Down
40 changes: 40 additions & 0 deletions resources/js/visual-editor/blocks/post-variant/block.json
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading
Loading