diff --git a/README.md b/README.md index 102bd84..b8a5125 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,9 @@ wp block # List all registered block types $ wp block type list + # Find posts by block usage + $ wp block search --block=core/button + # Get a specific block pattern $ wp block pattern get my-theme/hero @@ -38,6 +41,159 @@ wp block +### wp block search + +Searches posts for block usage. + +~~~ +wp block search [--block=] [--block-namespace=] [--style=] [--pattern=] [--pattern-namespace=] [--synced-pattern=] [--limit=] [--=] [--field=] [--fields=] [--format=] +~~~ + +Returns matching posts where the requested block appears anywhere in the +parsed block tree, including nested blocks. + +To reduce unnecessary parsing on large datasets, the command first applies a +coarse `post_content` prefilter when a safe marker is available, then +confirms matches by parsing blocks. Candidate posts are loaded in batches to +keep memory usage low. A plain `--block` search skips parsing entirely unless +the `occurrences` field is requested. + +At least one search filter is required: `--block`, `--block-namespace`, +`--style`, `--pattern`, `--pattern-namespace`, or `--synced-pattern`. + +Pattern filters match only blocks whose own `metadata.patternName` matches. + +The `--synced-pattern` filter matches reusable block references by synced +pattern post ID, and cannot be combined with `--block` or `--block-namespace`. + +**OPTIONS** + + [--block=] + Block type name to search for (for example, 'core/paragraph'). A name without a namespace is treated as a core block. + + [--block-namespace=] + Limit matches to blocks within a specific namespace (for example, 'core'). + + [--style=] + Limit matches to blocks using a specific block style. + + [--pattern=] + Limit matches to blocks embedded from a specific pattern (for example, 'twentytwentyfive/event-rsvp'). + + [--pattern-namespace=] + Limit matches to blocks embedded from patterns within a specific namespace (for example, 'twentytwentyfive'). + + [--synced-pattern=] + Limit matches to reusable block references for a specific synced pattern post ID. + + [--limit=] + Stop after this many matching posts, in query order (post ID ascending unless + `--orderby` is given). Unlike `--posts_per_page`, which limits the candidate + posts examined, this counts matches. Cannot be combined with `--paged`. + + [--=] + One or more args to pass to WP_Query. The default `post_type` is `any`, + which excludes `wp_block` and other post types that are not searchable. + Pass `--post_type=wp_block` to search them explicitly. + + Results are complete by default; candidate posts are processed in batches + internally. Pass `--posts_per_page=` and `--paged=` to search only + that window of candidate posts. + + Content inside synced patterns is not searched through `core/block` + references, so a block that only exists inside a synced pattern is not + found in the posts that use that pattern. + + [--field=] + Prints the value of a single field for each matching post. + + [--fields=] + Limit the output to specific result fields. + + [--format=] + Render output in a particular format. + --- + default: table + options: + - table + - csv + - json + - count + - yaml + - ids + --- + +**AVAILABLE FIELDS** + +These fields will be displayed by default for each matching post: + +* ID +* post_title +* post_name +* post_date +* post_status + +These fields are optionally available: + +* post_type +* url +* occurrences + +**EXAMPLES** + + # Find posts using the paragraph block. + $ wp block search --block=core/paragraph + + # Find any post type posts using the heading block. + $ wp block search --block=core/heading --post_type=any + + # Find posts using any block in namespace like 'core'. + $ wp block search --block-namespace=core + + # Find posts using any blocks with block style of 'rounded'. + $ wp block search --style=rounded + + # Find posts using certain block with a specific style. + $ wp block search --block=core/image --style=rounded + + # Search a namespace with a specific style. + $ wp block search --block-namespace=core --style=rounded + + # Search published pages for rounded images. + $ wp block search --block=core/image --style=rounded --post_type=page --post_status=publish + + # Find posts using blocks embedded from a specific pattern. + $ wp block search --pattern=twentytwentyfive/event-rsvp + + # Find posts using blocks from patterns in a specific namespace. + $ wp block search --pattern-namespace=twentytwentyfive + + # Find posts using a specific synced pattern. + $ wp block search --synced-pattern=123 + + # Show selected fields as JSON for further processing. + $ wp block search --block=core/heading --post_status=publish --fields=ID,post_type,occurrences --format=json + + # Limit the candidate posts scanned with a native query argument. + $ wp block search --block=core/paragraph --showposts=50 --format=ids + + # Search only the second window of 1000 candidate posts. + $ wp block search --block=core/paragraph --posts_per_page=1000 --paged=2 --format=ids + + # Restrict search to specific posts and return the count. + $ wp block search --style=rounded --post__in=21,42,84 --format=count + + # Return the first 20 posts using the paragraph block. + $ wp block search --block=core/paragraph --limit=20 --format=ids + + # Return only matching post IDs. + $ wp block search --block=core/paragraph --format=ids + + # Return count of matching posts. + $ wp block search --block=core/heading --format=count + + + ### wp block type Retrieves details on registered block types. diff --git a/block-command.php b/block-command.php index 83f6d0f..8b668ad 100644 --- a/block-command.php +++ b/block-command.php @@ -54,3 +54,4 @@ WP_CLI::add_command( 'block binding', WP_CLI\Block\Block_Binding_Command::class, [ 'before_invoke' => $wpcli_block_before_invoke_6_5 ] ); WP_CLI::add_command( 'block template', WP_CLI\Block\Block_Template_Command::class, [ 'before_invoke' => $wpcli_block_before_invoke_5_9 ] ); WP_CLI::add_command( 'block synced-pattern', WP_CLI\Block\Block_Synced_Pattern_Command::class, [ 'before_invoke' => $wpcli_block_before_invoke_5_0 ] ); +WP_CLI::add_command( 'block search', WP_CLI\Block\Block_Search_Command::class, [ 'before_invoke' => $wpcli_block_before_invoke_5_0 ] ); diff --git a/composer.json b/composer.json index 312ae18..5fb9dec 100644 --- a/composer.json +++ b/composer.json @@ -9,6 +9,7 @@ "wp-cli/wp-cli": "^3.0" }, "require-dev": { + "wp-cli/entity-command": "^2 || ^3", "wp-cli/extension-command": "^2 || ^3", "wp-cli/wp-cli-tests": "^5" }, @@ -29,6 +30,7 @@ "bundled": true, "commands": [ "block", + "block search", "block type", "block type exists", "block type list", diff --git a/features/block-search.feature b/features/block-search.feature new file mode 100644 index 0000000..c70ea3c --- /dev/null +++ b/features/block-search.feature @@ -0,0 +1,593 @@ +@require-wp-5.0 +Feature: Search posts by block usage + + Background: + Given a WP install + + Scenario: Search posts by block type usage + Given a quote-post.html file: + """ +

Hello world

+ """ + When I run `wp post create quote-post.html --post_type=post --post_title='Quote Post' --post_status=publish --porcelain` + Then STDOUT should be a number + And save STDOUT as {QUOTE_POST_ID} + + When I run `wp block search --block=core/quote --post_type=post --field=ID` + Then STDOUT should be: + """ + {QUOTE_POST_ID} + """ + + Scenario: Search posts by block namespace + Given a core-namespace-post.html file: + """ +

Hello core

+ """ + When I run `wp post create core-namespace-post.html --post_type=post --post_title='Core Namespace Post' --post_status=publish --porcelain` + Then STDOUT should be a number + And save STDOUT as {CORE_NAMESPACE_POST_ID} + + When I run `wp block search --block-namespace=core --post_type=post --field=ID` + Then STDOUT should contain: + """ + {CORE_NAMESPACE_POST_ID} + """ + + Scenario: Search posts by custom block namespace + Given a custom-namespace-post.html file: + """ +
Hello custom
+ """ + When I run `wp post create custom-namespace-post.html --post_type=post --post_title='Custom Namespace Post' --post_status=publish --porcelain` + Then STDOUT should be a number + And save STDOUT as {CUSTOM_NAMESPACE_POST_ID} + + When I run `wp block search --block-namespace=my-plugin --post_type=post --field=ID` + Then STDOUT should be: + """ + {CUSTOM_NAMESPACE_POST_ID} + """ + + Scenario: Search nested block usage + Given a nested-image-post.html file: + """ +
+ """ + When I run `wp post create nested-image-post.html --post_type=page --post_title='Nested Image Post' --post_status=publish --porcelain` + Then STDOUT should be a number + And save STDOUT as {NESTED_POST_ID} + + When I run `wp block search --block=core/image --post_type=page --field=ID` + Then STDOUT should be: + """ + {NESTED_POST_ID} + """ + + Scenario: Search block usage by style + Given a rounded-image-post.html file: + """ +
+ """ + When I run `wp post create rounded-image-post.html --post_type=post --post_title='Rounded Image Post' --post_status=publish --porcelain` + Then STDOUT should be a number + And save STDOUT as {ROUNDED_IMAGE_POST_ID} + + Given a plain-image-post.html file: + """ +
+ """ + When I run `wp post create plain-image-post.html --post_type=post --post_title='Plain Image Post' --post_status=publish --porcelain` + Then STDOUT should be a number + + Given a rounded-quote-post.html file: + """ +

Hello quote

+ """ + When I run `wp post create rounded-quote-post.html --post_type=post --post_title='Rounded Quote Post' --post_status=publish --porcelain` + Then STDOUT should be a number + And save STDOUT as {ROUNDED_QUOTE_POST_ID} + + When I run `wp block search --block=core/image --style=rounded --field=ID` + Then STDOUT should be: + """ + {ROUNDED_IMAGE_POST_ID} + """ + + When I run `wp block search --style=rounded --field=ID` + Then STDOUT should contain: + """ + {ROUNDED_IMAGE_POST_ID} + """ + + And STDOUT should contain: + """ + {ROUNDED_QUOTE_POST_ID} + """ + + Scenario: Style search ignores plain text false positives + When I run `wp post create --post_type=post --post_title='Style Token Plain Text Post' --post_status=publish --post_content='This post mentions is-style-rounded in plain text only.' --porcelain` + Then STDOUT should be a number + And save STDOUT as {STYLE_TOKEN_TEXT_POST_ID} + + When I run `wp block search --style=rounded --field=ID --format=ids` + Then STDOUT should not contain: + """ + {STYLE_TOKEN_TEXT_POST_ID} + """ + + Scenario: Search block usage count + Given a counted-quote-one.html file: + """ +

One

+ """ + When I run `wp post create counted-quote-one.html --post_type=post --post_title='Counted Quote One' --post_status=publish --porcelain` + Then STDOUT should be a number + And save STDOUT as {COUNTED_QUOTE_ONE_ID} + + Given a counted-quote-two.html file: + """ +

Two

+ """ + When I run `wp post create counted-quote-two.html --post_type=post --post_title='Counted Quote Two' --post_status=publish --porcelain` + Then STDOUT should be a number + And save STDOUT as {COUNTED_QUOTE_TWO_ID} + + When I run `wp block search --block=core/quote --post_type=post --format=count` + Then STDOUT should be: + """ + 2 + """ + + When I run `wp block search --block=core/quote --post__in={COUNTED_QUOTE_ONE_ID},{COUNTED_QUOTE_TWO_ID} --format=ids` + Then STDOUT should contain: + """ + {COUNTED_QUOTE_ONE_ID} + """ + + And STDOUT should contain: + """ + {COUNTED_QUOTE_TWO_ID} + """ + + Scenario: Search block usage with selected fields in JSON + Given a double-quote-post.html file: + """ +

One

Two

+ """ + When I run `wp post create double-quote-post.html --post_type=post --post_title='Double Quote Post' --post_status=publish --porcelain` + Then STDOUT should be a number + And save STDOUT as {DOUBLE_QUOTE_POST_ID} + + When I run `wp block search --block=core/quote --post__in={DOUBLE_QUOTE_POST_ID} --fields=ID,occurrences --format=json` + Then STDOUT should be JSON containing: + """ + [{"ID":{DOUBLE_QUOTE_POST_ID},"occurrences":2}] + """ + + Scenario: Search block usage with WP_Query pagination arguments + Given a paged-first-post.html file: + """ +

First paged match

+ """ + When I run `wp post create paged-first-post.html --post_type=post --post_title='A Paged Match' --post_status=publish --porcelain` + Then STDOUT should be a number + And save STDOUT as {PAGED_FIRST_POST_ID} + + Given a paged-second-post.html file: + """ +

Second paged match

+ """ + When I run `wp post create paged-second-post.html --post_type=post --post_title='B Paged Match' --post_status=publish --porcelain` + Then STDOUT should be a number + And save STDOUT as {PAGED_SECOND_POST_ID} + + When I run `wp block search --block=core/quote --post_type=post --orderby=title --order=ASC --posts_per_page=1 --paged=1 --field=ID` + Then STDOUT should be: + """ + {PAGED_FIRST_POST_ID} + """ + + When I run `wp block search --block=core/quote --post_type=post --orderby=title --order=ASC --posts_per_page=1 --paged=2 --field=ID` + Then STDOUT should be: + """ + {PAGED_SECOND_POST_ID} + """ + + Scenario: Pagination without orderby is stable for posts sharing a date + When I run `wp eval 'foreach ( [ 1, 2, 3 ] as $i ) { wp_insert_post( [ "post_title" => "Same date $i", "post_status" => "publish", "post_date" => "2024-01-01 00:00:00", "post_content" => "" ] ); } echo "done";'` + Then STDOUT should be: + """ + done + """ + + When I run `wp block search --block=my-plugin/stable --post_type=post --field=ID` + Then save STDOUT as {ALL_IDS} + + When I run `wp block search --block=my-plugin/stable --post_type=post --posts_per_page=1 --paged=1 --field=ID` + Then save STDOUT as {PAGE_ONE_ID} + + When I run `wp block search --block=my-plugin/stable --post_type=post --posts_per_page=1 --paged=2 --field=ID` + Then save STDOUT as {PAGE_TWO_ID} + + When I run `wp block search --block=my-plugin/stable --post_type=post --posts_per_page=1 --paged=3 --field=ID` + Then save STDOUT as {PAGE_THREE_ID} + + When I run `wp block search --block=my-plugin/stable --post_type=post --post__in={PAGE_ONE_ID},{PAGE_TWO_ID},{PAGE_THREE_ID} --format=count` + Then STDOUT should be: + """ + 3 + """ + + Scenario: Block search without occurrences does not match blocks sharing a name prefix + When I run `wp eval 'wp_insert_post( [ "post_title" => "Exact", "post_status" => "publish", "post_content" => "" ] ); wp_insert_post( [ "post_title" => "Prefix only", "post_status" => "publish", "post_content" => "" ] ); echo "done";'` + Then STDOUT should be: + """ + done + """ + + When I run `wp block search --block=my-plugin/prefix --post_type=post --field=post_title` + Then STDOUT should be: + """ + Exact + """ + + When I run `wp block search --block=my-plugin/prefix --post_type=post --fields=post_title,occurrences --format=csv` + Then STDOUT should be: + """ + post_title,occurrences + Exact,1 + """ + + Scenario: Offset without a page size skips posts and returns the rest + When I run `wp eval 'foreach ( [ 1, 2, 3 ] as $i ) { wp_insert_post( [ "post_title" => "Offset $i", "post_status" => "publish", "post_content" => "" ] ); } echo "done";'` + Then STDOUT should be: + """ + done + """ + + When I run `wp block search --block=my-plugin/offset --post_type=post --offset=1 --format=count` + Then STDOUT should be: + """ + 2 + """ + + Scenario: Prefilter finds explicitly namespaced core blocks and spaced JSON attributes + When I run `wp eval 'wp_insert_post( [ "post_title" => "Explicit core", "post_status" => "publish", "post_content" => "" ] ); wp_insert_post( [ "post_title" => "Spaced ref", "post_status" => "publish", "post_content" => "" ] ); wp_insert_post( [ "post_title" => "Spaced pattern", "post_status" => "publish", "post_content" => "

x

" ] ); echo "done";'` + Then STDOUT should be: + """ + done + """ + + When I run `wp block search --block=core/image --post_type=post --field=post_title` + Then STDOUT should be: + """ + Explicit core + """ + + When I run `wp block search --synced-pattern=4242 --post_type=post --field=post_title` + Then STDOUT should be: + """ + Spaced ref + """ + + When I run `wp block search --pattern=my-theme/spaced --post_type=post --field=post_title` + Then STDOUT should be: + """ + Spaced pattern + """ + + Scenario: Prefilter finds pattern and synced pattern attributes with unusual JSON spacing + When I run `wp eval 'wp_insert_post( [ "post_title" => "Wide ref", "post_status" => "publish", "post_content" => "" ] ); wp_insert_post( [ "post_title" => "Other ref", "post_status" => "publish", "post_content" => "" ] ); wp_insert_post( [ "post_title" => "Wide pattern", "post_status" => "publish", "post_content" => "

x

" ] ); echo "done";'` + Then STDOUT should be: + """ + done + """ + + When I run `wp block search --synced-pattern=5151 --post_type=post --field=post_title` + Then STDOUT should be: + """ + Wide ref + """ + + When I run `wp block search --pattern=my-theme/wide --post_type=post --field=post_title` + Then STDOUT should be: + """ + Wide pattern + """ + + When I run `wp block search --pattern-namespace=my-theme --post_type=post --field=post_title` + Then STDOUT should be: + """ + Wide pattern + """ + + Scenario: Style names that are escaped when serialized are still found + When I run `wp eval 'wp_insert_post( wp_slash( [ "post_title" => "Escaped style", "post_status" => "publish", "post_content" => serialize_block( [ "blockName" => "core/paragraph", "attrs" => [ "className" => "is-style-a&b" ], "innerBlocks" => [], "innerHTML" => "

x

", "innerContent" => [ "

x

" ] ] ) ] ) ); echo "done";'` + Then STDOUT should be: + """ + done + """ + + When I run `wp block search --style='a&b' --post_type=post --field=post_title` + Then STDOUT should be: + """ + Escaped style + """ + + Scenario: Search blocks embedded from a specific pattern + Given a pattern-rsvp-post.html file: + """ +

RSVP now

+ """ + When I run `wp post create pattern-rsvp-post.html --post_type=post --post_title='Pattern RSVP Post' --post_status=publish --porcelain` + Then STDOUT should be a number + And save STDOUT as {PATTERN_RSVP_POST_ID} + + Given a pattern-header-post.html file: + """ +

Header block

+ """ + When I run `wp post create pattern-header-post.html --post_type=post --post_title='Pattern Header Post' --post_status=publish --porcelain` + Then STDOUT should be a number + And save STDOUT as {PATTERN_HEADER_POST_ID} + + Given a spaced-pattern-rsvp-post.html file: + """ +

RSVP later

+ """ + When I run `wp post create spaced-pattern-rsvp-post.html --post_type=post --post_title='Pattern RSVP Spaced Post' --post_status=publish --porcelain` + Then STDOUT should be a number + And save STDOUT as {SPACED_PATTERN_RSVP_POST_ID} + + When I run `wp block search --pattern=twentytwentyfive/event-rsvp --field=ID` + Then STDOUT should contain: + """ + {PATTERN_RSVP_POST_ID} + """ + + And STDOUT should contain: + """ + {SPACED_PATTERN_RSVP_POST_ID} + """ + + When I run `wp block search --pattern=twentytwentyfive/event-rsvp --field=ID --format=ids` + Then STDOUT should not contain: + """ + {PATTERN_HEADER_POST_ID} + """ + + When I run `wp block search --pattern-namespace=twentytwentyfive --field=ID` + Then STDOUT should contain: + """ + {PATTERN_RSVP_POST_ID} + """ + + And STDOUT should contain: + """ + {SPACED_PATTERN_RSVP_POST_ID} + """ + + And STDOUT should contain: + """ + {PATTERN_HEADER_POST_ID} + """ + + Scenario: Search pattern blocks with an additional block filter + Given a patterned-image-post.html file: + """ +
+ """ + When I run `wp post create patterned-image-post.html --post_type=post --post_title='Patterned Image Post' --post_status=publish --porcelain` + Then STDOUT should be a number + And save STDOUT as {PATTERN_IMAGE_POST_ID} + + When I run `wp block search --pattern-namespace=twentytwentyfive --block=core/image --field=ID` + Then STDOUT should be: + """ + {PATTERN_IMAGE_POST_ID} + """ + + Scenario: Pattern search counts only blocks carrying the pattern metadata + Given a nested-pattern-image-post.html file: + """ +
+ """ + When I run `wp post create nested-pattern-image-post.html --post_type=post --post_title='Nested Pattern Image Post' --post_status=publish --porcelain` + Then STDOUT should be a number + And save STDOUT as {NESTED_PATTERN_IMAGE_POST_ID} + + When I run `wp block search --pattern=twentytwentyfive/inner-media --field=occurrences --post__in={NESTED_PATTERN_IMAGE_POST_ID}` + Then STDOUT should be: + """ + 1 + """ + + When I run `wp block search --pattern-namespace=twentytwentyfive --field=occurrences --post__in={NESTED_PATTERN_IMAGE_POST_ID}` + Then STDOUT should be: + """ + 2 + """ + + When I run `wp block search --pattern=twentytwentyfive/inner-media --block=core/image --field=ID --format=ids` + Then STDOUT should not contain: + """ + {NESTED_PATTERN_IMAGE_POST_ID} + """ + + Scenario: Search posts by synced pattern reference + Given a pattern.html file: + """ +

Reusable

+ """ + When I run `wp block synced-pattern create pattern.html --title='Reusable Search Pattern' --porcelain` + Then STDOUT should be a number + And save STDOUT as {SYNCED_PATTERN_ID} + + When I run `wp post create --post_type=post --post_title='Reusable Pattern Post' --post_status=publish --porcelain` + Then STDOUT should be a number + And save STDOUT as {SYNCED_PATTERN_POST_ID} + + When I run `wp post block insert {SYNCED_PATTERN_POST_ID} core/block --attrs='{"ref":{SYNCED_PATTERN_ID}}'` + Then STDOUT should contain: + """ + Success: Inserted block into post {SYNCED_PATTERN_POST_ID}. + """ + + Given a different-reusable-pattern-post.html file: + """ +

Not reusable

+ """ + When I run `wp post create different-reusable-pattern-post.html --post_type=post --post_title='Different Reusable Pattern Post' --post_status=publish --porcelain` + Then STDOUT should be a number + + When I run `wp block search --synced-pattern={SYNCED_PATTERN_ID} --field=ID` + Then STDOUT should contain: + """ + {SYNCED_PATTERN_POST_ID} + """ + + Scenario: Pattern and pattern namespace are mutually exclusive + When I try `wp block search --pattern=twentytwentyfive/event-rsvp --pattern-namespace=twentytwentyfive` + Then STDERR should contain: + """ + The --pattern and --pattern-namespace parameters are mutually exclusive. + """ + And the return code should be 1 + + Scenario: Block name and namespace are mutually exclusive + When I try `wp block search --block=core/quote --block-namespace=core` + Then STDERR should contain: + """ + The --block and --block-namespace parameters are mutually exclusive. + """ + And the return code should be 1 + + Scenario: Synced pattern parameter must be a positive integer + When I try `wp block search --synced-pattern=0` + Then STDERR should contain: + """ + The --synced-pattern parameter must be a positive integer post ID. + """ + And the return code should be 1 + + Scenario: Search requires at least one filter + When I try `wp block search` + Then STDERR should contain: + """ + At least one block filter is required: --block, --block-namespace, --style, --pattern, --pattern-namespace, or --synced-pattern. + """ + And the return code should be 1 + + Scenario: Synced pattern parameter rejects malformed IDs + When I try `wp block search --synced-pattern=12abc` + Then STDERR should contain: + """ + The --synced-pattern parameter must be a positive integer post ID. + """ + And the return code should be 1 + + Scenario: Synced pattern cannot be combined with block filters + When I try `wp block search --synced-pattern=5 --block=core/image` + Then STDERR should contain: + """ + The --synced-pattern parameter cannot be combined with --block or --block-namespace. + """ + And the return code should be 1 + + When I try `wp block search --synced-pattern=5 --block-namespace=core` + Then STDERR should contain: + """ + The --synced-pattern parameter cannot be combined with --block or --block-namespace. + """ + And the return code should be 1 + + Scenario: Limit stops after the requested number of matches + When I run `wp eval 'foreach ( [ 1, 2, 3 ] as $i ) { wp_insert_post( [ "post_title" => "Limit $i", "post_status" => "publish", "post_content" => "" ] ); } echo "done";'` + Then STDOUT should be: + """ + done + """ + + When I run `wp block search --block=my-plugin/limit --post_type=post --limit=2 --format=count` + Then STDOUT should be: + """ + 2 + """ + + When I run `wp block search --block=my-plugin/limit --post_type=post --limit=10 --format=count` + Then STDOUT should be: + """ + 3 + """ + + When I run `wp block search --block=my-plugin/limit --post_type=post --limit=1 --field=post_title` + Then STDOUT should be: + """ + Limit 1 + """ + + Scenario: Limit must be a positive integer and cannot be combined with paged + When I try `wp block search --block=core/paragraph --limit=0` + Then STDERR should contain: + """ + The --limit parameter must be a positive integer. + """ + And the return code should be 1 + + When I try `wp block search --block=core/paragraph --limit=5x` + Then STDERR should contain: + """ + The --limit parameter must be a positive integer. + """ + And the return code should be 1 + + When I try `wp block search --block=core/paragraph --limit=5 --paged=2` + Then STDERR should contain: + """ + The --limit parameter cannot be combined with --paged. + """ + And the return code should be 1 + + Scenario: Block name without namespace is treated as a core block + Given a short-name-post.html file: + """ +

Hello

+ """ + When I run `wp post create short-name-post.html --post_type=post --post_title='Short Name Post' --post_status=publish --porcelain` + Then STDOUT should be a number + And save STDOUT as {SHORT_NAME_POST_ID} + + When I run `wp block search --block=paragraph --post_type=post --field=ID` + Then STDOUT should contain: + """ + {SHORT_NAME_POST_ID} + """ + + Scenario: Permalink is only included when the url field is requested + Given a url-post.html file: + """ +

Hello

+ """ + When I run `wp post create url-post.html --post_type=post --post_title='Url Post' --post_status=publish --porcelain` + Then STDOUT should be a number + And save STDOUT as {URL_POST_ID} + + When I run `wp block search --block=core/paragraph --post__in={URL_POST_ID} --fields=ID,url --format=csv` + Then STDOUT should contain: + """ + {URL_POST_ID},http + """ + + Scenario: Search scans more posts than a single batch + When I run `wp eval 'for ( $i = 0; $i < 205; $i++ ) { wp_insert_post( [ "post_title" => "Batch $i", "post_status" => "publish", "post_content" => "" ] ); } echo "done";'` + Then STDOUT should be: + """ + done + """ + + When I run `wp block search --block=my-plugin/batch --post_type=post --format=count` + Then STDOUT should be: + """ + 205 + """ diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 8ffb46d..bbb7f11 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -85,6 +85,12 @@ parameters: message: '#^parse_blocks\(\) is only available since WordPress version 5\.0\.0\.$#' path: src/Block_Synced_Pattern_Command.php + # `wp block search` is gated on WordPress 5.0. + - + identifier: WPCompat.functionNotAvailable + message: '#^(has_block|parse_blocks)\(\) is only available since WordPress version 5\.0\.0\.$#' + path: src/Block_Search_Command.php + # `wp block style` is gated on WordPress 5.3. - identifier: WPCompat.methodNotAvailable diff --git a/src/Block_Command.php b/src/Block_Command.php index 0dae254..d9b38a5 100644 --- a/src/Block_Command.php +++ b/src/Block_Command.php @@ -12,6 +12,9 @@ * # List all registered block types * $ wp block type list * + * # Find posts by block usage + * $ wp block search --block=core/button + * * # Get a specific block pattern * $ wp block pattern get my-theme/hero * diff --git a/src/Block_Search_Command.php b/src/Block_Search_Command.php new file mode 100644 index 0000000..4ef9e4a --- /dev/null +++ b/src/Block_Search_Command.php @@ -0,0 +1,895 @@ +] + * : Block type name to search for (for example, 'core/paragraph'). A name without a namespace is treated as a core block. + * + * [--block-namespace=] + * : Limit matches to blocks within a specific namespace (for example, 'core'). + * + * [--style=] + * : Limit matches to blocks using a specific block style. + * + * [--pattern=] + * : Limit matches to blocks embedded from a specific pattern (for example, 'twentytwentyfive/event-rsvp'). + * + * [--pattern-namespace=] + * : Limit matches to blocks embedded from patterns within a specific namespace (for example, 'twentytwentyfive'). + * + * [--synced-pattern=] + * : Limit matches to reusable block references for a specific synced pattern post ID. + * + * [--limit=] + * : Stop after this many matching posts, in query order (post ID ascending unless + * `--orderby` is given). Unlike `--posts_per_page`, which limits the candidate + * posts examined, this counts matches. Cannot be combined with `--paged`. + * + * [--=] + * : One or more args to pass to WP_Query. The default `post_type` is `any`, + * which excludes `wp_block` and other post types that are not searchable. + * Pass `--post_type=wp_block` to search them explicitly. + * + * Results are complete by default; candidate posts are processed in batches + * internally. Pass `--posts_per_page=` and `--paged=` to search only + * that window of candidate posts. + * + * Content inside synced patterns is not searched through `core/block` + * references, so a block that only exists inside a synced pattern is not + * found in the posts that use that pattern. + * + * [--field=] + * : Prints the value of a single field for each matching post. + * + * [--fields=] + * : Limit the output to specific result fields. + * + * [--format=] + * : Render output in a particular format. + * --- + * default: table + * options: + * - table + * - csv + * - json + * - count + * - yaml + * - ids + * --- + * + * ## AVAILABLE FIELDS + * + * These fields will be displayed by default for each matching post: + * + * * ID + * * post_title + * * post_name + * * post_date + * * post_status + * + * These fields are optionally available: + * + * * post_type + * * url + * * occurrences + * + * ## EXAMPLES + * + * # Find posts using the paragraph block. + * $ wp block search --block=core/paragraph + * + * # Find any post type posts using the heading block. + * $ wp block search --block=core/heading --post_type=any + * + * # Find posts using any block in namespace like 'core'. + * $ wp block search --block-namespace=core + * + * # Find posts using any blocks with block style of 'rounded'. + * $ wp block search --style=rounded + * + * # Find posts using certain block with a specific style. + * $ wp block search --block=core/image --style=rounded + * + * # Search a namespace with a specific style. + * $ wp block search --block-namespace=core --style=rounded + * + * # Search published pages for rounded images. + * $ wp block search --block=core/image --style=rounded --post_type=page --post_status=publish + * + * # Find posts using blocks embedded from a specific pattern. + * $ wp block search --pattern=twentytwentyfive/event-rsvp + * + * # Find posts using blocks from patterns in a specific namespace. + * $ wp block search --pattern-namespace=twentytwentyfive + * + * # Find posts using a specific synced pattern. + * $ wp block search --synced-pattern=123 + * + * # Show selected fields as JSON for further processing. + * $ wp block search --block=core/heading --post_status=publish --fields=ID,post_type,occurrences --format=json + * + * # Limit the candidate posts scanned with a native query argument. + * $ wp block search --block=core/paragraph --showposts=50 --format=ids + * + * # Search only the second window of 1000 candidate posts. + * $ wp block search --block=core/paragraph --posts_per_page=1000 --paged=2 --format=ids + * + * # Restrict search to specific posts and return the count. + * $ wp block search --style=rounded --post__in=21,42,84 --format=count + * + * # Return the first 20 posts using the paragraph block. + * $ wp block search --block=core/paragraph --limit=20 --format=ids + * + * # Return only matching post IDs. + * $ wp block search --block=core/paragraph --format=ids + * + * # Return count of matching posts. + * $ wp block search --block=core/heading --format=count + * + * + * @param array $args Positional arguments. Unused. + * @param array $assoc_args Associative arguments. + */ + public function __invoke( $args, $assoc_args ) { + $block_name = Utils\get_flag_value( $assoc_args, 'block', null ); + $block_namespace = Utils\get_flag_value( $assoc_args, 'block-namespace', null ); + $style_name = Utils\get_flag_value( $assoc_args, 'style', '' ); + $pattern_name = Utils\get_flag_value( $assoc_args, 'pattern', null ); + $pattern_ns = Utils\get_flag_value( $assoc_args, 'pattern-namespace', null ); + $synced_pattern = null; + + $synced_pattern_raw = Utils\get_flag_value( $assoc_args, 'synced-pattern', null ); + + if ( null !== $synced_pattern_raw && '' !== $synced_pattern_raw ) { + if ( ! is_scalar( $synced_pattern_raw ) || 1 !== preg_match( '/^\d+$/', (string) $synced_pattern_raw ) || (int) $synced_pattern_raw <= 0 ) { + WP_CLI::error( 'The --synced-pattern parameter must be a positive integer post ID.' ); + } + + $synced_pattern = (int) $synced_pattern_raw; + } + + $limit = null; + $limit_raw = Utils\get_flag_value( $assoc_args, 'limit', null ); + + if ( null !== $limit_raw ) { + if ( ! is_scalar( $limit_raw ) || 1 !== preg_match( '/^\d+$/', (string) $limit_raw ) || (int) $limit_raw <= 0 ) { + WP_CLI::error( 'The --limit parameter must be a positive integer.' ); + } + + if ( isset( $assoc_args['paged'] ) ) { + WP_CLI::error( 'The --limit parameter cannot be combined with --paged.' ); + } + + $limit = (int) $limit_raw; + } + + if ( ( null === $block_name || '' === $block_name ) && ( null === $block_namespace || '' === $block_namespace ) && '' === $style_name && ( null === $pattern_name || '' === $pattern_name ) && ( null === $pattern_ns || '' === $pattern_ns ) && null === $synced_pattern ) { + WP_CLI::error( 'At least one block filter is required: --block, --block-namespace, --style, --pattern, --pattern-namespace, or --synced-pattern.' ); + } + + if ( null !== $block_name && '' !== $block_name && null !== $block_namespace && '' !== $block_namespace ) { + WP_CLI::error( 'The --block and --block-namespace parameters are mutually exclusive.' ); + } + + if ( null !== $pattern_name && '' !== $pattern_name && null !== $pattern_ns && '' !== $pattern_ns ) { + WP_CLI::error( 'The --pattern and --pattern-namespace parameters are mutually exclusive.' ); + } + + if ( null !== $synced_pattern && ( ( null !== $block_name && '' !== $block_name ) || ( null !== $block_namespace && '' !== $block_namespace ) ) ) { + WP_CLI::error( 'The --synced-pattern parameter cannot be combined with --block or --block-namespace.' ); + } + + if ( null !== $block_name && '' !== $block_name && false === strpos( $block_name, '/' ) ) { + $block_name = 'core/' . $block_name; + } + + $defaults = [ + 'post_type' => 'any', + 'post_status' => 'any', + 'no_found_rows' => true, + 'cache_results' => false, + 'update_post_meta_cache' => false, + 'update_post_term_cache' => false, + ]; + + $array_arguments = [ 'date_query', 'tax_query', 'meta_query' ]; + $query_assoc_args = Utils\parse_shell_arrays( $assoc_args, $array_arguments ); + + unset( + $query_assoc_args['block'], + $query_assoc_args['block-namespace'], + $query_assoc_args['style'], + $query_assoc_args['pattern'], + $query_assoc_args['pattern-namespace'], + $query_assoc_args['synced-pattern'], + $query_assoc_args['limit'], + $query_assoc_args['field'], + $query_assoc_args['fields'], + $query_assoc_args['format'] + ); + + $query_args = array_merge( $defaults, $query_assoc_args ); + $query_args = self::process_csv_arguments_to_arrays( $query_args ); + + if ( isset( $query_args['post_type'] ) && 'any' !== $query_args['post_type'] ) { + $query_args['post_type'] = explode( ',', $query_args['post_type'] ); + } + + $rough_prefilter = $this->build_rough_post_content_prefilter( + $block_name, + $block_namespace, + $style_name, + $pattern_name, + $pattern_ns, + $synced_pattern + ); + + $include_url = $this->is_field_requested( $assoc_args, 'url' ); + $include_occurrences = $this->is_field_requested( $assoc_args, 'occurrences' ); + $use_fast_path = $this->can_use_has_block_fast_path( $block_name, $block_namespace, $style_name, $pattern_name, $pattern_ns, $synced_pattern ); + $results = []; + + foreach ( $this->query_posts_in_batches( $query_args, $rough_prefilter ) as $post ) { + $occurrences = null; + + if ( $use_fast_path && ! has_block( $block_name, $post ) ) { + continue; + } + + if ( ! $use_fast_path || $include_occurrences ) { + $matches = $this->find_matching_blocks( $post->post_content, $block_name, $block_namespace, $style_name, $pattern_name, $pattern_ns, $synced_pattern, ! $include_occurrences ); + + if ( empty( $matches ) ) { + continue; + } + + $occurrences = count( $matches ); + } + + $result = [ + 'ID' => $post->ID, + 'post_title' => $post->post_title, + 'post_name' => $post->post_name, + 'post_date' => $post->post_date, + 'post_type' => $post->post_type, + 'post_status' => $post->post_status, + ]; + + if ( $include_url ) { + $result['url'] = get_permalink( $post->ID ); + } + + if ( null !== $occurrences ) { + $result['occurrences'] = $occurrences; + } + + $results[] = $result; + + if ( null !== $limit && count( $results ) >= $limit ) { + break; + } + } + + $formatter = new Formatter( + $assoc_args, + [ 'ID', 'post_title', 'post_name', 'post_date', 'post_status' ], + 'post' + ); + + if ( 'ids' === $formatter->format ) { + echo implode( ' ', wp_list_pluck( $results, 'ID' ) ); + return; + } + + if ( 'count' === $formatter->format ) { + WP_CLI::line( (string) count( $results ) ); + return; + } + + $formatter->display_items( $results ); + } + + /** + * Checks whether an output field was explicitly requested. + * + * @param array $assoc_args Associative arguments. + * @param string $field Field name. + * @return bool + */ + private function is_field_requested( array $assoc_args, $field ) { + $requested = []; + + foreach ( [ 'field', 'fields' ] as $key ) { + if ( isset( $assoc_args[ $key ] ) && is_string( $assoc_args[ $key ] ) ) { + $requested = array_merge( $requested, array_map( 'trim', explode( ',', $assoc_args[ $key ] ) ) ); + } + } + + return in_array( $field, $requested, true ); + } + + /** + * Yields candidate posts, loading them in batches of IDs. + * + * The prefiltered query only selects IDs. Posts are then loaded in fixed-size + * batches so memory stays flat regardless of how many posts are candidates. + * Explicit pagination args still select exactly that window of IDs. + * + * @param array $query_args WP_Query arguments. + * @param array> $markers Rough prefilter markers. + * @return \Generator<\WP_Post> + */ + private function query_posts_in_batches( array $query_args, array $markers ) { + $id_query_args = $query_args; + $id_query_args['fields'] = 'ids'; + + $has_page_size = false; + + foreach ( [ 'posts_per_page', 'showposts', 'nopaging' ] as $key ) { + if ( isset( $query_args[ $key ] ) ) { + $has_page_size = true; + break; + } + } + + if ( ! $has_page_size && ! isset( $query_args['paged'] ) ) { + // -1 would make WP_Query ignore `offset`, so use an effectively unbounded size when one is given. + $id_query_args['posts_per_page'] = isset( $query_args['offset'] ) ? PHP_INT_MAX : -1; + } + + if ( ! isset( $query_args['orderby'] ) ) { + $id_query_args['orderby'] = 'ID'; + $id_query_args['order'] = 'ASC'; + } + + $found = $this->run_query_with_rough_prefilter( $id_query_args, $markers )->posts; + $ids = array_map( + static function ( $id ) { + return (int) ( $id instanceof \WP_Post ? $id->ID : $id ); + }, + is_array( $found ) ? $found : [] + ); + + foreach ( array_chunk( $ids, self::BATCH_SIZE ) as $batch_ids ) { + $batch = new \WP_Query( + [ + 'post__in' => $batch_ids, + 'post_type' => $query_args['post_type'], + 'post_status' => $query_args['post_status'], + 'orderby' => 'ID', + 'posts_per_page' => count( $batch_ids ), + 'ignore_sticky_posts' => true, + 'no_found_rows' => true, + 'cache_results' => false, + 'update_post_meta_cache' => false, + 'update_post_term_cache' => false, + ] + ); + + $batch_posts = $batch->posts; + $by_id = []; + + foreach ( is_array( $batch_posts ) ? $batch_posts : [] as $post ) { + if ( $post instanceof \WP_Post ) { + $by_id[ $post->ID ] = $post; + } + } + + // Restore the order of the ID query in PHP, as ORDER BY FIELD() with 200 IDs fails on some versions of SQLite. + foreach ( $batch_ids as $batch_id ) { + if ( isset( $by_id[ $batch_id ] ) ) { + yield $by_id[ $batch_id ]; + } + } + } + } + + /** + * Converts known CSV query args to arrays for WP_Query compatibility. + * + * @param array $assoc_args Query args. + * @return array + */ + private static function process_csv_arguments_to_arrays( array $assoc_args ) { + $int_array_fields = [ + 'post__in', + 'post__not_in', + 'post_parent__in', + 'post_parent__not_in', + 'author__in', + 'author__not_in', + 'category__in', + 'category__not_in', + 'category__and', + 'tag__in', + 'tag__not_in', + 'tag__and', + ]; + + $string_array_fields = [ + 'post_name__in', + 'tag_slug__in', + ]; + + foreach ( $int_array_fields as $field ) { + if ( isset( $assoc_args[ $field ] ) && ! is_array( $assoc_args[ $field ] ) ) { + $assoc_args[ $field ] = array_map( 'intval', explode( ',', (string) $assoc_args[ $field ] ) ); + } + } + + foreach ( $string_array_fields as $field ) { + if ( isset( $assoc_args[ $field ] ) && ! is_array( $assoc_args[ $field ] ) ) { + $assoc_args[ $field ] = array_map( 'trim', explode( ',', (string) $assoc_args[ $field ] ) ); + } + } + + return $assoc_args; + } + + /** + * Builds a rough post_content prefilter marker. + * + * The prefilter is advisory only and must never exclude valid matches. + * + * @param string|null $block_name Requested exact block name. + * @param string|null $block_namespace Requested block namespace. + * @param string $style_name Requested style name. + * @param string|null $pattern_name Requested exact pattern name. + * @param string|null $pattern_namespace Requested pattern namespace. + * @param int|null $synced_pattern Requested synced pattern post ID. + * @return array> Markers that must all match; a list entry matches if any alternative does, and a `regexp` entry carries a LIKE `fallback`. + */ + private function build_rough_post_content_prefilter( $block_name, $block_namespace, $style_name, $pattern_name, $pattern_namespace, $synced_pattern ) { + $markers = []; + + if ( null !== $block_name && '' !== $block_name ) { + $markers[] = $this->alternatives( + [ + '