From 6990bfd5fbaced6ff281b7bb3370e6e7aca4b8a2 Mon Sep 17 00:00:00 2001 From: mt8 Date: Tue, 28 Apr 2026 20:43:34 +0900 Subject: [PATCH] feat: add `eq` / `in` processors and make WhenRenderer whitespace-tolerant (#31) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Equality predicates against bindings used to require the verbose `map:value=1,*=` idiom. Two new processors make the common single-value and multi-value match cases direct: {{post_raw.post_status|eq:trash}} # was: |map:trash=1,*= {{post_raw.post_status|in:trash,draft}} # was: |map:trash=1,draft=1,*= The `eq` / `in` processors return the input unchanged when the match holds, and an empty string otherwise. They compose with the rest of the pipeline and gate `feedwright/when` directly. Negative match is still expressed via `feedwright/when negate=true`, so we deliberately do not add `neq` / `not_in`. Bundled with this: WhenRenderer's truthiness check now uses `'' !== trim( $value )`, so a stray trailing space in the expression (common typo class — observed in real local testing) no longer flips the gate to always-true. - src/Bindings/Resolver.php: process_eq + process_in registered as built-in processors - src/Renderer/WhenRenderer.php: trim() before the empty check - blocks/_shared/processor-suggestions.js: eq / in surfaced in the | autocomplete with arg hints - Unit tests: eq / in equality, trim, list semantics, empty-arg edge - Integration tests: when block with eq sanity-checks trashed-post gating, and a regression test that whitespace-only expressions stay closed - docs/requirements.md §13.6.2 (whitespace tolerance) + §14.6.1 (processor table) - ja translation strings added --- blocks/_shared/processor-suggestions.js | 12 +++++ docs/requirements.md | 8 ++- ...t-ja-093f72868b125390d00428ed7df28073.json | 6 +++ languages/feedwright-ja.mo | Bin 19524 -> 19860 bytes languages/feedwright-ja.po | 8 +++ languages/feedwright.pot | 10 +++- src/Bindings/Resolver.php | 38 +++++++++++++++ src/Renderer/WhenRenderer.php | 6 ++- tests/Integration/RenderTest.php | 46 ++++++++++++++++++ tests/Unit/BindingResolverTest.php | 42 ++++++++++++++++ 10 files changed, 171 insertions(+), 5 deletions(-) diff --git a/blocks/_shared/processor-suggestions.js b/blocks/_shared/processor-suggestions.js index 929c984..f83bc26 100644 --- a/blocks/_shared/processor-suggestions.js +++ b/blocks/_shared/processor-suggestions.js @@ -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', + }, ]; /** diff --git a/docs/requirements.md b/docs/requirements.md index fecda3e..0c4a3d5 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -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 []; @@ -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. @@ -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 `` 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. diff --git a/languages/feedwright-ja-093f72868b125390d00428ed7df28073.json b/languages/feedwright-ja-093f72868b125390d00428ed7df28073.json index c23581e..7bdfb65 100644 --- a/languages/feedwright-ja-093f72868b125390d00428ed7df28073.json +++ b/languages/feedwright-ja-093f72868b125390d00428ed7df28073.json @@ -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" ] } } diff --git a/languages/feedwright-ja.mo b/languages/feedwright-ja.mo index 83ca7a0dcbec65303f1af56e7d52c9d2947c6f8f..c6643815cac75c99fc2c7c4806345dfb3ea80e6b 100644 GIT binary patch delta 4095 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" diff --git a/src/Bindings/Resolver.php b/src/Bindings/Resolver.php index ac62eb6..38e2f81 100644 --- a/src/Bindings/Resolver.php +++ b/src/Bindings/Resolver.php @@ -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' ) ) { @@ -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. * diff --git a/src/Renderer/WhenRenderer.php b/src/Renderer/WhenRenderer.php index 2834cda..c76864b 100644 --- a/src/Renderer/WhenRenderer.php +++ b/src/Renderer/WhenRenderer.php @@ -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 ) { diff --git a/tests/Integration/RenderTest.php b/tests/Integration/RenderTest.php index 7cbda5d..77d5534 100644 --- a/tests/Integration/RenderTest.php +++ b/tests/Integration/RenderTest.php @@ -942,6 +942,52 @@ public function test_when_block_works_at_channel_scope(): void { $this->assertStringContainsString( 'channel-only', $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 = ''; + $when_attrs = wp_json_encode( + // Expression resolves to "" + " " for publish; whitespace-only. + array( 'expression' => '{{post_raw.post_status|eq:trash}} ' ) + ); + $content = '' + . '' + . "{$inner}" + . '' + . ''; + + $feed_post = $this->make_feed_post( $content ); + $xml = ( new Renderer( Plugin::build_resolver() ) )->render( $feed_post )['xml']; + + $this->assertStringNotContainsString( '', $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 = ''; + $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 = '' + . "" + . "{$inner}" + . '' + . ''; + + $feed_post = $this->make_feed_post( $content ); + $xml = ( new Renderer( Plugin::build_resolver() ) )->render( $feed_post )['xml']; + + // Exactly one for the trashed post; live post gets none. + $this->assertSame( 1, substr_count( $xml, '' ) ); + } + public function test_when_blocks_can_nest(): void { self::factory()->post->create( array( 'post_status' => 'publish', 'post_title' => 'Nested' ) ); diff --git a/tests/Unit/BindingResolverTest.php b/tests/Unit/BindingResolverTest.php index 8f727ca..1335a09 100644 --- a/tests/Unit/BindingResolverTest.php +++ b/tests/Unit/BindingResolverTest.php @@ -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() ) ); + } }