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
12 changes: 12 additions & 0 deletions blocks/_shared/processor-suggestions.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,18 @@ const PROCESSORS = [
takesArg: true,
argHint: '99',
},
{
name: 'eq',
label: __( 'eq — keep value when it equals the argument, else empty', 'feedwright' ),
takesArg: true,
argHint: 'trash',
},
{
name: 'in',
label: __( 'in — keep value when it appears in the comma-separated list, else empty', 'feedwright' ),
takesArg: true,
argHint: 'trash,draft',
},
];

/**
Expand Down
8 changes: 6 additions & 2 deletions docs/requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -1254,7 +1254,9 @@ public function render( array $block, Context $ctx ): array {
$negate = ! empty( $block['attrs']['negate'] );

$value = '' === $expression ? '' : $this->resolver->resolve( $expression, $ctx );
$gate = '' !== $value;
// Whitespace-only counts as empty so a stray trailing space doesn't
// flip the gate to always-true.
$gate = '' !== trim( $value );
if ( $negate ) $gate = ! $gate;

if ( ! $gate ) return [];
Expand All @@ -1269,7 +1271,7 @@ public function render( array $block, Context $ctx ): array {
}
```

The "non-empty string" truthiness rule is intentional and works well with the existing processor chain — `map`, `default`, `first` all produce strings. For more elaborate predicates (numeric comparisons etc.), users compose them via processors rather than introducing a new condition DSL.
The "non-empty string" truthiness rule is intentional and works well with the existing processor chain — `map`, `default`, `first`, `eq`, `in` all produce strings. For more elaborate predicates (numeric comparisons etc.), users compose them via processors rather than introducing a new condition DSL. The check uses `trim()` so whitespace-only output is treated as empty (a common typo class — trailing space on the expression).

`feedwright/when` does not switch context; it inherits the current item / sub-item / channel scope from its position in the block tree. Item-level expressions inside a channel-scoped `when` will resolve to empty because there is no current post — that is the correct degraded behavior, not an error.

Expand Down Expand Up @@ -1597,6 +1599,8 @@ You can append `|name:arg` to the end of a binding to apply transforms to the re
| `map` | `key=val,*=fallback` | If the input matches `key`, return that line's `val`. `*` is the fallback if no key matches. If neither matches nor `*` exists, returns empty string. The first `=` separates key and val, so `=` may appear in val. Useful for conditionals such as mapping `post_status` to a numeric `<media:status>` flag (publish=1 / removed=0): `{{post_raw.post_status\|map:publish=1,*=0}}` |
| `first` | separator (default `, `) | Returns the first segment of a separator-joined string. Pairs naturally with `post_term.{taxonomy}` (which joins multi-term posts with `", "`) before piping into `map`. Example: `{{post_term.category\|first\|map:Tech=10,News=20}}`. The separator argument cannot contain `\|` (pipe-syntax conflict) or `}`; to split on `\|`, change the upstream binding to emit a different separator via its modifier first |
| `default` | replacement value | Returns the literal argument when the input is the empty string; otherwise passes the input through unchanged. Differs from `map`'s `*` in that it triggers only on empty input — `"0"` and other falsy-looking strings pass through. Idiomatic with `post_term_meta`: `{{post_term_meta.category._mediba_category_id\|default:99}}` |
| `eq` | comparison value | Equality gate: returns the input unchanged when it equals the argument, empty string otherwise. Argument is trimmed. Composes naturally with `feedwright/when` (which treats non-empty as truthy). Example: `{{post_raw.post_status\|eq:trash}}` |
| `in` | comma-separated list | Multi-value equality gate: returns the input when it appears in the list, empty string otherwise. Each list entry is trimmed. Example: `{{post_raw.post_status\|in:trash,draft}}` |

Unknown processor names log a warning and pass the input through unchanged.

Expand Down
6 changes: 6 additions & 0 deletions languages/feedwright-ja-093f72868b125390d00428ed7df28073.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@
],
"default \u2014 replace empty input with a literal value": [
"default \u2014 \u5165\u529b\u304c\u7a7a\u306e\u3068\u304d\u306b literal \u3067\u5024\u3092\u7f6e\u304d\u63db\u3048\u308b"
],
"eq \u2014 keep value when it equals the argument, else empty": [
"eq \u2014 \u5f15\u6570\u3068\u4e00\u81f4\u3059\u308b\u3068\u304d\u3060\u3051\u5024\u3092\u6b8b\u3059\u3001\u9055\u3048\u3070\u7a7a"
],
"in \u2014 keep value when it appears in the comma-separated list, else empty": [
"in \u2014 \u30ab\u30f3\u30de\u533a\u5207\u308a\u30ea\u30b9\u30c8\u306e\u3044\u305a\u308c\u304b\u3068\u4e00\u81f4\u3059\u308b\u3068\u304d\u3060\u3051\u5024\u3092\u6b8b\u3059\u3001\u9055\u3048\u3070\u7a7a"
]
}
}
Expand Down
Binary file modified languages/feedwright-ja.mo
Binary file not shown.
8 changes: 8 additions & 0 deletions languages/feedwright-ja.po
Original file line number Diff line number Diff line change
Expand Up @@ -1051,3 +1051,11 @@ msgstr "条件"
msgctxt "block keyword"
msgid "filter"
msgstr "フィルタ"

#: blocks/_shared/processor-suggestions.js:49
msgid "eq — keep value when it equals the argument, else empty"
msgstr "eq — 引数と一致するときだけ値を残す、違えば空"

#: blocks/_shared/processor-suggestions.js:55
msgid "in — keep value when it appears in the comma-separated list, else empty"
msgstr "in — カンマ区切りリストのいずれかと一致するときだけ値を残す、違えば空"
10 changes: 9 additions & 1 deletion languages/feedwright.pot
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ msgstr ""
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"POT-Creation-Date: 2026-04-28T10:39:48+00:00\n"
"POT-Creation-Date: 2026-04-28T10:55:40+00:00\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"X-Generator: WP-CLI 2.12.0\n"
"X-Domain: feedwright\n"
Expand Down Expand Up @@ -820,6 +820,14 @@ msgstr ""
msgid "default — replace empty input with a literal value"
msgstr ""

#: blocks/_shared/processor-suggestions.js:49
msgid "eq — keep value when it equals the argument, else empty"
msgstr ""

#: blocks/_shared/processor-suggestions.js:55
msgid "in — keep value when it appears in the comma-separated list, else empty"
msgstr ""

#: blocks/channel/block.json
msgctxt "block title"
msgid "<channel>"
Expand Down
38 changes: 38 additions & 0 deletions src/Bindings/Resolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ public function __construct() {
'map' => array( self::class, 'process_map' ),
'first' => array( self::class, 'process_first' ),
'default' => array( self::class, 'process_default' ),
'eq' => array( self::class, 'process_eq' ),
'in' => array( self::class, 'process_in' ),
);

if ( function_exists( 'apply_filters' ) ) {
Expand Down Expand Up @@ -248,6 +250,42 @@ public static function process_first( string $value, string $arg ): string {
return false === $pos ? $value : substr( $value, 0, $pos );
}

/**
* Equality gate: return the input unchanged when it equals the argument,
* empty string otherwise. Combines naturally with `feedwright/when`,
* which treats any non-empty value as a truthy gate.
*
* Argument leading / trailing whitespace is trimmed (parity with `map`).
*
* Example:
* {{post_raw.post_status|eq:trash}} -> "trash" when status is trash, else ""
*
* @param string $value Input value.
* @param string $arg Value to compare against.
*/
public static function process_eq( string $value, string $arg ): string {
return trim( $arg ) === $value ? $value : '';
}

/**
* Multi-value equality gate: return the input unchanged when it appears
* in the comma-separated argument list, empty string otherwise. Each list
* entry is trimmed.
*
* Example:
* {{post_raw.post_status|in:trash,draft}} -> "trash" or "draft", else ""
*
* @param string $value Input value.
* @param string $arg Comma-separated list of accepted values.
*/
public static function process_in( string $value, string $arg ): string {
if ( '' === $arg ) {
return '';
}
$candidates = array_map( 'trim', explode( ',', $arg ) );
return in_array( $value, $candidates, true ) ? $value : '';
}

/**
* Replace an empty string input with the literal argument.
*
Expand Down
6 changes: 4 additions & 2 deletions src/Renderer/WhenRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,10 @@ public function render( array $block, Context $ctx ): array {
$expression = (string) ( $attrs['expression'] ?? '' );
$negate = ! empty( $attrs['negate'] );

$value = '' === $expression ? '' : $this->resolver->resolve( $expression, $ctx );
$matches = '' !== $value;
$value = '' === $expression ? '' : $this->resolver->resolve( $expression, $ctx );
// Whitespace-only resolution counts as empty: a stray trailing space
// in the expression must not flip the gate to always-true.
$matches = '' !== trim( $value );
$gate_open = $negate ? ! $matches : $matches;

if ( ! $gate_open ) {
Expand Down
46 changes: 46 additions & 0 deletions tests/Integration/RenderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -942,6 +942,52 @@ public function test_when_block_works_at_channel_scope(): void {
$this->assertStringContainsString( '<hint>channel-only</hint>', $xml );
}

public function test_when_block_treats_whitespace_only_value_as_empty(): void {
// Stray trailing space in the expression used to flip the gate to
// always-true: '{{...|eq:trash}} ' resolved to ' ' (a space) for
// non-trash posts, which was non-empty. Ensure trim() makes it empty.
self::factory()->post->create( array( 'post_status' => 'publish', 'post_title' => 'Live Post' ) );

$inner = '<!-- wp:feedwright/element {"tagName":"deleted","contentMode":"empty"} /-->';
$when_attrs = wp_json_encode(
// Expression resolves to "" + " " for publish; whitespace-only.
array( 'expression' => '{{post_raw.post_status|eq:trash}} ' )
);
$content = '<!-- wp:feedwright/rss --><!-- wp:feedwright/channel -->'
. '<!-- wp:feedwright/item-query {"postsPerPage":1} --><!-- wp:feedwright/item -->'
. "<!-- wp:feedwright/when {$when_attrs} -->{$inner}<!-- /wp:feedwright/when -->"
. '<!-- /wp:feedwright/item --><!-- /wp:feedwright/item-query -->'
. '<!-- /wp:feedwright/channel --><!-- /wp:feedwright/rss -->';

$feed_post = $this->make_feed_post( $content );
$xml = ( new Renderer( Plugin::build_resolver() ) )->render( $feed_post )['xml'];

$this->assertStringNotContainsString( '<deleted/>', $xml );
}

public function test_when_block_uses_eq_processor_for_status_match(): void {
// Sanity: the recommended idiom `{{post_raw.post_status|eq:trash}}`
// gates correctly on a single trashed post amid live ones.
self::factory()->post->create( array( 'post_status' => 'publish', 'post_title' => 'Live' ) );
$dead = self::factory()->post->create( array( 'post_status' => 'publish', 'post_title' => 'Dead' ) );
wp_trash_post( $dead );

$inner = '<!-- wp:feedwright/element {"tagName":"deleted","contentMode":"empty"} /-->';
$when_attrs = wp_json_encode( array( 'expression' => '{{post_raw.post_status|eq:trash}}' ) );
$query_attrs = wp_json_encode( array( 'postsPerPage' => 10, 'postStatus' => array( 'publish', 'trash' ) ) );
$content = '<!-- wp:feedwright/rss --><!-- wp:feedwright/channel -->'
. "<!-- wp:feedwright/item-query {$query_attrs} --><!-- wp:feedwright/item -->"
. "<!-- wp:feedwright/when {$when_attrs} -->{$inner}<!-- /wp:feedwright/when -->"
. '<!-- /wp:feedwright/item --><!-- /wp:feedwright/item-query -->'
. '<!-- /wp:feedwright/channel --><!-- /wp:feedwright/rss -->';

$feed_post = $this->make_feed_post( $content );
$xml = ( new Renderer( Plugin::build_resolver() ) )->render( $feed_post )['xml'];

// Exactly one <deleted/> for the trashed post; live post gets none.
$this->assertSame( 1, substr_count( $xml, '<deleted/>' ) );
}

public function test_when_blocks_can_nest(): void {
self::factory()->post->create( array( 'post_status' => 'publish', 'post_title' => 'Nested' ) );

Expand Down
42 changes: 42 additions & 0 deletions tests/Unit/BindingResolverTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -270,4 +270,46 @@ public function test_chain_falls_through_to_default_when_map_missing(): void {
$r->resolve( '{{foo.k|first|map:お役立ち=91,芸能=92|default:99}}', $this->ctx() )
);
}

public function test_eq_processor_returns_value_when_equal(): void {
$r = new Resolver();
$r->add( new FakeProvider( 'foo', array( 'k' => 'trash' ) ) );
$this->assertSame( 'trash', $r->resolve( '{{foo.k|eq:trash}}', $this->ctx() ) );
}

public function test_eq_processor_returns_empty_when_not_equal(): void {
$r = new Resolver();
$r->add( new FakeProvider( 'foo', array( 'k' => 'publish' ) ) );
$this->assertSame( '', $r->resolve( '{{foo.k|eq:trash}}', $this->ctx() ) );
}

public function test_eq_processor_trims_argument_whitespace(): void {
$r = new Resolver();
$r->add( new FakeProvider( 'foo', array( 'k' => 'trash' ) ) );
$this->assertSame( 'trash', $r->resolve( '{{foo.k|eq: trash }}', $this->ctx() ) );
}

public function test_in_processor_returns_value_when_in_list(): void {
$r = new Resolver();
$r->add( new FakeProvider( 'foo', array( 'k' => 'draft' ) ) );
$this->assertSame( 'draft', $r->resolve( '{{foo.k|in:trash,draft,pending}}', $this->ctx() ) );
}

public function test_in_processor_returns_empty_when_not_in_list(): void {
$r = new Resolver();
$r->add( new FakeProvider( 'foo', array( 'k' => 'publish' ) ) );
$this->assertSame( '', $r->resolve( '{{foo.k|in:trash,draft}}', $this->ctx() ) );
}

public function test_in_processor_trims_each_list_entry(): void {
$r = new Resolver();
$r->add( new FakeProvider( 'foo', array( 'k' => 'draft' ) ) );
$this->assertSame( 'draft', $r->resolve( '{{foo.k|in: trash , draft , pending }}', $this->ctx() ) );
}

public function test_in_processor_with_empty_list_returns_empty(): void {
$r = new Resolver();
$r->add( new FakeProvider( 'foo', array( 'k' => 'anything' ) ) );
$this->assertSame( '', $r->resolve( '{{foo.k|in:}}', $this->ctx() ) );
}
}
Loading