Skip to content

Releases: Shepdesign/hooked-on-facets

Hooked on Facets 1.0.0

04 Jun 04:22
a1aa612

Choose a tag to compare

What's Changed

  • chore(release): prep 1.0.0 — docs, branding, i18n, distribution readiness by @Shepdesign in #15
  • ci: auto-publish releases and sync the wiki from /docs by @Shepdesign in #16

Full Changelog: https://github.com/Shepdesign/hooked-on-facets/commits/v1.0.0

v0.13.1-alpha — counts query optimization

03 Jun 21:12

Choose a tag to compare

A focused performance patch on top of v0.13.0-alpha.

Changed

Drill-down counts query — 7.5× faster

The counts behind the /filter endpoint grouped by (facet_value, facet_display) — two 191-char VARCHARs — which bloated the aggregation temp table. Since the value→display mapping is 1:1 (slug→name, ID→title, or value === display for meta), the query now GROUP BY facet_value alone and takes the display via MIN(facet_display):

  • Identical output — byte-for-byte the same buckets (verified on the live 100k stack: same 21 category buckets, same counts).
  • 454ms → 60ms on the counts query, taking resolve() p95 from ~465ms → ~63ms uncached on 100k products.
  • COUNT(DISTINCT object_id) is kept (not COUNT(*)) so a multi-row-per-object meta facet still counts each object once.

Together with the v0.13.0 result-set cache, the /filter path is now fast on both cold (~63ms) and repeat (sub-ms) hits. SQL shape is locked by a unit test.

Note: EXPLAIN reported both the old and new queries as "Using temporary; Using filesort" — only wall-clock measurement exposed the 7.5× gap. Measure, don't trust the plan.

Verification

  • PHP: 121 tests / 269 assertions; php -l clean
  • JS: 35 tests; build clean · Markdown: lint clean
  • Live stack: identical buckets confirmed; resolve() re-benchmarked at ~63ms p95

Upgrade

git pull && composer install && npm install

No schema change, no reindex, no behavior change — purely a faster query.

Full changelog: CHANGELOG.md

v0.13.0-alpha — enhancements: SEO, analytics, resolver cache

03 Jun 21:02

Choose a tag to compare

The first round of enhancements on top of the feature-complete plugin. Three additions, each verified end-to-end on the live 100k-product stack.

What's new

SEO for filtered pages

Faceted URLs bloat crawl budget and spawn near-duplicate pages, and general SEO plugins don't understand the ?hof[*] query shape — so HOF owns the faceted signals via a new SeoManager:

  • rel=canonical to the filter-stripped base URL (deferred when Yoast / Rank Math / AIOSEO / SEOPress is active, to avoid a double tag).
  • noindex,follow once a configurable number of facets are stacked (default 2 — single-facet landing pages stay indexable while the combinatorial long tail doesn't), emitted via the core wp_robots filter so it composes with other plugins.
  • An active-filters title suffix so an indexed single-facet page reads meaningfully in search results.

Settings persist to hof_seo, exposed via a new admin SEO screen and GET|POST /seo-settings. Decision logic is pure and unit-tested (12 cases).

Analytics dashboard

The telemetry recorder now captures per-facet/value usage and zero-result filter combinations — one signal per /filter action, buffered in memory and flushed at shutdown (with caps), so it never adds request latency. snapshot() gains p50/p95/p99 resolver latency.

The admin Dashboard renders most-used facets (usage bars), "filters that find nothing," the latency percentiles, and a dead-facets callout (configured but never applied by a shopper). Covered by 6 tests.

Resolver result-set cache

resolve() and resolve_ids() now cache their output in the object cache, keyed by (index version, filter state). The Indexer bumps hof_index_version on every write, so invalidation is O(1) — a bump orphans every stale key.

Benchmarking on the live 100k stack drove the scope: resolve_ids() was already ~8ms p95, but resolve() — IDs + N drill-down counts behind /filter — was ~465ms, the real bottleneck. Cached repeat hits are sub-millisecond. Most effective with a persistent object cache (Redis / Memcached); kill switch hof_resolver_cache_enabled. Covered by 4 tests.

Deliberately not done: LIMIT/OFFSET pushdown into resolve_ids() (unsafe — QueryHook needs the full ID set for post__in) and mysqli_poll parallel legs (deferred per the Phase-1 note).

Verification

  • PHP: 120 tests / 265 assertions; php -l clean
  • JS: 35 tests; production Vite build clean
  • Markdown: lint clean
  • Live stack: cache 465ms → sub-ms on repeat; version invalidation confirmed on reindex_object; SEO canonical/robots/title verified

Upgrade

git pull && composer install && npm install

No schema change (hof_db_version 1.2.0). No reindex needed. All three features are additive — SEO defaults are conservative, the cache is transparent and version-invalidated, analytics capture is passive.

Full changelog: CHANGELOG.md

v0.12.0-alpha — wow-kit complete: spin wheel, saved bin, matrix + AND resolver + multisite

29 May 03:28

Choose a tag to compare

The feature-complete milestone. The two designed-but-unbuilt "wow-kit" facets ship, the matrix returns on real intersection semantics, and the plugin goes multisite-aware. Every planned facet (16 types), source (WooCommerce, ACF, Meta Box, Pods), and capability is now in.

What's new

Spin-the-wheel facet (spin_the_wheel)

A gamified single-select picker: a cosmetic conic-gradient dial over a real, keyboard- and screen-reader-accessible radiogroup. Spin lands on a random value (or pick one directly); it degrades to a plain single-select with JS off. Same hof[<name>]=value URL shape as radio, so the resolver treats it identically. Covered by spin.test.js (6 cases).

Saved-bin facet (saved_bin)

A drag-and-drop / click comparison bin. Shoppers pin items — via the new [hof_bin_button id="…"] shortcode or any draggable data-hof-bin-add element — into a per-site localStorage bin, then a Show only saved toggle filters results to the bin. It's the only facet that filters by object ID rather than an index value, riding a new reserved resolver key _bin_ids (a plain ID intersection, parallel to Visual DNA's _visual_ids). Covered by bin.test.js (8 cases).

AND-within-facet resolver

A facet can now require an object to carry every selected value, not just one (settings.match = 'all', an any/all control on multi-value facets in the admin). It composes naturally with the existing INTERSECT engine: instead of one IN-list leg, the facet emits one single-value equality leg per value, and INTERSECT ANDs them — each a covering-index scan with no DISTINCT, so AND mode is at least as fast as OR.

Intersection matrix (matrix) — un-retired

The Venn/UpSet matrix returns. It was originally shelved for an OR/AND semantic mismatch and unreadable selection state; it's back on the new AND-within-facet semantics (which it defaults to), paired with an explicit selected-state dot, a per-row count bar, and the active-filters chip strip — the readability gap the first version lacked.

Multisite support

The index table and facet options were already per-blog, so this is a lifecycle concern: Activator::activate( $network_wide ) installs across every existing site on network activation, install_new_site() (on wp_initialize_site) seeds tables for sites added later while HOF is network-active, the plugins_loaded auto-heal self-installs any blog whose schema is behind, and uninstall.php runs its opt-in cleanup per site (checking the removal gate per site, so one site opting in never wipes a sibling). Now validated on a live WP_ALLOW_MULTISITE network — stable.

Verification

  • PHP: 98 tests / 221 assertions; php -l clean across the tree
  • JS: 35 tests (refresh, swiper, spin, bin); production Vite build clean
  • Markdown: lint clean
  • Live stack (MySQL 8, 100k+ indexed rows): the AND resolver returned AND = 150 = exact co-occurrence count (vs OR = 10,000), and all three new server-rendered facets render without warnings

Known limitations (deferred, gated on a live third-party environment)

  • Breakdance native placement — Element-Studio bundle format needs a live Breakdance install to author + validate; placement works today via [hof_facet].
  • Divi Visual Builder — the native module is authored; its VB render path needs a live Divi install to validate. Theme Builder (main-query) placement already works.
  • Pods table / wp_podsrel storage — the indexer reads postmeta; the meta_in_use() gate cleanly skips table-stored Pods fields rather than mis-suggesting them.
  • Time-of-day faceting — intentionally not a facet type; a raw seconds-range is poor UX and a dedicated clock picker isn't warranted yet.

Upgrade

git pull && composer install && npm install

No schema change since v0.11.0-alpha (hof_db_version 1.2.0). No reindex needed. New facets and the AND mode are opt-in per facet; existing configs are unaffected.


Full changelog: CHANGELOG.md

v0.11.0-alpha — custom-field sources: ACF, Meta Box & Pods

29 May 03:29

Choose a tag to compare

The custom-field source line lands: HOF now inspects ACF, Meta Box, and Pods fields and one-click-suggests facet configs — backed by an indexer that resolves ID-references to human labels and normalizes dates. Also includes the Divi bridge and an Action Scheduler-backed background reindex.

What's new

Source integrations — suggestion providers

Each integration inspects its plugin's registered fields and proposes ready-to-use facet configs over GET /integrations/{woocommerce,acf,metabox,pods}/suggest, gated by an is_active() check and a shared meta_in_use() data filter so only fields with real data are offered.

  • ACF — maps every meaningful field type to a display: select→dropdown, multi-select/checkbox→checkbox, radio/button_group→radio, true_false→toggle, number/range→range, text/textarea/email/url→search, taxonomy (Save Terms on)→taxonomy facet, relationship/post_object→resolve='post', user→resolve='user', Save-Terms-off taxonomy→resolve='term', date_picker/date_time_picker→date_range.
  • Meta Box — reads the meta_box registry; scalar/select/date/taxonomy types plus post/user (resolve) and taxonomy_advanced (comma-separated term IDs → resolve='term').
  • Pods — post-type Pod fields, including pick relationships mapped by pick_object (post→post, user→user, taxonomy→term). The meta_in_use() gate cleanly skips table-stored Pods fields this indexer can't serve.

Indexer — the resolve mechanism

  • ID → label resolution for meta facets whose values are IDs: 'post' (post title, via wp_posts), 'user' (display name, wp_users), and 'term' (term name + term_id, wp_terms) — resolved in one batched query per facet per reindex batch.
  • Serialized-array adapter — explodes ACF checkbox / multi-select arrays into one bucket per scalar value.
  • Comma-splitextract_ids() splits comma-separated ID strings so Meta Box taxonomy_advanced lands (a no-op for the plain-ID cases).
  • ACF date normalizationdate_picker's compact Ymd is parsed to a UTC-midnight epoch (round-trip validated), so date_range facets scale correctly.
  • Action Scheduler-backed background reindex — chunked, self-chaining full rebuilds under the hooked-on-facets group (with a wp_cron fallback), so large catalogs reindex without blocking a request.

Divi bridge

Divi core ships no scoped per-loop query filter, so the bridge splits Divi's two surfaces: Theme Builder archive/index templates run the main query (already filtered through QueryHook), and a Blog module on a page uses the global hof_divi_query_args() helper from the developer's own scoped pre_get_posts. Placement gets a native ET_Builder_Module (slug et_pb_hof_facet); the [hof_facet] shortcode remains a fallback.

Known limitations (deferred)

  • Divi Visual Builder — the native module is authored; its VB server-render path needs validation against a live Divi install.
  • Pods table / wp_podsrel storage, ACF/Meta Box time-of-day fields — deferred (see plan.md).

Upgrade

git pull && composer install && npm install

Schema bump to hof_db_version 1.2.0 runs automatically on load (the plugins_loaded auto-heal). A reindex picks up newly-suggested fields; suggestion providers are additive and never alter existing facets.


Full changelog: CHANGELOG.md

v0.10.0-alpha — third page builder bridge: Breakdance

26 May 06:38

Choose a tag to compare

Third page-builder bridge. HOF now filters Breakdance Post Loop Builder loops too — though Breakdance forced a different shape than its predecessors.

What's new

Breakdance bridge

Breakdance is the exception. Elementor gave us elementor/query/{id} and Bricks gave us bricks/posts/query_vars — scoped, documented filters to bind a specific loop. Breakdance documents no equivalent: its Post Loop Builder is customized in the builder, and the only PHP injection point is the user-authored Array Query. So the binding is a documented recipe, not a hook.

Loop binding. Set a Post Loop Builder's Query type to Array Query and return the HOF helper, passing your loop's own args:

return hof_breakdance_query_args( [
    'post_type'      => 'product',
    'posts_per_page' => 12,
] );

When the request carries ?hof[*] filters, the returned args gain a post__in of the matching IDs (or [0] when nothing matches, so the loop shows "no results" rather than everything). With no HOF filters active, your base args are returned unchanged — HOF wins on post__in; you keep control of post type, ordering, and per-page.

Placement. Drop the existing [hof_facet] shortcode into a Breakdance Shortcode/Code element.

The logic lives in Breakdance::query_args() (unit-testable with a mocked resolver); the global hof_breakdance_query_args() is a thin delegate, loaded unconditionally from register_hooks() (an inert function definition until Array Query calls it). 7 PHPUnit cases; full suite 27/27.

Known limitations

  • No native placement element yet. Breakdance elements are Element-Studio directory bundles registered via registerSaveLocation(), not a registerable PHP class. Authoring that format needs validation against a live Breakdance install, so it's deferred — placement uses the shortcode for now.
  • One manual step per loop. Unlike Elementor (Query ID) and Bricks (CSS class), the Array Query recipe means pasting one line into each loop you want filtered. That's the cost of Breakdance lacking a scoped query hook; if it ships one, the binding moves there and this recipe becomes a fallback.

Upgrade

git pull && composer install && npm install

No DB changes. No reindex needed. The helper is inert until invoked from a Breakdance Array Query — safe to deploy on non-Breakdance sites.

v0.9.0-alpha — second page builder bridge: Bricks

26 May 06:21

Choose a tag to compare

Second page builder bridge lands. HOF now filters Bricks Builder query loops the same way it filters Gutenberg Query Loops and Elementor Loop Grids — explicit, opt-in, no magic detection. Inert when Bricks isn't installed.

What's new

Bricks bridge

Two halves, both behind a single Bootable service that no-ops cleanly when Bricks isn't active.

Facet placement element. Drop the new Hooked Facet element anywhere on a Bricks page and set the slug of a facet you created in the HOF admin. Mirror of the hof/facet Gutenberg block, the [hof_facet] shortcode, and the Elementor widget — all four surfaces render via the same Renderer service, so markup is identical no matter where the facet sits.

Loop binding via CSS class. On the query-loop element you want filtered (Style → CSS classes), add the class hof. HOF hooks bricks/posts/query_vars and applies post__in directly from the URL's ?hof[*] state. A class (rather than the element's CSS ID) is the binding key because it's repeatable across loops and doesn't force a unique HTML id. Multiple bound loops or a different convention:

add_filter( 'hof_bricks_query_ids', fn() => [ 'shop', 'recipes' ] );

Why the boot timing differs from Elementor: Bricks ships as a theme, loaded on after_setup_theme — after HOF's plugins_loaded:5 boot — so \Bricks\Elements doesn't exist yet when the bridge wires up. The passive bricks/posts/query_vars filter registers at boot regardless (it's inert until Bricks fires it during a render); element registration defers to init:11 (after Bricks registers its own elements) and gates on the class being present. If Bricks isn't active, that callback returns early — no element, no cost.

Ships with 11 PHPUnit cases: hook registration, element registration, CSS-class matching (incl. multi-class strings and array tolerance), the URL-filter no-op paths, post__in application, the [0] no-results sentinel, telemetry recording, and the filterable binding list.

Minor: CI

Removed the advanced CodeQL workflow (.github/workflows/codeql.yml) in favor of the repo's CodeQL default setup. The advanced config's SARIF upload was rejected on every code-touching PR — "CodeQL analyses from advanced configurations cannot be processed when the default setup is enabled" — leaving a persistent (non-blocking) red check. Default setup already scans javascript-typescript and actions. To keep the broader security-extended query suites the old workflow ran, switch default setup's query suite to extended in Settings → Code security.

Upgrade

git pull && composer install && npm install

No DB changes. No reindex needed. The bridge is inert if Bricks isn't loaded — safe to deploy on non-Bricks sites.

Not yet verified on a live Bricks install — the _cssClasses binding is fully unit-tested but hasn't been smoke-tested against a running Bricks query loop. Worth a quick check before relying on it in production.

v0.8.0-alpha — polish pass: theming foundation, admin clarity, pagination

24 May 21:27

Choose a tag to compare

Six commits' worth of design-system foundation, admin polish, and a new pagination facet. No DB changes; safe to deploy in place.

Highlights

New: pagination facet

Drop [hof_facet name="pages"] (or the Gutenberg block) into your sidebar/footer for a numbered « 1 2 3 … 12 » nav with iOS-17 pill chrome. Server-rendered for SEO + no-JS fallback; JS upgrades clicks to pushState + refresh so the result region swaps in place without a full page reload. All ?hof[*] filters survive pagination. Configurable from the editor: per-page override, neighbors visible (0–5), first/last + prev/next toggles.

New: design system foundation

50 new --hof-* CSS variable tokens across every facet — brand colors, radius scale (xs / sm / md / lg / xl / pill), font sizes, focus rings (hard + soft), shared input/label/count/option chrome, named shadow scale. Per-facet specifics filled in where the shared tokens can't cover it.

// Override the brand color and every facet picks it up:
add_filter( 'hof_public_css_tokens', function ( $t ) {
    $t['--hof-primary']   = '#0ea5e9';
    $t['--hof-radius-md'] = '12px';
    return $t;
} );

Cascade bug fixed: hof_public_css_tokens was effectively dead before this release because the inline <style> block fired at wp_head:5, before the bundled stylesheet enqueued at :8. The bundle's defaults silently overrode any filter value for any token the bundle defined (which was most of them). Now bound at :20, so filter overrides actually win. If you've been wondering why your filter overrides "didn't do anything" — they should start working after this upgrade.

Admin polish

  • Editor preview no longer blows up at narrow widths. Container queries replaced the 960px media query that fired at the wrong moment (the editor sits inside a 240px facet list inside a 168px nav inside WP admin's own sidebar — viewport-only queries couldn't see the actual cramped space). Preview now clamp(240px, 32cqi, 320px) and collapses to single-column at 640px container width.
  • Display picker reorganized: options grouped by what shoppers do (Select / Range / Boolean / Text / Visual / Navigation / Display-only) instead of one flat list.
  • Three relabels for clarity (internal slugs unchanged — stored configs keep working):
    • AskNatural-language search
    • Visual DNAColor match (drop an image)
    • View (no source)Display-only (no filter)
  • Swatch tiles get a hover/focus tooltip showing full term + count. Crucial for the long-tail end where tiles shrink to 36px and the label below truncates.

Removed: 2D slider

The 2D slider display landed as a visual skeleton (ca9831a) and a resize-handle iteration (b28468b), then got shelved during the natural-language search pivot. Never reached working interactive filtering. Removed entirely — public/src/two-d-slider.js (229 lines) + ~125 CSS rules + the Renderer method + admin editor refs + REST/validation entries. Saved configs that pointed at two_d_slider don't crash — they fall through to checkbox via Renderer's default arm.

Upgrade

git pull && composer install && npm install

No DB changes. No reindex needed. Build the bundle after deploy: npm run build.

Honest gaps

Three places that still need human eyes — all called out in their respective commits:

  1. Visual diff against pre-0.8 not done. CSS bundle's defaults claim to be byte-identical to before but only the test gates verify code correctness, not pixel correctness.
  2. Pagination renderer has no automated tests (depends on $wp_query global + WP function helpers that aren't worth the Brain Monkey setup for a UI component).
  3. Sidebar widgets that render before the main query may see $wp_query->found_posts as 0 and silently render no pagination. Acceptable for v1.

v0.7.0-alpha — first page builder bridge: Elementor

24 May 20:19

Choose a tag to compare

First page builder bridge lands. HOF now filters Elementor Loop Grids the same way it filters Gutenberg Query Loops — explicit, opt-in, no magic detection. Brought a PHPUnit harness along with it.

What's new

Elementor bridge

Two halves, both behind a single Bootable service that no-ops cleanly when Elementor isn't installed.

Facet placement widget. Drop the new Hooked Facet widget anywhere on an Elementor page, set the slug of a facet you created in the HOF admin. Mirror of the hof/facet Gutenberg block and the [hof_facet] shortcode — all three surfaces render via the same Renderer service, so markup is identical no matter where the facet sits.

Loop binding via Query ID. On the Loop Grid / Posts / Products widget you want filtered, set Advanced → Query ID to hof. HOF hooks elementor/query/hof and applies post__in directly from the URL's ?hof[*] state. Multiple bound loops or a different naming convention:

add_filter( 'hof_elementor_query_ids', fn() => [ 'shop', 'recipes' ] );

Why Query ID instead of piggybacking on pre_get_posts: Elementor calls $query->query($args) after the elementor/query/{id} action fires, and WP_Query::query() resets query_vars from $args — so setting an opt-in flag like hof_facet_target on the query would be discarded. Mutating post__in directly is robust and matches Elementor's own documented pattern.

PHPUnit harness

First PHP test suite. PHPUnit 11 + Brain Monkey + Mockery. No live WordPress — WP functions stub per-test. Run with composer test. Tests live under tests/php/; the bridge ships with 9 tests covering presence-guard, widget registration, the Query ID filter (incl. dedupe + sanitize), bind_query's no-op paths, post__in application, the [0] no-results sentinel, and telemetry recording. CI gates on the suite — the previous composer run-script test --if-it-exists was a no-op due to an invalid flag; that's fixed too.

Minor refactor (testability)

Two narrow interfaces extracted from final services so consumers can mock past them:

  • HookedOnFacets\Filter\IdResolver — just resolve_ids()
  • HookedOnFacets\Telemetry\LoopHookRecorder — just record_loop_hook()

Resolver and Recorder implement them. The DI container still resolves to the concrete classes — only consumer type hints changed. SHEPDESIGN.md documents the convention.

Upgrade

git pull && composer install && npm install

No DB changes. No reindex needed. The bridge is inert if Elementor isn't loaded — safe to deploy on non-Elementor sites.

v0.6.1-alpha — test harness + CI hygiene

24 May 19:54

Choose a tag to compare

Tooling-only patch release — no plugin behavior changed. Safe to skip if you're already on 0.6.0-alpha and not contributing.

What's new

First JS test suite. Vitest 4 + jsdom 29 wired into the project (npm test / npm run test:watch). 15 tests covering:

  • Swipe-deck runtime (public/src/swiper.js): commit/skip via click + keyboard, deck-position math, Card vs Grid deckDepth, reset coalescing into a single change event, done-state + button-disable when the deck empties.
  • URL-state reconciliation (patchSwiper in public/src/refresh.js): count patching, server-included → right-swiped + hidden + checked sync, popstate restore when the server clears a value we'd locally right-swiped, preservation of local left-swipes.

Markdown lint is actually gating now. The Markdown · Lint CI job had continue-on-error: true set since it landed, quietly hiding 122 markdownlint findings. This release:

  • Adds .markdownlint.json that turns off the rules that fight this project's writing voice — MD013 (long prose lines are intentional), MD060 (padded tables read better than |col|col|), MD034 (bare GitHub URLs are fine inline).
  • Fixes the 21 real readability issues MD022/MD032 flagged — missing blank lines around headings glued to lists in plan.md, SECURITY.md, SHEPDESIGN.md.
  • Drops continue-on-error: true so future regressions can't sneak through.

Minor production tweaks (testability)

These are the only non-doc/non-test changes to runtime code:

  • initSwiper(root, { signal }) — accepts an AbortSignal for listener teardown. Standard pattern; needed because vi.resetModules clears the module cache but leaks document-level listeners the prior copy registered.
  • patchSwiper is now a named export from public/src/refresh.js — was a module-private helper; now reachable from tests without going through fetch + DOMParser.

Upgrade

git pull && npm install

No DB changes. No reindex needed. No admin-visible changes.