Approach & Setup
- Built as an ACF Block V3 registered directly in
functions.php— chose this over a plugin like WPCodebox because theme-level registration is more portable and easier to version control - Used a
renderTemplatePHP file rather than a JS-rendered block — the original plan was JS rendering withwp_localize_script, but that failed silently in the ACF editor context, so I switched to full PHP rendering early and never looked back - ACF field group registered via JSON import (Custom Fields → Tools → Import) rather than
acf_add_local_field_group()— the latter kept missing ACF’s sync scanner regardless of hook priority
PHP Architecture
- All sanitisation centralised in one normalisation pass (
array_map) so helper functions can trust their inputs are clean - Helper functions wrapped in
function_exists()guards — ACF can call the render template more than once per request (editor + frontend), which caused a fatalCannot redeclareerror before this fix - Switched from
ob_start()/ob_get_clean()per row to string concatenation — smaller memory footprint, no output buffer stack overhead get_template_directory_uri()cached in astaticvariable insidecct_icon()so it resolves once per request regardless of icon count- Icon names whitelisted against an allowed array — prevents any path traversal if field data is ever tampered with
Design & UX Decisions
- Matched the provided mock closely: name as blue link above the logo, single amber star rather than a 5-star row,
$prepended automatically to flat fees,Unverifiedrendered as a distinct pill - Used asset folder SVGs (
/assets/*.svg) via<img>tags rather than an inline sprite — cleaner markup, browser caches the files, and swapping icons later requires no PHP changes - White tooltip with blue border chosen for contrast and readability against both light and dark row backgrounds
- ARIA table roles (
role="table"→role="rowgroup"→role="row"→role="columnheader"/role="cell") implemented correctly after Page Speed Insights flagged the original flat structure
JavaScript
- Stripped to ~22 lines — WordPress enqueues with
deferso no IIFE, no DOMContentLoaded check, no “already initialised” guard needed - Single
keydownlistener ondocumenthandles Escape for both tooltips and claimed badges instead of one listener per element - Event delegation kept the file minimal and robust regardless of how many rows render
How AI Was Used
I used Claude (Anthropic) inside claude.ai minimally.
- Used it to catch issues I described in plain English (“fatal redeclare error”, “path shows as just
marker.svg“, “ARIA parent role warning”) and get precise fixes rather than having to dig through docs - The refactor pass was explicitly prompted — asked Claude to make the existing code safe and fast, which produced the
function_existsguards, whitelist,staticcache, andob_startremoval
Future Iterations
- Fragment caching — wrap the rendered HTML in a WordPress transient keyed to the block ID and a hash of the field data; only re-renders when ACF content actually changes, which matters if the block ends up on a high-traffic page
- Extract to a proper class file — move
CCT_Renderer(or thefunction_existshelpers) into a standaloneclass-cct-renderer.phpautoloaded viafunctions.php, keeping the render template thin and the logic testable - Unit tests — with the logic in a class, helpers like
dollar_score,format_reviews, and the fee prefix logic are straightforward to cover with PHPUnit; edge cases likefee_score = 0, empty companies array, or malformed URLs are the obvious first tests - Block variations or InnerBlocks — if the table needs to support different column configs (e.g. a shorter 3-column version), ACF block variations would be cleaner than adding conditional field logic
- Schema markup — add
ItemList+LocalBusinessstructured data to the rendered HTML so the rankings have a chance of appearing in rich results - Lazy-loaded rows — if the company count grows beyond ~10, render the first 3 immediately and fetch the rest via a REST endpoint on scroll
- Editor preview parity — the current
$is_previewflag just adds a CSS class; a real next step would be a proper block.jsonexampledataset so the block looks populated when browsing the block inserter