JPKCom Post Filter — Guide & Tips

Faceted taxonomy filtering for posts, pages and custom post types with JPKCom Post Filter — SEO-friendly URLs, AJAX, shortcodes, blocks and page builder support.

JPKCom Post Filter adds faceted taxonomy filtering to any WordPress archive page — for posts, pages and any custom post type. Every filter state is encoded in an SEO-friendly URL like /blog/filter/marketing+web-design/wordpress/, so it is shareable and bookmarkable; filtering runs via AJAX with a full no-JavaScript fallback.

Guide

Requirements

  • WordPress 7.0 or newer (tested up to WordPress 7.1)
  • PHP 8.3 or newer
  • No dependencies: no ACF, no Bootstrap, no jQuery required

Page builder support is optional — the Gutenberg blocks, Elementor widgets and Oxygen elements load only when the respective builder is active.

Installation

  1. Upload the jpkcom-post-filter directory to /wp-content/plugins/ (or install the ZIP via Plugins → Add New → Upload Plugin).
  2. Activate the plugin under Plugins → Installed Plugins.
  3. Under Post Filter → General, select the post types to filter.
  4. Under Post Filter → Filter Groups, add the taxonomies you want as filter dimensions.
  5. Go to Settings → Permalinks and click Save Changes to flush the rewrite rules.

Configuring filter groups

Each filter group maps a taxonomy to a URL position; the group order determines the order of the URL segments. Per group you set the taxonomy, label, target post types, a numeric sort position (Order) and an on/off toggle (Enabled).

You can register new taxonomies right from the Filter Groups page (the "Register as new WordPress taxonomy" option): slug, rewrite slug, hierarchical/flat, public, admin column and REST API visibility (required for Gutenberg) are all configurable.

Warning: Deleting a filter group that registered a custom taxonomy unregisters that taxonomy — all term assignments on posts are permanently lost.

Output: auto-inject, shortcodes, blocks

There are three ways to render the filter and list:

  • Auto-inject (General → Auto-Inject Filter): the plugin automatically wraps the theme's archive loop with the filter UI — no shortcode or template edit needed. It hooks into loop_start/loop_end on archive/blog pages (not on singular posts, search or pages). In block themes such as Twenty Twenty-Five this printed the filter bar before <!DOCTYPE html> up to 1.7.0; since 1.7.1 the plugin places its markup around the post template of the query block that inherits the main query. The theme's own query pagination then gives way to the plugin's, and with zero results the bar and the message take the place of the query's no-results block. Classic themes get exactly the same output as before; the filter jpkcom_postfilter_inject_via_blocks switches between the two ways.
  • Shortcodes: [jpkcom_postfilter_filter], [jpkcom_postfilter_list] and [jpkcom_postfilter_pagination]. The interactive shortcode builder under Post Filter → Shortcodes generates ready-made snippets.
  • Builders: three Gutenberg blocks, three Elementor widgets and three Oxygen elements each (Post Filter, Post List, Post Pagination).
[jpkcom_postfilter_filter post_type="portfolio" layout="dropdown"]
[jpkcom_postfilter_list post_type="portfolio" layout="cards" limit="12"]
[jpkcom_postfilter_pagination post_type="portfolio"]

Important: Shortcodes (and blocks/widgets/elements) always attach to the archive of the configured post type. They only work on an archive template page or on the page set as the "Posts Page" under Settings → Reading. On an arbitrary page (e.g. /test/) filter clicks and AJAX jump back to the archive URL — use auto-inject there instead.

Layout & design

Post Filter → Layout & Design offers six tabs (Global, Filter, Posts, Pagination, Color Schemes, Advanced). You choose from four filter layouts (bar, columns, sidebar, dropdown), three list layouts (cards, rows, minimal) and four color schemes (Default, Dark, Contrast, Monochrome). The Advanced tab controls the stylesheet mode (Full / Variables only / Disabled), the Plus/Minus mode, reset button visibility and your own custom CSS, among others.

Crawlers & SEO: one URL per filter selection

Since 1.5.0 every filter selection has exactly one address. Until then the plugin accepted every spelling of a selection and served it with HTTP 200: b+a, a+b and a+a+b were three separate pages, each with its own canonical, and every term link in the filter bar pointed, without limit, at "current selection + this term" in click order. Crawlers that follow links found millions of addresses from a handful of terms; on a production site about 840,000 filter requests a day reached WordPress, every URL unique, every one a full page render. The URL space is now finite: at ten terms in each of two groups, 20 single-term pages are crawlable instead of tens of millions of combinations. No crawler is locked out of content, the archive or the single-term pages.

What changes on a running site after the update:

  • One spelling, 301 for all others: the terms of a group are sorted and de-duplicated. The plugin answers any other spelling — b+a, a+a, upper case, a trailing _, …/page/1/ — with a 301 to the canonical URL. Existing links and indexed addresses keep working through that redirect.
  • 410 instead of a truncated page: a URL beyond the limits — more terms per group than Max. Filters per Group, more groups than Max Filter Combinations, more segments than filter groups — gets a 410 with a short error page instead of being truncated and rendered. Terms that do not exist (any more) are redirected away, to the remaining selection or to the archive. Both happen before the database is queried. With WPML, a term of any language counts as existing. Since 1.6.0, Max. Filters per Group counts the terms as they appear in the URL, before duplicates are removed: at a limit of 2, a+a+b gets a 410 instead of a 301 to a+b. URLs the plugin builds itself never look like that. On new installations the limit defaults to 2 instead of 3 since 1.6.0; existing installations keep 3 on the update, and a value you saved yourself is never touched.
  • Indexed only where it belongs: filter pages with unknown terms or with no results are noindex, nofollow without a canonical; the same goes out in an X-Robots-Tag header. From 1.5.0 through 1.6.1 this also applied, by default (Indexable Filter Pages = 1), to every page with more than one term, while single-term pages stayed index, follow with a canonical to themselves. Since 1.7.0 the canonical points by default to the term's archive, or for combinations to the unfiltered blog page — details under Canonical targets below.
  • Different term links without JavaScript: for clients without JavaScript — in practice, crawlers — a term button links to its own single-term page. Terms whose link would lead nowhere useful are rendered as <button type="button"> instead of <a>, styled identically. With JavaScript you notice none of this: the script still combines terms freely. Without JavaScript a click now switches to that term instead of adding it; to get the old behaviour (now capped and sorted), set Filter Links for Crawlers to "Combinations within the limits".
  • One canonical instead of two: with Rank Math or Yoast, every filter page up to 1.4.7 carried a second canonical from the plugin next to the SEO plugin's own, which points at the blog page. Since 1.5.0 the plugin hands its canonical through the SEO plugin, so there is exactly one. Rank Math also drops its canonical and its prev/next links on noindex pages.
  • robots.txt: the plugin appends Disallow: …/filter/*+ to WordPress's virtual robots.txt — only segments with more than one term are blocked, for every crawler alike — plus lines for the fragment segment and the internal jpkcom_filter_* query variables. With WPML the rules are written for every language prefix (/filter/*+, /en/filter/*+). This only takes effect when the web server passes /robots.txt to WordPress; for a physical file in the web root, the settings page shows the lines to copy into it.
  • Pagination: page 1 is no longer linked as …/page/1/, a duplicate of the page that every paginated list contained up to 1.4.7.
  • Internal query variables from the URL rule only: up to 1.4.7, ?jpkcom_filter_path=… (and the fragment and post-type variables) in a query string turned any URL into a filter or fragment response, bypassing every path-based rule. Since 1.5.0 the plugin takes them only from the matched rewrite rule.

The settings live under Post Filter → General → Crawlers & SEO. Each can also be pinned with a constant in wp-config.php; the field is then shown locked:

Option Default Constant Effect
Filter Links for Crawlers Single terms only JPKCOM_POSTFILTER_LINK_MODE (single, combo, none) Target of term links without JavaScript: single-term page, combination within the limits, or no link
Indexable Filter Pages 1 JPKCOM_POSTFILTER_INDEX_MAX_TERMS Filter pages with up to this many terms are index, follow; 0 = none
Canonical: Single Term Term archive JPKCOM_POSTFILTER_CANONICAL_SINGLE (archive, filter, off) Canonical of a single-term page: the term's archive, the filter page itself, or none at all (since 1.7.0)
Canonical: Multiple Terms Archive base JPKCOM_POSTFILTER_CANONICAL_MULTI (home, filter, off) Canonical of a page with two or more terms: the unfiltered blog page or post type archive, the filter page itself, or none at all (since 1.7.0)
Max. Terms in Total 0 (off) JPKCOM_POSTFILTER_MAX_TERMS_TOTAL Cap across all groups together, enforced by the filter bar and the server
Strict Filter URLs on JPKCOM_POSTFILTER_STRICT_URLS 301 to the canonical spelling, rejection of impossible paths before the database query
Invalid Combinations 410 JPKCOM_POSTFILTER_REJECT_STATUS Status for paths beyond the limits, 410 or 404
Unknown Terms in URLs Redirect JPKCOM_POSTFILTER_UNKNOWN_TERMS_ACTION (redirect, noindex, reject) Redirect to the selection without them, show the page with noindex, or reject
Query Strings on Filter URLs off JPKCOM_POSTFILTER_STRIP_QUERY 301 from /filter/x/?a=b to /filter/x/; keep parameters you need through the jpkcom_postfilter_strip_query_allowlist filter
robots.txt Rules Disallow combinations JPKCOM_POSTFILTER_ROBOTS_TXT (deep, all, off) Block combinations, block the whole endpoint, or no rules

With Indexable Filter Pages at 0, "Disallow combinations" blocks the whole endpoint; at 2 or more it writes only the lines for the fragment segment and the query variables, because a wildcard can no longer tell the indexable pages apart. Since 1.5.0 the existing caps can be pinned by constant too: JPKCOM_POSTFILTER_MAX_FILTERS_PER_GROUP and JPKCOM_POSTFILTER_MAX_FILTER_COMBOS. Each of these values also passes through a filter jpkcom_postfilter_{key}, such as jpkcom_postfilter_filter_link_mode; further levers include jpkcom_postfilter_request_verdict, jpkcom_postfilter_robots_directives, jpkcom_postfilter_canonical, jpkcom_postfilter_term_href, jpkcom_postfilter_term_link_rel and jpkcom_postfilter_robots_txt_rules.

Careful with your own filter templates: if you override partials/filter/*.php in your theme, it keeps working — but your override produces the old, unbounded links until it calls jpkcom_postfilter_term_link_attributes() like the shipped templates.

Canonical targets (since 1.7.0)

A single-term page such as /blog/filter/seo/ shows the same posts as the archive /category/seo/. Since 1.7.0 its canonical therefore points there by default, and the canonical of a combination points to the unfiltered blog page. The two fields Canonical: Single Term and Canonical: Multiple Terms (table above) control this. With the defaults:

  • One term: canonical to the term's archive (/category/…/, /tag/…/ or the archive of a custom taxonomy), and the page stays index, follow. That only applies when the archive is public, lists the filtered post type, is not set to noindex in Rank Math or Yoast and has no manual canonical there pointing elsewhere — otherwise the canonical stays on the filter page. Rank Math sets tag archives to noindex out of the box, so tag filter pages there keep their own canonical.
  • Two or more terms: canonical to the unfiltered blog page or post type archive, and the page is index, nofollow instead of noindex, nofollow. The reason: a noindex next to a canonical pointing elsewhere contradicts itself, and search engines may carry the noindex over to the target. If the blog page itself is set to noindex, has a manual canonical elsewhere, or the site has a static front page and no posts page, nothing changes for these pages. With the default robots.txt rules, combinations within one group stay blocked anyway, so crawlers never see their canonical.
  • Page 2 and later: since 1.7.1 a paginated single-term page has a canonical to itself (…/filter/seo/page/2/), as Google recommends for paginated pages; page 1 still points to the archive or the blog page. As a consequence, page 2 and later of a combination are noindex, nofollow without a canonical again, as in 1.6.1. In 1.7.0 the canonical of a paginated filter page still pointed to page 1 of its target.
  • Past the last page: /blog/filter/seo/page/99/ answers 404 since 1.7.1, like /blog/page/99/ and /category/seo/page/99/. Before, WordPress's canonical redirect sent it with a 301 to the unfiltered blog page. Filter pages that exist keep WordPress's redirects (a missing trailing slash and the like).
  • Rank Math and Yoast: since 1.7.1 filter pages no longer inherit a noindex from the blog page. A single-term page stays index with its canonical to the term archive even if the blog page itself is set to noindex. Two settings still win: Discourage search engines from indexing this site in WordPress, and, for page 2 and later, Rank Math's noindex archive subpages and, on filter pages of the blog (not of a post type archive), noindex paginated pages. A search on a filter page (?s=) stays noindex, and a comment reply link (?replytocom=) noindex, nofollow. Yoast's rel="prev"/rel="next" links are no longer printed on filter pages — they pointed into the unfiltered blog.

Filter URL in both fields brings back the canonical targets from before 1.7.0: single-term pages point to themselves, and Indexable Filter Pages decides about combinations. Off leaves the canonical out entirely, and then none comes from Rank Math or Yoast either. Purge the page cache after updating: cached filter pages keep the old canonical and robots until they are rendered again.

If you use an SEO plugin other than Rank Math or Yoast, or your theme lists less in a term archive than WordPress itself does, you rule an archive out as a target through jpkcom_postfilter_canonical_archive_indexable or jpkcom_postfilter_canonical_base_indexable. The filters only run once the archive has passed the plugin's own checks; they never turn an archive the plugin rejected (say, because category archives list only post by WordPress's logic) into a target. Since 1.7.0 the filter jpkcom_postfilter_canonical also receives the kind of target (archive, home or self) and the active filters; since 1.7.1 a callback that returns an empty string means no canonical, and the page keeps the noindex that matches its term count.

Your own pagination template: since 1.7.0 the shipped partials/pagination/pagination.php asks jpkcom_postfilter_should_nofollow() instead of jpkcom_postfilter_should_noindex(). An older override in your theme keeps working, but its page links on page 1 of a combination (which is index, nofollow with its canonical to the blog page) are no longer nofollow until it takes over that line.

Server rules: rejecting impossible URLs before PHP (since 1.6.0)

The plugin rejects filter URLs beyond its limits — but only after WordPress has started. On a production site, after 1.5.0 each rejected URL still cost a full WordPress start, together about 70 % of the remaining PHP load. Post Filter → Server Rules generates rules from the current settings that let the web server turn those URLs away before PHP runs, and slow down floods of filter requests. There is one tab each for nginx (plain server block or ISPConfig), Apache (server configuration), Apache (.htaccess), Caddy/FrankenPHP, Ferron 3, Ferron 2.8 and OpenLiteSpeed; the tab of the server PHP runs on is preselected. Each tab contains, as far as the server allows:

Part What it does
Reject before PHP answers filter URLs with more terms per group, or more segments, than the plugin accepts, with the same 410 (or 404) status, plus X-Robots-Tag: noindex, nofollow, X-Jpkpf-Reject, X-Jpkpf-Rule: <hash> and Cache-Control: no-store. There is no tested rule for Ferron 2.8, and OpenLiteSpeed sends no headers
robots.txt how /robots.txt reaches WordPress, so the plugin's robots.txt lines are served
Rate limit only for filter URLs requested without any cookie: a cap on concurrent renders, a site-wide rate, a per-IP rate and a separate budget for the major search engines, beyond that 429 with Retry-After. Derived from two numbers you enter: PHP workers (default 8) and the render time of a filter page (default 0.4 s). Not possible in .htaccess or on OpenLiteSpeed, reduced on Ferron
Check after installing curl commands to test the rule
Page cache when to purge it

The rules never reject a URL the plugin serves with the current settings. Rules you deployed by hand do not change by themselves, though, so follow the order on the page: when you tighten the settings, change the plugin first, purge the page cache, then update the server; when you loosen them, update the server first, then the plugin. After every save (or import) that loosens the filter URL settings, a notice reminds you of server rules you deployed by hand — red when the last check found them active or stale. It is not shown while the plugin manages the .htaccess block itself.

Check now sends a few requests without cookies to your own site and shows whether robots.txt reaches WordPress, whether the server rule is deployed and current, and — as a red alarm — whether it is stricter than the plugin. The check does not test the rate limit, because that would use up the budget all cookie-less visitors share. If the site cannot reach itself (HTTP authentication, firewall, maintenance mode), the result says it could not be checked, never that the rule is missing. Since 1.7.1 this also applies to the check whether the rule is stricter than the plugin: a redirect that does not come from the plugin (to https, to another host name, from a proxy) is reported as not checked instead of not stricter. Such a redirect may happen before the server rules and proves nothing about them.

Managed .htaccess block (Apache only). On Apache, single site, with a writable .htaccess and without DISALLOW_FILE_MODS, a click on Set up lets the plugin write its block itself — directly before WordPress's own block, between # BEGIN JPKCom Post Filter and # END JPKCom Post Filter — and keep it in step with the settings on every save in wp-admin and on import from then on. A loosening wp option update under WP-CLI loosens the block before the change is stored; tightening through WP-CLI, wp-config.php constants or code filters follows on the next admin page load (until then the block is only looser than the plugin, never stricter). A save that would loosen the settings is refused while the block cannot follow; the error names the reason and what to do. Remove, or deactivating the plugin, deletes the block. Without that click the plugin never writes the file, and it never touches a block you pasted yourself (recognisable by its (manual) markers). The managed block is not available on LiteSpeed/OpenLiteSpeed or multisite.

If DISALLOW_FILE_MODS is set only after the block was set up (or the site joins a network only afterwards), Set up, tightening and the update on admin page loads stop. Everything that only loosens keeps writing the still-writable file: a loosening save, deleting the General or Filter Groups option, Remove, deactivation and the repair after a version change under WP-CLI. To keep the plugin out of the file entirely, click Remove; it works with the constant set.

Since 1.7.1 three safeguards have been added:

  • One save at a time: a second save of General or Filter Groups (or an import) while another one is still between loosening and tightening the block is refused with a message to wait a few seconds and save again; nothing is stored in that case. An import holds the lock from its first settings group to its last, so it is never applied half. Set up is refused the same way ("try again"), and the automatic block updates after a version change or a change of the posts page or permalinks write nothing then and catch up later.
  • Unreadable .htaccess: a loosening save is refused, and deactivation or Remove no longer forget the block — before, the plugin treated the unreadable file as one without a block. The Server Rules page shows the file as not readable.
  • Deleting options: wp option delete jpkcom_postfilter_general now also deletes the matching settings cache file; before, the plugin kept running with the deleted values until the next save. With a managed block, the block is first loosened for the defaults. If that fails (read-only file, another save in progress, a database error), deleting General or Filter Groups is refused: wp option delete ends with an error and exit code 1, any other caller with HTTP 409. Deleting the row directly in the database bypasses this.

Very many filter groups — 500 on 1.6.0: the reject pattern grows with the number of filter groups and terms per group. From 71 filter groups at 20 terms per group (37 if the last group has no taxonomy) PCRE cannot compile it: nginx then refuses the configuration, and a managed .htaccess block makes the whole site answer 500. Since 1.6.1 the plugin leaves such a rule — and likewise any rule longer than 4,000 characters or too much for Caddy or Ferron 3 — out of every snippet, the curl commands, Check now and the managed block, for every server alike, and the Server Rules page says which rule and why. That is looser, never stricter — the plugin still rejects those URLs itself. Up to 8 filter groups and 5 terms per group, 1.6.1 left every rule, the managed block and the rule hash byte-for-byte the same as in 1.6.0. If a site is still stuck on such a 1.6.0 block, since 1.7.1 wp plugin update jpkcom-post-filter is enough: the next wp command runs the new version and rewrites the block, but only when the new block provably leaves out whole rules and changes nothing else. Removing the lines by hand is no longer needed. If the Server Rules page warns that a rule is left out for your settings, 1.7.1 also changes the rule hash: Check now then reports rules you pasted by hand as stale, so paste them again. For every other setting the rule hash and the reject rules stay byte-for-byte the same as in 1.7.0.

With ModSecurity on Apache, since 1.7.1 neither the 429 answers of the ModSecurity rate limit nor the filter URLs the server rejects write an audit-log entry; a 429's one line in the error log stays. Measured with Debian's recommended modsecurity.conf, with and without OWASP CRS: 1.7 MB of audit log per 1,000 answered 429s and 1 MB per 1,000 server rejects before, none after. Paste the ModSecurity snippet again to get this; the reject snippet and the limits are unchanged.

Posts as data: the Abilities API

Since version 1.3.0 the plugin registers two read-only abilities. The Abilities API is a WordPress core registry of machine-readable capabilities — AI assistants, MCP clients and REST automation can answer questions about your posts through it without scraping the front end:

Ability What it answers
jpkcom-post-filter/list-filters which taxonomies and terms a post type can be filtered by, with a post count per term
jpkcom-post-filter/query-posts a filtered, paginated query returning the matching posts plus a shareable filter URL

The point of the pair is that the first removes the guesswork from the second: an assistant looks up the real taxonomy keys and term slugs, then filters with values that exist. A filter naming a taxonomy your site does not have is rejected with an error listing the valid ones — it never silently returns your whole archive instead.

Both are reachable at /wp-json/wp-abilities/v1/ and answer on GET:

GET /wp-json/wp-abilities/v1/abilities/jpkcom-post-filter/query-posts/run
      ?input[post_type]=post&input[filters][category][]=allgemein&input[per_page]=3

query-posts accepts post_type, filters, page, per_page (1–50) and search. Terms within one taxonomy are combined with OR, different taxonomies with AND — the same logic as the filter bar on your site. A term slug that matches nothing is not an error: the query runs and the slug is reported under unknown_terms, so the caller can tell a typo apart from an empty result.

Worth knowing when reading a response: filter_url comes back empty whenever no front-end address would show exactly the posts listed beside it. There are six such cases: a post type without an archive page; a filter combination beyond your configured limits; a page after the first requested with a page size other than your site's own "posts per page"; a page past the last one; since 1.3.1 an applied search term, because a filter URL cannot carry a search term and would show a larger, different list; and since 1.5.0 a term slug that does not exist, because the website redirects that URL to a different selection (unless Unknown Terms in URLs is set to noindex or Strict Filter URLs is off). The results are complete in every one of those cases; only the link is missing.

What is exposed, and to whom:

  • As a base rule, only published posts: the post status is fixed in the code and a caller cannot set it as a parameter. That is not a guarantee, though, and the plugin's developer documentation has said so explicitly since 1.3.1: post_status is an ordinary query var, and anything that touches the query first — another plugin hooking pre_get_posts without an is_main_query() guard, or the documented jpkcom_postfilter_query_args filter — changes what comes back. There is no second read gate behind the query here (the sibling plugin jpkcom-acf-jobs has one). On a site with such interventions, do not rely on drafts being unreachable.
  • Only post types you have enabled for filtering, and only taxonomies you have configured as filter groups.
  • Running an ability requires a logged-in user with the read capability. The mere list of abilities, with their descriptions and parameter schemas, is visible to any logged-in user including a subscriber — that is how WordPress core gates every ability, not something specific to this plugin.

To switch it off, in wp-config.php:

define( 'JPKCOM_POSTFILTER_ABILITIES', false );

To keep the abilities but raise the bar for running them:

add_filter( 'jpkcom_postfilter_ability_capability', static fn(): string => 'edit_posts' );

To keep them out of MCP clients while leaving the REST route intact, or the other way round, jpkcom_postfilter_ability_meta is the lever.

Tips & Tricks

  • Clearing the cache was incomplete — fixed in 1.3.1: Clearing the cache never cleared the fastest layer of it. Saving a post, editing a term, saving the settings, and both "Clear cache" buttons in the admin all reported success while leaving stale query results in place until they expired on their own — up to an hour by default. This affects every site running APCu, whether or not you use the abilities — the dead deletion loop sat in exactly that branch.
  • Change the URL endpoint: By default the filter sits under /filter/. Change the path segment under General → URL Endpoint and then click Flush Rewrite Rules (or re-save your permalinks).
  • Behaviour without filter terms: Use Bare Endpoint Behaviour to set what happens when /filter/ is accessed without terms — 404, redirect to the blog homepage, or a custom URL.
  • Multiple filter instances per page: Each filter/list/pagination set is paired via the post_type attribute (data-jpkpf-post-type), so different post types can be filtered independently on one page.
  • Fragment responses instead of whole pages: Since version 1.2.0 a filter click is answered with just the swappable zones — the theme header, nav menus, sidebar widgets and the entire asset pipeline are skipped (measured on a test install: 60–72 % less transferred, 5–19 % less server time). The request goes through a dedicated /jpkpf-fragment/ URL segment rather than a query parameter, so a page cache that strips unknown parameters cannot serve a bare fragment to an ordinary visitor. The rewrite rules this needs are flushed once automatically after a version change — no manual permalink save required after the update.
  • HTML minifiers and fragments — fixed in 1.4.4: With an HTML-minifying optimisation plugin active, every filter click returned an empty response and the list reported "No posts found." — on a site whose posts all still matched. The fragment was cut out of the rendered page along HTML comment markers, and comments are exactly what a minifier removes; Autoptimize does so with its stock settings, and its output buffer runs before the plugin's. Measured on the verification install: 18,300 bytes with Autoptimize inactive, 0 bytes with it active, 18,300 again with its HTML optimisation switched off. Since 1.4.4 the markers are empty <template> elements — no minifier removes elements, and the markers never reach the browser either way. On top of that, fragment requests ask the known optimisers to leave them alone, through Autoptimize's own filter and the cross-vendor DONOTMINIFY convention. Add other vendors from an mu-plugin through jpkcom_postfilter_fragment_noptimize_filters — the registration happens while the plugin is loading, so a regular plugin would be too late. And if a fragment response does arrive unusable, the script now falls back to a full page load instead of wrongly reporting "no posts".
  • Four cache layers: Object cache, transients, APCu and a PHP settings file cache keep output fast. Caches are invalidated automatically on save_post/deleted_post or term changes; clear them manually under Post Filter → Cache. Since 1.5.0 the query cache only stores selections of one term without unknown slugs (constant JPKCOM_POSTFILTER_QUERY_CACHE_MAX_TERMS, filter jpkcom_postfilter_cache_query) — under crawler load every deep combination was a write that was never read again. Up to 1.7.0 the checkboxes Object Cache, Transient Cache and Settings File Cache on the Cache page could not be switched off: an unchecked box sends nothing, and the plugin read that as the default, on. Since 1.7.1 they can; the master switch JPKCOM_POSTFILTER_CACHE_ENABLED in wp-config.php already turned all caches off together before. Also since 1.7.1 only saving writes the settings cache file, no longer every read — otherwise a request that loaded the settings just before a save could write its outdated copy into a missing cache file, after Clear Settings File Cache, say.
  • Multisite and APCu — fixed in 1.5.0: all sites of a network shared one settings cache directory, so the site that saved last decided the URL endpoint, filter groups and limits of every other site; each site now has its own subdirectory (site-{id}), and the update removes the old shared files. And the query cache in APCu used the same keys on every site of a network and in every WordPress installation served by the same PHP-FPM pool, so one site could show another site's cached posts. The keys are now scoped to the site. Neither affects a single site without a shared pool.
  • Override CSS cleanly: All styles use CSS custom properties prefixed --jpkpf-. Set them in your theme stylesheet, in the variable fields under Layout & Design, or via the custom CSS field. For full control, set the stylesheet mode to "Disabled".
  • Override templates per theme: The loader checks child theme → parent theme → mu-plugins/jpkcom-post-filter-overrides/templates/ → plugin templates/. For example, copy templates/partials/list/list-cards.php to themes/your-theme/jpkcom-post-filter/partials/list/list-cards.php.
  • Migrate settings: Under Post Filter → Import / Export you can export all settings (general, layout, cache, filter_groups) as JSON and re-import them on another environment.
  • Constants in wp-config.php: among others, JPKCOM_POSTFILTER_URL_ENDPOINT, JPKCOM_POSTFILTER_CACHE_ENABLED, JPKCOM_POSTFILTER_DEBUG and JPKCOM_POSTFILTER_MAX_FILTER_COMBOS are overridable — the last one had been listed in the README for a while, but the plugin only reads it since 1.5.0. Also since 1.5.0, on top of the constants under Crawlers & SEO, there are four constant-only switches: JPKCOM_POSTFILTER_ROBOTS_HEADER (false suppresses the X-Robots-Tag header), JPKCOM_POSTFILTER_LINK_MAX_TERMS (depth of the links in combo mode, 0 = the limits only), JPKCOM_POSTFILTER_PAGECACHE_MAX_TERMS (default 0 = off; filter pages with more terms get DONOTCACHEPAGE) and JPKCOM_POSTFILTER_QUERY_CACHE_MAX_TERMS (default 1). The Debug mode checkbox under General is display-only since 1.7.1: it never did anything, and debug logging and the debug templates follow JPKCOM_POSTFILTER_DEBUG alone (default: WP_DEBUG).
  • Build Gutenberg blocks from source: The block editor scripts need a build step (npm install && npm run build, output to blocks/build/). If the build directory is missing, the plugin skips block registration.
  • German translation: The translation catalogue was last generated at 1.1.2 and then trailed the code for twelve releases, so everything added since appeared in English on a German site. Since 1.4.1 it covers the whole plugin again, and 1.4.2 added 46 missing German translations — block titles with their descriptions and search keywords, the Elementor and Oxygen widget labels, several settings hints and the updater's security messages. The Abilities API texts stay English on purpose: they are read by AI clients and automation, not in the admin area, and their exact wording is what lets a caller correct a mistaken request in one attempt.
  • Accessible & SEO-ready: Filter terms are real <a> links (page reload without JS) or, where a link would lead nowhere useful, <button type="button">; the results zone uses aria-live, toggle buttons use aria-pressed. Since 1.5.0 a screen reader announces the result count after a filter click with auto-inject and the theme's own post loop — before, it announced "No posts found" after every successful click, because the theme's posts carry none of the plugin's markers. Since 1.7.1 the announcement also counts the posts of a block theme's post template (it gave no number there), and only the list's own posts, not those of a query block inside a post. The archive and the single-term pages are crawlable and indexable with exactly one canonical, by default pointing to the term archive (see Canonical targets above).
  • Updates & security: The plugin updates securely via GitHub with SHA256 checksum verification; update notices appear under Dashboard → Updates (since 1.4.6 with the JPKCom logo as plugin icon) and on the Plugins page. Since 1.4.5 it also carries the Update URI header: the wordpress.org update API then no longer returns updates for this plugin, so a plugin published there under the same slug cannot pose as an update. Since 1.4.7 (updater 1.5.0) the plugin is offered updates even while deactivated — provided another JPKCom plugin with updater 1.5.0 or later is active, on multisite network-active or active on the main site: one loaded updater serves every installed JPKCom plugin with the update row, icon and “View details”, and the SHA256 check applies just the same. The stored update list no longer contains JPKCom download URLs; they are added back only while a JPKCom updater is loaded, so without one no unverified package can be installed. The check also verifies packages supplied by another plugin and recognises every GitHub address of a release file. A Git or other version-control checkout and a renamed folder (such as jpkcom-post-filter-main) are never updated through WordPress — the update would delete the checkout or the folder. They are still told about new releases, a renamed folder only while the plugin in it is active. The updater fetches update information only for accounts that can update plugins, in the update cron and in WP-CLI, never for anonymous visitors.

Further reading