Changelog
Release history for TAW Theme and the taw/core framework package.
August 2026 — Metabox: readonly fields get a lock icon
A follow-up to the readonly field option shipped earlier the same day: the muted, non-interactive styling alone wasn't a strong enough signal that a field is intentionally locked rather than just visually broken. Every readonly field's label now gets a small lock icon (dashicon, with a "Managed externally — read-only" tooltip) — wherever the label renders, including inside group and repeater sub-fields.
August 2026 — Metabox: readonly fields
Any Metabox (and Options Page) field now accepts 'readonly' => true, rendering it as non-interactive text instead of an editable <input>/<select>/<textarea> and skipping it entirely in save() — a forged $_POST key is ignored regardless of what a tampered-with rendered form contained. This closes a real gap: content synced in from an external source (e.g. a git-tracked .md-file pipeline) previously still rendered as an editable field in wp-admin, inviting edits that the next sync would silently overwrite.
readonly composes with group (propagates to every sub-field) and repeater (disables Add/Remove/reorder for the whole row list, not just sub-field values, and propagates to every sub-field in every row) rather than being a field type of its own. See Readonly fields for the full breakdown, including the one deliberate gap: VisualEditorEndpoint and the fields:set/seo:inject CLI commands are separate write paths that don't consult this flag yet.
August 2026 — ViteLoader: optimizer-exclusion hardening
Vite's compiled output is already minified, hashed, and (for JS) split into ES modules that require exact, un-mangled execution order — a generic optimization plugin re-processing it can only make things worse. ViteLoader's module scripts, their modulepreload/preload <link> tags, and every Vite-extracted stylesheet now carry data-no-optimize="1" data-cfasync="false" data-no-defer="1" data-no-minify="1" — standard exclusion signals WP Rocket, Autoptimize, Perfmatters, and LiteSpeed Cache all document and honor.
This is defensive hardening, not a fix for the specific incident that motivated it: a client site (mlizardo.com) hit a hard, silent failure when the host's JS optimization plugin (WPMUdev Hummingbird) CDN-rehosted the entry bundle onto a CDN with no CORS support — module scripts always require CORS, so the browser hard-blocked the fetch and the entire bundle (Alpine.js, all core init) never ran, with zero visible page errors. Hummingbird's CDN-rehosting feature rewrites the raw HTML buffer directly, bypassing WordPress's tag filters entirely, so these new markers don't help against it specifically — that class of tool still needs manual exclusion-list configuration in its own admin UI. See Assets and Vite integration for the full incident and a fast diagnostic.
August 2026 — SEO output mode: a manual override for unreliable plugin detection
SeoMeta::isSeoPluginActive() detects Yoast/RankMath/SmartCrawl via version constants (WPSEO_VERSION, RANK_MATH_VERSION, WPMU_DEV_SITE_ID) so TAW's own <title>/meta/OG/JSON-LD output stands down rather than duplicating a real SEO plugin's own tags. That detection is inherently best-effort — not every install of every plugin defines a stable constant. This surfaced on a real production site: SmartCrawl (WPMU DEV) was active and rendering correct, complete SEO output, but WPMU_DEV_SITE_ID wasn't defined for that install, so TAW never detected it and kept rendering alongside it — duplicate og:title (one correct, one a stale "Index" placeholder), duplicate <link rel="canonical">, and duplicate JSON-LD <script> blocks, simultaneously.
Added: a new SEO Output setting on Schema's existing "SEO Schema" admin page (SeoMeta::OUTPUT_MODE_FIELD) — Automatic (today's detection-based default), Always On (ignore detection, TAW's output always renders), or Always Off (TAW stands down entirely, deferring to whatever's actually installed). The settings page itself now always registers, regardless of current detection state, so the override stays reachable even when detection is currently wrong in either direction.
August 2026 — SeoMeta: canonical dedup + static front-page identity fallback
Two related gaps in SeoMeta's <head> output, both only visible once you check the actual rendered HTML rather than trusting the code's own docblocks:
- Duplicate
<link rel="canonical">.SeoMeta::renderHeadTags()prints its own canonical tag onwp_head(priority 1), but never removed WordPress core's defaultrel_canonical()output — every page shipped two identical canonical tags. og:title/twitter:titleon a static front page could show a raw, possibly internal-onlypost_title. A static front page (aPageselected under Settings → Reading) is stillis_singular(), so it never reached the dedicatedis_front_page()/is_home()fallback branch that already existed for the dynamic home/blog-index case — the same gap WordPress core's ownwp_get_document_title()already closes for the real<title>tag, butSeoMetahadn't mirrored for OG/Twitter.
Fixed: remove_action('wp_head', 'rel_canonical') runs alongside SeoMeta's own hook registration, and singularContext() now falls back to get_bloginfo('name')/get_bloginfo('description') for a static front page when no per-post meta title/description is set — matching the treatment the dynamic front-page/home branch already had.
August 2026 — BaseBlock: a script's own imported CSS now reaches production
A block's script.js is allowed to import '...css' — e.g. a third-party lightbox library's stylesheet. Vite's dev server injects that CSS automatically, so it always worked under npm run dev. A production build behaves differently: Rollup extracts that CSS into its own file and records it on the script's manifest entry (css: [...]), not the block's style.scss/style.css entry. BaseBlock::enqueueProdAssets() only ever read the latter, so the script's own CSS silently never reached the page in any real production build — while looking completely fine in local dev, the one environment most developers test in.
Fixed: enqueueProdAssets() now also reads $manifest[$js_key]['css'] and enqueues it, using the same critical/non-critical inline-vs-async logic already used for the block's dedicated stylesheet. See Assets and Vite integration for the updated per-block assets note.
July 2026 — ViteLoader: manifest cache invalidates on deploy, not on a timer
ViteLoader::getManifest() cached the parsed Vite manifest under a fixed key for 24 hours. On a site with a persistent object cache (Redis/Memcached), that meant a deploy which rotated asset filenames could leave the site serving the previous build's hashed JS/CSS URLs — both genuinely 404ing — for up to a day, unless something explicitly flushed the object cache afterward. This is exactly what happened on a real client site: two entry-point assets 404'd in production while the manifest on disk was already correct.
Fixed: the cache key now includes the manifest file's own mtime. A deploy necessarily rewrites manifest.json with a new mtime, so the previous build's cache entry is orphaned automatically — no deploy hook, no manual flush, no reliance on remembering. If a site is currently affected, one manual object-cache flush clears it immediately; going forward, this can't recur.
July 2026 — Emailit: multipart/alternative emails, not HTML-only
EmailConfig::useEmailit() sent every HTML email (which is all of them, now that Form::sendWithTemplate() is active) with no plain-text alternative part — text was explicitly set to null and dropped before ever reaching the Emailit API. A missing plain-text part is one of several small factors that can compound with new-sender reputation issues on some spam filters.
Fixed: HTML sends now include a plain-text fallback, derived from the HTML via wp_strip_all_tags($message, true) (the whitespace-collapsing variant, since MJML's nested-table output otherwise leaves the plain-text version full of stray blank lines). Both html and text go to EmailService::send() together — Emailit's API accepts them as independent, non-exclusive params. Non-HTML sends are unaffected.
July 2026 — Escaping fix: form notification/confirmation emails
Form::sendWithTemplate() built the {{all_fields}} block (and each individual {{field_id}} / {{client_name}} placeholder) by concatenating raw submitted values straight into the HTML email sent to both the site admin and the submitter themselves. A field value containing <, &, or a full tag broke the email's layout for both recipients — a real gap since this path emails the submitter their own data back.
Fixed: every value going into a {{...}} placeholder is now wrapped in esc_html() before it reaches the template. This applies uniformly, including inside attribute contexts (e.g. a mailto: href built from an email field) — HTML-entity encoding is correct there too, not just in element text. There's intentionally no raw/unescaped variant of these placeholders.
July 2026 — Emailit transport: fixed a silent no-op
EmailConfig::useEmailit() has never actually sent mail through Emailit, since it was first documented in v1.16.110 — interceptForEmailit() referenced \Emailit\Client, a class that has never existed in the real emailit/emailit-php SDK (the actual client class is \Emailit\EmailitClient). Every site with Emailit configured silently fell back to plain wp_mail() on every send, with no error logged anywhere.
Fixed: both references now point at the correct \Emailit\EmailitClient class. No change to the public API — EmailConfig::useEmailit($apiKey, $from, $fromName) is called exactly the same way as before; sites already "configured" for Emailit will simply start actually using it once they update.
emailit/emailit-php is now also required as a dev dependency in taw/core itself (previously only composer suggested), so this integration has real regression test coverage against the actual installed SDK.
July 2026 — Webhook payload: page_url + a customization filter
Every form's webhook payload now includes page_url — the full URL of the page the form was actually submitted from, captured server-side at render time (not read from the request's Referer header, which browsers and privacy tools can strip). The same registered form is often embedded on more than one page; this is what lets a downstream automation (n8n, Zapier, Make) tell those submissions apart without needing a separate form_id per page.
A new taw_form_webhook_payload filter runs on the payload right before it's sent — and before the HMAC signature is computed over it — letting a specific site add or override anything the default shape doesn't cover, entirely from that site's own inc/customizations.php, with no taw-core changes needed:
add_filter('taw_form_webhook_payload', function (array $payload, string $formId, int $postId, array $data) {
$path = wp_parse_url($payload['page_url'], PHP_URL_PATH) ?? '';
if (str_contains($path, '/financiera/')) {
$payload['destination'] = 'financiera-sheet';
} elseif (str_contains($path, '/fideicomisos/')) {
$payload['destination'] = 'fideicomisos-sheet';
}
return $payload;
}, 10, 4);
page_url is also stored on the submission's taw_submission CPT entry (visible in the admin detail view, next to Webhook Status and User IP) and shown in the Settings → Form Webhook page's payload example.
July 2026 — Security fixes from a full framework audit
A full pass across taw/core for anything that could let a site get compromised. One real, exploitable bug found and fixed; broader hardening applied everywhere it's safe to.
- Fixed:
Svg::sanitizeOnUpload()could leave an unsanitized file on disk.wp_handle_upload()moves an uploaded file to its final, publicly-servable location before this filter runs. Previously, if sanitization failed to positively confirm a file was safe — eitherenshrined/svg-sanitizereturningfalsefor malformed/unparseable XML, or (a second bug found in the same pass) throwing an uncaughtLogicExceptionfor well-formed XML with no<svg>root — the original file was silently left in place, unsanitized, at its uploads URL. Any user withupload_files(Author and above by default) could exploit this for stored XSS. Now: any failure to positively confirm the file is clean deletes it and rejects the upload outright, and the exception path is caught so it converges to the same rejection instead of crashing the request. - Added
if (!defined('ABSPATH')) { exit; }guards to the framework classes where it's safe to add them — direct defense-in-depth against a file being requested directly if a host doesn't block.phpexecution insidevendor/. Deliberately not added tobin/taw's CLI command classes,Framework.php, or the two Composerfiles-autoloaded bootstrap files (performance.php,utilities.php) — all four are used in contexts where WordPress is legitimately not yet bootstrapped, and guarding them breaksbin/tawoutright (verified against a real, unintentional regression during this fix, caught before shipping).
July 2026 — Per-form webhooks
Every form previously shared one site-wide webhook URL/secret (Settings → Form Webhook), regardless of which form actually submitted. Each form can now target its own webhook, resolved in precedence order:
- An admin-configured override for that specific form — a new Per-Form Webhooks table on the same Settings → Form Webhook page, one row per form registered via
Form::register(). - A code-level default set on the form itself, via a new
'webhook' => ['url' => ..., 'secret' => ...]config key. - The existing Default Webhook field — now the site-wide fallback for any form with neither of the above, rather than the only option.
Fully backward compatible: any site with the old single webhook already configured keeps firing it for every form exactly as before, until a form is given its own override. No migration needed.
July 2026 — Emailit transport: documented and wired into the scaffold
TAW\Support\EmailConfig::useEmailit() already existed in taw/core but was never documented or wired into taw-theme's scaffold. Now that it's actually in use for paying clients:
- New "Email" section in the
taw/coreREADME, and a matching section on thetaw/corereference page here, documentingEmailConfig::useEmailit()— routes allwp_mail()calls (form submissions, password resets, WooCommerce, etc.) through Emailit's API, with automatic fallback to plainwp_mail()if unconfigured or the API call fails. taw-theme'sinc/customizations.phpnow has a commented-out wiring block, matching the style of this scaffold's other opt-in features — gated on adefined('EMAILIT_API_KEY')check, so it's a genuine no-op on every site that doesn't define the constant.EMAILIT_API_KEY/EMAILIT_FROM_EMAIL/EMAILIT_FROM_NAMEare site-specific secrets that belong in each client's ownwp-config.php, never in the theme repo. Theemailit/emailit-phpSDK is only required on sites that opt in (composer require emailit/emailit-php), not a default dependency.
No behavior change to EmailConfig itself — this is documentation and scaffold wiring for an existing, already-working class.
July 2026 — Grid-view sidebar: sort controls
Two independent sort controls, each remembered per-browser:
- Folder tree — sort by name (A–Z/Z–A) or creation order (newest/oldest first), applied at every nesting level. Uses the terms REST endpoint's own
orderby=id(term IDs increase monotonically with creation), since WordPress terms have no native creation-date field — no new REST endpoint needed. - File grid — sort by name, upload date, or file size (largest/smallest first). Applied directly as
wp.media's existing Backbone query props (orderby/order/meta_key), the same bridge the folder filter already uses — no new AJAX filter needed for this half either.
File-size sorting needed one new piece: WordPress core only stores a file's size nested inside a serialized metadata array, which SQL can't ORDER BY. A new _taw_media_filesize postmeta value is now set on every upload, and backfilled once for pre-existing attachments the first time anyone sorts by size.
July 2026 — TAW Media: the dedicated screen becomes a full app
The dedicated Media → TAW Media admin screen (previously "Media → Folders") is rebuilt from an imperative vanilla-JS page into a full Alpine.js app, matching the Grid-view sidebar's look and adding real app capabilities the old screen never had:
- Lucide icons throughout, a toolbar (New Folder / Rename / Delete, context-aware), an overflow menu (Expand all / Display folder IDs), and folder count badges — the same visual language as the Grid-view sidebar.
- A breadcrumb above the attachment grid, and subfolders now show as clickable cards in the grid pane itself (not just the tree), matching the Grid-view sidebar's own folder cards.
- Direct file upload: drag OS files straight onto the screen (or use the Upload Files button) to upload directly into the currently open folder, via
wp/v2/media's own multipart upload support plus its taxonomy REST field, in the same request. - Multi-select + bulk actions: click attachments to select several, then bulk-delete or drag the selection onto a folder to move them all at once.
- The scoping fix from the Grid-view sidebar (a folder only shows what's directly inside it, not descendants' files too) applies here as well.
The underlying TAW\Core\Media\MediaFolders class and the taw_media_folder taxonomy key are unchanged — this is a UI/JS rewrite of one admin screen, not an API or data-model change.
July 2026 — Grid-view sidebar: scoping, navigation, and live-refresh fixes
A run of fixes and refinements to the Grid-view sidebar, in order:
- Fixed the Grid-view's folder click-filter always returning zero results —
WP_Query::parse_tax_query()auto-detects any top-level query key matching a registered taxonomy'squery_varand adds its own slug-based tax_query clause, AND-combined with the sidebar's own (numeric-ID-based) one, which never matched. Fixed by removing the raw key before the query runs. - Fixed a folder's Grid view also showing every subfolder's files mixed in —
tax_querydefaults toinclude_children: truefor hierarchical taxonomies; now explicitlyfalse, so a folder only shows what's directly inside it. - Replaced the initial subfolder "tiles" (which reused WP core's photo-thumbnail markup and had a clipped-icon rendering bug) with dedicated folder cards — their own markup, shown in a row above the photo grid rather than mixed into it — plus a breadcrumb above them for navigating back up.
- Fixed the grid not refreshing live after moving a file while still viewing its (unchanged) source or destination folder — Backbone's
props.set()only re-fetches on an actual value change, so the collection's own_requery()is now called directly instead. - Fixed folder-card count badges not updating after an in-view move — the card re-render's "already correct, skip" check compared folder IDs only, not their counts.
July 2026 — Grid-view sidebar: drag-and-drop fixes
Two fixes surfaced while testing the drag-and-drop refinement below:
- Folder count badges always read 0, regardless of actual contents. WordPress core's default taxonomy count callback only counts attachments with
post_status = 'publish'(or whose parent post is) — standalone Media Library uploads areinheritstatus with no parent, so they were never counted.registerTaxonomy()now sets'update_count_callback' => '_update_generic_term_count', which counts relationships directly. The underlying filtering/query was never affected by this — only the badge. Existing sites need a one-timewp term recount taw_media_folderto catch up on assignments made before this shipped; new assignments count correctly automatically from here on. - Drag ghost visually bled into the next grid thumbnail. WordPress's Grid view lays out attachments with
float: left+ percentage widths, a layout where Chromium's automatic drag-ghost snapshot is known to leak into an adjacent floated sibling. Fixed by setting an explicit drag image (dataTransfer.setDragImage(), just the thumbnail<img>) instead of relying on the browser's automatic capture.
July 2026 — Grid-view sidebar: drag attachments into folders
Two refinements to the Grid-view sidebar's drag-and-drop, on top of the existing folder-to-folder re-nesting:
- Grid thumbnails (single or multi-selected, via WordPress's own selection model) are now draggable straight onto a folder row to file them — a
dragstart/drop-target bridge on top of WordPress core's own Backbone attachment views, reusing the samewp/v2/mediaREST call the dedicated Folders screen already used for this. - Dragging a folder row (or an attachment) no longer triggers WordPress core's own "drop files to upload" overlay — that overlay fires on any drag reaching it regardless of what's being dragged, so internal drags are now kept from bubbling up to it while real OS file drags are left completely alone.
Full reference: taw-core README § "Media Folders".
July 2026 — Grid-view sidebar polish pass
A FileBird-inspired visual pass on the Grid-view sidebar: Lucide icons throughout, a toolbar (New Folder / Rename / Delete, context-aware and disabled until a real folder is selected), a collapsible sidebar (persisted via localStorage), an overflow menu (Expand all / Display folder IDs), per-folder expand/collapse chevrons, and folder count badges. Purely a UI layer on top of the existing tree/CRUD/live-filter behavior — no change to the REST routes or the ajax_query_attachments_args bridge underneath it.
Full reference: taw-core README § "Media Folders".
July 2026 — Media Folders Grid-view sidebar + vendored Alpine.js
TAW\Core\Media\MediaFolders gains a third admin surface: a FileBird-style sidebar bolted directly onto the default Media Library Grid view (upload.php's thumbnail grid), not just the dedicated Folders screen and the classic List view. Clicking a folder in the sidebar filters the grid live, via a new ajax_query_attachments_args filter plus a narrow JS bridge that reads/sets props on wp.media's existing Backbone query object — the same technique folder plugins like FileBird use, not a Backbone view override, so the existing Folders screen and List-view integration are untouched. The sidebar has the same full folder CRUD (create/rename/delete/drag-to-reparent) as the dedicated screen, and stays in sync with the List view's filter dropdown when switching between the two.
Alpine.js — used by every admin-side interactive widget in taw-core (Metabox fields, Options Page, the Icon picker, and now this sidebar) — is no longer loaded from a CDN. It's vendored as a static asset (assets/vendor/alpine.min.js, pinned to 3.15.12) and enqueued through a new shared TAW\Support\Alpine::enqueue() helper, since taw/core installs on arbitrary client sites where a CDN dependency for a required admin script isn't safe to assume.
Full reference: taw-core README § "Media Folders" and § "Dependencies".
July 2026 — Icon System + Media Folders
Two new opt-in subsystems, both following the same enable()-before-Theme::boot() pattern VisualEditor established.
New icon Metabox/OptionsPage field type (TAW\Core\Icons\Lucide): the full Lucide icon set (~1,750 icons) is now vendored directly inside taw-core (resources/icons/lucide/, refreshed via the new php bin/taw icons:sync command) — the wp-admin picker never makes a network call. Stores a bare icon name; Lucide::render($name, [...]) renders it as inline SVG in templates and needs no enable() call of its own, same relationship Svg::render()/Svg::inline() have to Svg::register(). A new GET taw/v1/icons REST route (opt-in, only registered when enabled) powers the picker's search.
New nestable Media Library folders (TAW\Core\Media\MediaFolders): a single hierarchical taxonomy on attachments, with a dedicated Media → Folders admin screen (folder tree, drag-and-drop attachment organization, an "Unfiled" pseudo-folder) plus a filter dropdown/column/bulk-action on the classic Media Library list view. Built entirely on WordPress core's own taxonomy REST support (wp/v2/taw_media_folder, and a taw_media_folder param on wp/v2/media) — no custom REST endpoint needed. Deliberately does not patch the native Grid view's Backbone internals, to avoid the fragility that comes with hooking WordPress core's JS views across version updates. Opt-in at the taw/core level, but the taw-theme scaffold's inc/customizations.php calls MediaFolders::enable() by default, so every new taw-theme site has it active out of the box.
Full reference: taw-core README §§ "Icon System" and "Media Folders", and this site's Icon System / Media Folders sections.
July 2026 — SEO meta system + site-wide audits
TAW has never owned per-post SEO meta (title tag, meta description, social/OG image) natively — every real site either has an SEO plugin installed or nothing at all. New TAW\Core\Seo\SeoMeta (wired into Theme::boot()) fixes this without fighting whatever else might be installed: with no SEO plugin active, it registers its own lightweight metabox and renders the actual <head> tags itself; with Yoast active, TAW's own UI and output both stand down entirely and it reads/writes Yoast's own meta keys directly instead, so Yoast's own UI stays in sync; with a different plugin active (RankMath, SmartCrawl), TAW's output stands down but write support isn't attempted — seo:inject refuses any meta write with a clear reason rather than guessing at that plugin's key scheme.
seo:extract/seo:inject extended: every dump now includes a seo_meta object (title/description/social image, its current source, and the post's own already-uploaded candidate images for recommendation) beside the existing page-copy blocks. Both commands gain --all for a genuine site-wide audit — seo:extract --all extracts every published page/post in one run; seo:inject --all applies it with per-post independence, so one page failing validation doesn't block the rest.
The audit-seo skill now analyzes SEO meta alongside page copy (empty/generic meta title or description as a Red Flag, social image recommended from existing uploads or asked about via AskUserQuestion when none exist), and supports "audit SEO for the whole site" as a trigger.
Caught two real bugs live-testing before shipping: og_image_id: 0 (extraction's own "no image set" sentinel) was being rejected as an invalid attachment on an unedited round-trip; and the wp passthrough command (from the prior release) wasn't forwarding piped/redirected stdin to the child process, breaking wp eval-file --style usage — fixed via the standard isatty() check so a live interactive session never blocks on stdin that was never meant to be forwarded. Verified end-to-end: single-post and site-wide extract/inject round-trips, plus a deliberately-broken post confirming --all's per-post isolation (6/7 succeeded, the broken one specifically reported, command correctly exits non-zero for the partial run while still applying everything that passed).
July 2026 — Local by Flywheel socket auto-detection
A recurring, easy-to-hit failure: every CLI command that boots WordPress (inspect, fields:get/fields:set, export:static, seo:extract/seo:inject) failed with "Error establishing a database connection" when run from an ordinary terminal under Local by Flywheel — DB_HOST in wp-config.php is just 'localhost', which resolves to the system default MySQL socket, but Local runs a separate MySQL instance per site on its own per-site Unix socket. Only Local's own "Open Site Shell" sets the environment variables that would make this resolve correctly.
WpLoader::autoConfigureLocalSocket() fixes this at the root instead of requiring a manually-typed -d mysqli.default_socket=... workaround every time: it reads Local's own sites.json (which maps every site's local path to a short site ID — the same ID that names its socket's containing directory), matches the theme directory against it, and points PHP's MySQL drivers at the real socket via ini_set() before wp-load.php ever connects. A pure no-op everywhere this doesn't apply (real hosting, CI, other local environments). Wired into all six commands that boot WordPress.
The same problem also affects the separate wp binary (WordPress's own official CLI) — a different process WpLoader's ini_set() fix can't reach. New php bin/taw wp <args> is a thin passthrough to the real wp binary (every argument forwarded exactly as given, unparsed) that resolves both the socket and --path automatically before shelling out. AGENTS.md's WP-CLI section now recommends it over a bare wp command.
Caught a real bug during live-testing: the socket-existence check used is_file(), which always returns false for a Unix domain socket (it only matches regular files) — silently failing every time even with everything else correct. Fixed to file_exists(), which doesn't discriminate by type. Verified end-to-end with zero manual flags: inspect, fields:get, seo:extract, and wp post list/wp option get/wp eval all confirmed working, plus error/exit-code passthrough on a deliberately-failing wp command.
July 2026 — SEO/copy audit loop
New php bin/taw seo:extract <post_id> walks the live Metabox field registry (the same one TAW\Core\Metabox\SeoContentIntegration already walks to feed Yoast/SmartCrawl), keeps only non-empty text/textarea/wysiwyg content — including inside repeater rows, recursively — and writes a hierarchical JSON dump grouped by block. Image/URL/post_select/layout fields are excluded by design: this is a copy audit, not a general field dump.
New php bin/taw seo:inject <post_id> writes an edited copy of that JSON back, with real safety rails, not a thin update_post_meta() wrapper: every field is validated against the live registry before anything is written (all-or-nothing, never a partial write); only text-bearing fields are accepted, anything else is rejected in favor of fields:set; repeater rows are merged into the current live row by index rather than replaced wholesale, so a non-text sub-field on the same row is never lost; a row-count mismatch against live data (someone edited the post in the admin meanwhile) refuses rather than guessing alignment; core post data (post_title/post_content/post_status) is never touched. --dry-run previews the sanitized values before any real write.
New audit-seo Claude Code skill in taw-theme performs the analysis itself — keyword presence (H1/H2), copywriting/CTA quality, readability — and reports Red Flags/Polish Opportunities, applying approved rewrites only after explicit batch confirmation, following the same content-writing safety model as populate-content/fields:set.
Verified end-to-end against a real dev site: extract → edit → dry-run → real inject → confirmed via fields:get, plus both safety rails (unknown field, wrong field type) tested and confirmed to refuse cleanly without writing anything.
July 2026 — Static site export + headless CORS
New php bin/taw export:static command (TAW\CLI\ExportStaticCommand) fetches every published page/post over HTTP against its own permalink, rewrites absolute site-URL references to root-relative paths (or an explicit --prod-url), and writes <dir>/<slug>/index.html plus the built Vite assets and wp-content/uploads/ into a self-contained bundle — deployable as-is to Cloudflare Pages, Vercel, or any static host. Forms and search deliberately stay dynamic (admin-ajax.php?action=taw_form_*, taw/v1/search-posts) rather than being frozen into the export.
New TAW\Core\Rest\Cors (wired into Theme::boot(), no-op unless TAW_HEADLESS_ORIGINS is set in wp-config.php — same opt-in pattern as Turnstile) opens up cross-origin access to both surfaces once the static bundle is served from a different domain: REST via WordPress core's own allowed_http_origins filter, admin-ajax.php via hand-rolled headers scoped to taw_form_* actions only. Always an explicit origin allowlist, never a wildcard.
New export-static Claude Code skill in taw-theme (.claude/skills/export-static/) walks through building assets, running the export, verifying the output, and reminding the user about the headless-CORS follow-up.
Two real bugs were caught live-testing against a real dev site before shipping: the command initially assumed Vite always builds to dist/ (this project's own vite.config.js uses public/build/ — now detects both, matching ViteLoader's own resolution order), and initially copied the Vite build flattened to the export root instead of its real theme-relative URL path (every CSS/JS reference would have 404'd once deployed — fixed to mirror the exact path the exported HTML references).
July 2026 — Get Started guides brought current with recent DX additions
The Quickstart and Setting up a new TAW project guides had fallen behind several recent framework additions. Both now cover: initializing git and pushing to a repository (a step that was missing entirely, despite being a prerequisite for framework-sync.yml to do anything), and the project-init skill as the recommended way to enable and verify the GitHub Actions PR permission plus walk through optional integrations.
setup-project.mdx additionally had a stale functions.php code example showing the pre-bootstrapFullSite() architecture (manual Theme::boot() + Theme::performance() calls wrapped in add_action('after_setup_theme', ...)) — replaced with the current two-line functions.php and an accurate description of what bootstrapFullSite() does, including the inc/ file split. Its "what was created" file tree was also missing .github/workflows/ and .claude/skills/, both of which ship from the first commit now.
A second pass caught two more real gaps in the new git-init step of both guides: the prerequisites list never mentioned needing git at all, and git remote add origin <your-repo-url> never explained where that URL comes from for a reader following the guide for the first time. Both fixed — git (plus an empty pre-created repo) is now a listed prerequisite, and a callout explains creating the repo first (on github.com, or via gh repo create --source=. --remote=origin --push as a one-step alternative).
July 2026 — PHPUnit unit test suite for taw-core
New tests/Unit/ suite (composer run test), using Brain Monkey to stub individual WordPress functions rather than requiring a real WordPress + MySQL install — fast, isolated tests of taw-core's own logic. Covers the message-resolution precedence every Forms validation rule shares (field-level override > form-level default > built-in default), RateLimiter's fixed-window logic, and Turnstile's configured/not-configured states plus verify()'s success/rejected/network-error/malformed-response paths with fail-closed behavior throughout.
This is a deliberate division of labor with taw-theme's bin/ci/smoke-test.php: that suite boots a real WordPress environment and exercises the full render path against a live theme (integration coverage); this suite covers taw-core's internal logic in isolation (unit coverage). Form::requiredMessage()/Form::emailMessage() were extracted out of Form::process() as small, independently-testable private methods (no behavior change) to make this possible without mocking the entire AJAX pipeline. Wired into taw-core's own CI alongside PHPStan.
July 2026 — update-theme composer.json/package.json fix
A Tier 2 diff on composer.json/package.json was being treated the same as prose docs — full-file overwrite once approved. That's dangerous for these two files specifically: a real client project's own added dependencies are additive and will never exist in the canonical taw-theme scaffold, so a whole-file overwrite would silently delete every client-added package. Fixed: update-theme now edits only the genuinely framework-relevant lines by hand for these two files, and tells the user plainly when a diff is just structural noise from additive dependencies rather than asking them to re-decide the same non-issue on every run. Found via real usage on a client project.
July 2026 — sync-remote skill
New Claude Code skill triggered explicitly ("sync with remote, please"): fetches and compares against origin, reconciles diverged history via a real merge (never rebase), stops for explicit human resolution on genuine conflicts rather than guessing a side, optionally runs phpstan before pushing, and always confirms the push separately — "sync" authorizes pulling and reconciling, not the push itself. Built after real cross-machine divergence on a client project (two independent commits both adding the same line to .gitignore), resolved cleanly by git's own merge algorithm — the case this skill generalizes.
July 2026 — dynamic smoke test in CI
New bin/ci/smoke-test.php boots a real, CI-provisioned WordPress + MySQL install with the theme active, creates a real post, and renders every registered MetaBlock + displays every registered Form against it, failing on any runtime error. Complements the existing static checks (php -l, the getData() signature check, PHPStan) by catching the class of bug none of them can see: an undefined function call, a template referencing a variable getData() never returned, WP API misuse — anything that only surfaces once the render path actually executes.
Runs as a new parallel job in ci.yml on every push/PR, and inside framework-sync.yml's verification gate against the post-sync codebase before a weekly update PR is opened.
July 2026 — project-init skill
New Claude Code skill picking up right after composer create-project + the first push: verifies gh CLI authentication, checks and (with confirmation) enables the GitHub Actions "Allow GitHub Actions to create and approve pull requests" permission framework-sync.yml needs, then triggers a real manual run to confirm the whole pipeline actually works rather than assuming it does. Also walks through optional per-project integrations — Cloudflare Turnstile, transactional email, CSS Studio, Visual Editor — as explicit yes/no questions instead of leaving them to be discovered later. None of these are enabled without an explicit answer.
July 2026 — automated framework-drift detection
New php bin/taw sync CLI command (taw/core v1.16.72) checks whether the installed taw/core version is behind the latest release, and whether a project's Tier 1/Tier 2 taw-theme scaffold paths differ from the canonical repo — without booting WordPress, so it can run in CI. --apply writes Tier 1 changes directly; Tier 2 is always report-only, same human-review requirement as the update-theme skill it shares logic with.
Both sync and update-theme now read a single shared manifest (taw/core's resources/update-manifest.json) for the Tier 1/Tier 2/never-touched path lists, instead of maintaining the same lists independently in two places.
A new .github/workflows/framework-sync.yml (Tier 1, so it propagates to every client project via update-theme) runs sync unattended on a weekly schedule: bumps taw/core, applies Tier 1, runs the same verification CI runs on every push, and opens a pull request with Tier 2 diffs for review — only if something actually changed and verification passed.
php bin/taw sync --json # report only
php bin/taw sync --apply # also write Tier 1 changes
July 2026 — form-level default validation messages
Adds a fallback tier between per-field {rule}_message overrides and the hardcoded English defaults: a top-level messages entry per rule (required, email, min_length, max_length, pattern, min, max). Lets a non-English site set its validation copy once per form instead of repeating a {rule}_message on every field. Precedence: field-level {rule}_message > form-level messages.{rule} > built-in English default.
Form::register([
'id' => 'contact',
'messages' => [
'required' => '%s es obligatorio.',
'email' => 'Correo electrónico no válido.',
'min_length' => '%1$s debe tener al menos %2$d caracteres.',
],
'fields' => [...],
]);
July 2026 — per-field custom validation messages
Extends the {rule}_message convention (previously only pattern_message) to required, the built-in email format check, min_length, max_length, min, and max — each optional, falling back to the existing generic default (with the field's label interpolated) when not set.
['id' => 'name', 'type' => 'text', 'required' => true, 'required_message' => 'Please tell us your name.'],
['id' => 'email', 'type' => 'email', 'required' => true, 'email_message' => 'That doesn\'t look like a real email address.'],
['id' => 'age', 'type' => 'number', 'min' => 18, 'min_message' => 'You must be 18 or older.'],
July 2026 — Forms API security hardening
Three additions to the Forms API's security posture, taking it beyond its existing CSRF-nonce + honeypot baseline:
Rate limiting — on by default (5 attempts/60 seconds, per IP, per form), backed by WP transients, no external cache dependency. Checked before the nonce check, since a flooding script doesn't need a valid nonce to cause load.
Form::register([
'id' => 'contact',
'rate_limit' => ['max' => 3, 'window' => 120], // override, or false to disable
'fields' => [...],
]);
Optional Cloudflare Turnstile bot verification — 'turnstile' => true, keyed via TAW_TURNSTILE_SITE_KEY/TAW_TURNSTILE_SECRET_KEY constants in wp-config.php (never a metabox/OptionsPage field — those are REST-readable by anyone with edit_posts). Fails closed on any network error; degrades gracefully (no widget, no check) rather than blocking submission when a form opts in but keys aren't configured, with a WP_DEBUG-only notice for developers.
Field validation rules beyond required: min_length, max_length, pattern (+ pattern_message), and min/max for number fields. Render as native HTML attributes for client-side UX; the authoritative check is always server-side.
Verified end-to-end against real forms on live sites before shipping: rate limiting via rapid requests at the live AJAX endpoint (confirmed blocked on the 6th attempt), Turnstile via Cloudflare's publicly documented test keys (always-pass, always-block, and the unconfigured fail-closed case), and every validation rule type via direct unit tests, including a malformed-pattern fail-safe.
July 2026 — populate-content skill, and a content-writing safety model
New Claude Code skill: populate-content fills real field values on a real post from a document, list, or plain-language description the user provides — e.g. "fill in the team_members repeater on the About page with this list". It resolves the target post and field(s), maps source content to a field's shape (asking for confirmation on ambiguous mappings, e.g. repeater sub-fields that don't obviously correspond to the source document's structure), and writes via fields:set.
Because this is a skill that writes directly to a live database, it follows a mandatory safety model — documented once in AGENTS.md § "Content-writing safety model" and referenced by every skill that writes content, not duplicated per skill:
- Always
--dry-runbefore any real write. - Always confirm before overwriting a non-empty existing value — old vs. new shown side by side.
- Always confirm before writing to a
publish-status post, regardless of whether the field is currently empty. - Batch operations get one confirmation for the entire plan, shown up front — never expanded mid-batch without re-confirming.
- Extra scrutiny on
wysiwyg/'sanitize' => 'code'fields when the source content came from outside the conversation. - Never touches
post_title/post_content/post_status— Metabox/OptionsPage meta only. - No wildcard/glob mass-edits — every affected post and field enumerated up front.
make-metablock (screenshot-sourced blocks) and figma-to-block (Figma-sourced blocks) now ask, once real content is available and a target post exists, whether to populate real extracted values, leave fields as template fallbacks, or fill with Lorem Ipsum placeholder content — delegating any real write to populate-content rather than writing meta directly themselves. build-page asks this once per page instead of once per section for a design-sourced brief, and passes the answer down so individual section builds don't re-ask.
July 2026 — fields:get / fields:set
New taw/core v1.16.68 CLI commands give an agent (or a script) a safe, first-party way to read and write Metabox/OptionsPage field values directly, without going through wp-admin. This is the same read/write primitive VisualEditorEndpoint already uses for its REST-driven saves — Metabox::get_field_config() to resolve a field's type from the live registry, then the matching type-aware getter (get_repeater(), get_bool(), get_posts(), ...) or sanitizer (sanitizeValue(), sanitizeRepeaterRows()) — minus the Visual Editor's 'editor' => true gate, since CLI access is already a trusted, direct-DB-write context.
php bin/taw fields:get 42 hero_heading --json
php bin/taw fields:get 42 team_members --json # repeater comes back as a decoded array
php bin/taw fields:set 42 hero_heading "Welcome"
php bin/taw fields:set 42 team_members --file=/tmp/team.json # --file avoids shell JSON-quoting for repeater/array values
php bin/taw fields:set 42 hero_heading "Welcome" --dry-run # preview the sanitized result without writing
Verified end-to-end against a live site before shipping: XSS payloads correctly stripped by the real sanitizers on both scalar and repeater fields, unknown-field/nonexistent-post error paths return clean messages with the right exit code, and a real write/read-back/revert round-trip persisted correctly. The wp-load.php-locating logic (previously private to InspectCommand) was extracted into a shared TAW\CLI\WpLoader helper, since three commands now need it.
This is the "populate the content" half of a design-to-page pipeline — figma-to-block/make-metablock scaffold a block and its empty fields, fields:set fills them in, so "build this page from a Figma design" can mean the whole thing, not just an empty scaffold.
July 2026 — WP-CLI integration, and a real WordPress 6.7+ bug fix
WP-CLI documented as a first-class agent tool
bin/taw only covers framework scaffolding/introspection (blocks, fields, forms) — it has no visibility into actual site content. WordPress's own wp CLI fills that gap: posts, options, users, terms, transients, arbitrary PHP via wp eval/wp eval-file, an interactive wp shell. Documented in taw-theme.mdx and AGENTS.md/CLAUDE.md, including the Local by Flywheel connection quirk that makes a bare wp command fail with a DB connection error even though the site works fine in-browser (Local runs a per-site MySQL socket, not the system default wp-config.php assumes).
Real bug: textdomain loaded too early on every single request
Using WP-CLI to test the connection quirk above immediately surfaced a genuine, pre-existing bug: every request triggered WordPress 6.7+'s _load_textdomain_just_in_time doing_it_wrong notice. Root cause — Theme::bootstrapFullSite() required inc/options.php (whose OptionsPage/Metabox field configs call __('...', 'taw-theme') at file scope) synchronously, during functions.php's own load, which always runs before after_setup_theme even starts firing. WordPress's actual gate for this notice (verified directly in wp-includes/l10n.php) is whether after_setup_theme has started — not "before init" as the notice's own message text claims.
Fixed in taw/core v1.16.67: both the textdomain load and inc/options.php's require are now deferred to after_setup_theme, at priorities 1 and 5 respectively — ahead of inc/customizations.php's own callback and BlockLoader::loadAll() (both default priority 10). The scaffold's inc/customizations.php no longer needs its own load_theme_textdomain() call (removed — it was on init, running even later than register_nav_menus()'s translation calls, which never actually fixed the ordering).
This is exactly the class of bug static analysis can't catch — PHPStan checks types, not hook-firing order at runtime. It was only found by actually booting the site through wp eval and reading the debug output.
July 2026 — PHPStan static analysis
taw-theme and taw/core now ship phpstan.neon (level 5) with WordPress core stubs via szepeviktor/phpstan-wordpress, so add_action, WP_Query, and other core WP symbols resolve correctly instead of erroring as unknown. Runs via composer run phpstan, and as a new CI step on every push/PR alongside the existing lint and getData() signature checks.
composer run phpstan
taw-theme analyzes Blocks/ and inc/ only; taw/core analyzes src/. Both repos started clean or near-clean — taw/core carries a small phpstan-baseline.neon (26 findings, mostly WP_Post dynamic-property access and a Symfony Console interface gap) captured as a migration aid, not a permanent suppression file.
If a block template trips a PHPStan false positive on a variable that's actually guaranteed present (e.g. isset($x) or $x ?? default on a value defaults()/getData() always supplies), that's expected — PHPStan can't trace TAW's extract()-based template variable injection statically. Verify the variable really is wired through render()/getData() before assuming it's a false positive; baseline it once confirmed, don't widen types or add defensive checks to silence it.
CI fix: excludePaths must mark build artifacts optional
phpstan.neon's excludePaths initially listed node_modules/ and public/build/ unconditionally. Both are gitignored build artifacts that don't exist on a fresh CI checkout (CI never runs npm install/npm run build), and PHPStan treats a missing excludePaths entry as a hard config error, not a no-op — it failed with Invalid entries in excludePaths, not the memory-limit crash it looked like at first. Fixed by marking both paths optional with the (?) suffix. A --memory-limit=-1 change landed alongside this while diagnosing (the parallel worker legitimately needs more headroom on GitHub's runners than a local dev machine), but the exclude-path fix was the actual break.
External skill references, not vendored wholesale
AGENTS.md/CLAUDE.md now point at specific skills from WordPress/agent-skills — wp-phpstan (source for the WP-aware PHPStan setup above), plus wp-performance, wp-wpcli-and-ops, wp-playground as on-demand reading. Explicitly steering away from wp-block-development and wp-block-themes: those teach native Gutenberg blocks and theme.json, both of which TAW replaces with its own MetaBlock/Block system and Vite pipeline — following them would fight this framework's conventions rather than extend it.
July 2026 — framework bootstrap split, live introspection, dev-server detection fix
Theme::bootstrapFullSite() — functions.php is now 100% framework-owned
New one-call bootstrap (taw/core v1.16.63+): functions.php collapses to two lines (require autoload, call Theme::bootstrapFullSite(get_template_directory())). Everything that used to be hand-written there — theme supports, nav menus, performance tuning, VisualEditor::enable(), an explicit MetaboxOrder::lock() — moves to three theme-owned files, none of which framework updates ever touch: inc/options.php (pre-existing), inc/performance.php, and inc/customizations.php.
inc/customizations.php loads before Theme::boot() (fixed in v1.16.65). Flag-style opt-ins like VisualEditor::enable() are synchronous — boot() reads the flag immediately via VisualEditor::init(), which silently no-ops if enable() hasn't run yet. Hook-registration-only customizations (add_action calls for later events) work fine regardless of order.
VisualEditor is opt-in, not automatic — call VisualEditor::enable() in inc/customizations.php before Theme::boot() runs. (Earlier docs incorrectly said this was automatic — corrected.)
php bin/taw inspect — live introspection
New CLI command reports the site's actual current state: registered blocks and their real metabox field schemas, registered forms, the installed taw/core version, and whether MetaboxOrder is locked. Unlike other bin/taw commands, this one boots WordPress itself, since the data only exists once after_setup_theme/init have fired.
php bin/taw inspect # human-readable summary
php bin/taw inspect --json # machine-readable
CI smoke-test
New .github/workflows/ci.yml + bin/ci/check-getdata-signature.php — runs composer validate, php -l across the repo, and a check that every MetaBlock::getData() is declared exactly getData(int|false $postId): array on every push/PR. A narrower signature there is a PHP fatal that takes the entire site down, since every block auto-loads on every request.
update-theme — manifest-based sync, no git merge required
The update-theme AI skill (for syncing the shared taw-theme scaffold into a client site) no longer depends on shared git history at all. It copies a small, precisely-delimited set of framework-owned paths (functions.php, .claude/skills/, bin/, CI config) directly from a fresh checkout — no merge, no conflicts. A new client project can start from a single clean git init commit.
Vite dev-server detection — real bug fix
ViteLoader::isDevServerRunning() previously just checked whether anything was listening on port 5173 (fsockopen). Any unrelated process — another dev server, a Docker container, anything — could occupy that port for reasons that have nothing to do with the project, causing the theme to serve dead localhost:5173 asset URLs in production with no CSS/JS loading at all, even though npm run build succeeded and the manifest was completely correct. Found on a real client project where an unrelated Dockerized Vite instance for a different project happened to be bound to the same port.
Fixed in taw/core v1.16.66: isDevServerRunning() now verifies at the HTTP level — GET /@vite/client must return an actual 200 — not just that the port is open. The scaffold's vite.config.js also gained an optional hot-file convention (writes the dev server's real URL to public/build/hot) for resolving the correct host:port, and keeps server.strictPort: true deliberately (auto-moving ports would silently break hardcoded font URLs instead of failing loudly).
CI: composer GitHub auth + stale PHP version constraint
Two real bugs found fixing CI: (1) composer install needs the GitHub API to resolve taw/core's VCS repository, and unauthenticated API access caps at 60 requests/hour — Actions' shared runner IPs hit that constantly. Fixed by authenticating with the free GITHUB_TOKEN. (2) Once that was fixed, CI correctly surfaced that composer.json claimed PHP >=8.1 while symfony/console had already resolved to a version requiring >=8.2 — a real, pre-existing latent bug, not just a CI mismatch. Bumped the declared minimum PHP to 8.2 everywhere to match reality.
Docs — June 2026 (2)
New: Framework and Dump helpers
TAW\Helpers\Framework— documented the path/URL resolver helpers (path(),url(),themePath(),themeUrl()) that were present in thetaw/coreREADME but missing from the docs.TAW\Helpers\Dump— documented the debug utility helpers (Dump::dd(),Dump::log()), also missing from the docs.
Blocks
getData(int|false)404 note — added explanation thatgetData()receivesint|falsebecauseget_the_ID()returnsfalseon 404 pages, and that all MetaBlock convenience helpers handlefalsesafely.
Docs — June 2026
Options page
- OptionsPage field types expanded — per the authoritative
taw/coreREADME,OptionsPagenow supports the same field types asMetabox(all types, includingfiles,group,post_select,repeater, anddatepicker). The previous explicit subset list has been replaced with the correct statement.
Docs — June 2026
Visual Editor
- Visual Editor documented — the Visual Editor (
TAW\Core\Editor\VisualEditor) is now opt-in and production-ready. Enable it by callingVisualEditor::enable()beforeTheme::boot()infunctions.php. The new Visual Editor section covers automatic behaviour, template annotations (Editor::field(),Editor::attrs()), and the save endpoint. - Updated theme boot callout — removed the "not ready for production" warning from the Theme boot page; replaced with a note pointing to the Visual Editor API.
REST API
- Visual editor endpoints documented —
POST /taw/v1/visual-editor/saveandGET /taw/v1/visual-editor/fieldsare now listed in the REST API endpoint table.
Options page
- Removed
filesfrom supported field types —filesis not supported inOptionsPage(only inMetabox). Corrected the supported-types list to match the authoritative README.
taw/core v1.15.52 — June 2026
Block system
MetaBlock::variations()static method — block variations are now declared by overridingpublic static function variations(): arrayand returning an array of string suffixes (''= default variation). The previous constructor-basedparent::__construct(['variations' => [...]])approach is replaced.
Asset pipeline
ViteLoader::inlineCriticalCss(string $path)— inline critical CSS directly into<head>from any path relative to the Vite asset root. Use this to manage critical CSS loading from code rather than relying solely on the automaticcritical.scssdetection.ViteLoader::preloadAssets(array $paths)— addmodulepreloadhints for JS chunks to improve first-load parse performance.
Theme boot
Theme::boot()accepts optional config array — pass['performance' => [...]]directly toTheme::boot()as a shorthand for callingTheme::performance([...])as a separate step. Both patterns remain supported.
taw/core v1.15 · TAW Theme — June 2026
Asset pipeline
ViteLoaderOOP API —TAW\Support\ViteLoaderis now the authoritative way to interact with Vite assets. New static methods:ViteLoader::assetUrl(),ViteLoader::isDevServerRunning(),ViteLoader::enqueueAsset(), andViteLoader::init().- Legacy procedural helpers removed from autoload —
vite_asset_url()andvite_is_dev()are no longer in the Composerfilesautoload and are not available globally. UseViteLoaderinstead. - SCSS block asset priority — when a block has both
style.scssandstyle.css, SCSS now takes priority.
Form system
Form::register()+Form::display()— forms are now registered statically viaForm::register()insideboot()and displayed in templates withForm::display('id'). The oldnew Form()+$form->render()pattern has been replaced.- New input field types:
radio(withoptionsandlayout),checkbox_group(stored as comma-separated string),date(withmin_dateandmax_date). - Structural field types:
heading,divider, andhtml— cosmetic fields for layout and copy inside a form with no submission data. - AND / OR conditional logic —
conditionsnow supports'relation' => 'any'for OR logic. The old flat-array AND format is still fully supported. - Multi-step forms — replace the top-level
fieldskey withsteps. Each step has atitleand its ownfieldsarray. Includes a numbered step indicator, per-step client-side validation, and automatic navigation to the failing step on server error. SubmissionsHandlerauto-wired —Theme::boot()now registersSubmissionsHandlerautomatically; manual instantiation is no longer needed.
Metabox engine
datepickerfield type — jQuery UI date picker; stored as a date string (YYYY-MM-DDdefault). Supportsdate_format,min_date, andmax_dateoptions.- Repeater
layoutoption — repeater rows can now render astabbed_horizontalortabbed_verticalin addition to the default accordion. screenskey (plural) — the Metabox config option is nowscreens(accepts an array of post types, page slugs, or page template filenames). The singularscreenis no longer used.
Transactional email
- Template path updated — pre-compiled HTML templates now live at
mails/html/{name}.html. MJML source files remain atmails/{name}.mjmlfor dev-time compilation. MailTesterregistration — register manually with(new \TAW\Core\Mail\MailTester())->register()infunctions.php.
Block system
defaultData()method — UI Blocks now usedefaultData()instead ofdefaults()to define prop fallbacks.getData(int|false $postId)—MetaBlock::getData()now acceptsint|falseso blocks return safe empty values on 404 pages.boot()for all blocks —BlockLoadernow callsboot()on every block type (not just UI Blocks) during discovery.
Navigation menus
MenuItem::target()— new method returning'_self'or'_blank'.MenuItem::isActiveAncestor()— new method;isInActiveTrail()now means any of active/activeParent/activeAncestor is true.MenuItem::wpPost()— new method returning the rawWP_Postmenu item object.
TAW Theme
ThemeUpdaterre-introduced viataw/core—TAW\Core\ThemeUpdateris available again for GitHub Releases-based auto-updates. Instantiate it infunctions.php.- CSS Studio toggle — CSS Studio is now activated via WP Admin → TAW Settings → Developer Tools → Enable CSS Studio (previously auto-activated when the Vite dev server was running).
Image::render()signature — the$altparameter has been removed. Pass options (includingclass,sizes,above_fold,attr) as the third argument directly.Image::preloadTag()— renamed frompreload_tag()to camelCasepreloadTag().
taw/core v1.14 · TAW Theme — April 2026
Metabox engine
filesfield type — new multi-file picker with drag-to-reorder. Stores an array of attachment IDs. Acceptslimitto cap the number of selectable files.- Nested repeaters — repeater fields now fully support nesting: place a
repeaterinside another repeater'sfieldsarray for hierarchical data structures up to three levels deep. Serialization is pre-submit and handles re-entrant calls safely. - JSON encoding — all repeater data is now serialized with full Unicode support.
postMatchesTemplate— new method onMetaboxfor template matching;screennow accepts multiple post types (comma-separated or array).- Repeater template tag — internally switched from
<script>to<template>for repeater row prototypes (better browser compatibility). - jQuery selector optimization — repeater element selectors are scoped for improved performance on pages with multiple repeaters.
Block system
- Block variations —
MetaBlocknow accepts avariationsarray in its constructor to declare WordPress block variations. Asset handles are automatically namespaced per variation. BlockLoader::boot()for plain blocks — whenBlockLoaderloads a UI Block (extendingBlock), it now calls the block'sboot()method if one exists. Use it for one-time setup such as registering hooks.
REST API
post_selectpage handling —SearchEndpointsnow properly validates thepagepost type and handles the WordPress special case automatically.
TAW Theme
ThemeUpdaterremoved — theThemeUpdaterclass has been removed from TAW Theme. Usecomposer update taw/coreto update the framework.SearchEndpointscleaned up — removed the theme-level override; the endpoint is now served exclusively bytaw/core.- Hero block — added
content(wysiwyg) field. Added a three-levelnested_repeaterfield as a reference implementation for hierarchical repeater data. - CSS Studio — added as a dev-only dependency. Activated automatically when
vite_is_dev()istrue; never shipped to production. - Performance —
Theme::performance()now schedules its work onafter_setup_themefor better WordPress filter compatibility.
v1.0.0 — Initial release
TAW Theme and taw/core v1.0.0 are the first stable release of the framework. Everything below ships out of the box when you run composer create-project taw/theme.
Block system
- Auto-discovery — drop a class and a template inside
Blocks/and TAW finds it automatically. No registration step, nofunctions.phpedits required. - MetaBlock — page sections backed by metabox data. Extend
TAW\Core\MetaBlock, define fields inregisterMetaboxes(), return template vars fromgetData(). - Block (UI Block) — stateless components that receive props at render time. Extend
TAW\Core\Blockand define adefaults()array. Missing props always fall back safely. - Block nesting — UI Blocks compose naturally inside MetaBlock templates via
(new Button())->render([...]). - Subgroup organisation — blocks can live in nested folders (
Blocks/sections/Hero/) for large projects. BlockRegistry::queue()/::render()— queue a block's assets beforeget_header(), render it anywhere in the template.
CLI scaffolding (bin/taw)
Powered by Symfony Console, shipped inside taw/core.
make:block Name --type=meta|ui— scaffold a MetaBlock or UI Block with the correct class stub and template.--with-style— include astyle.scsswired into the Vite pipeline.--with-script— include ascript.jsloaded as an ES module in the footer.--group=sections— place the block inside a subgroup folder.--force— overwrite an existing block.export:block Name— export any block as a portable ZIP file.import:block path.zip— import a block from a ZIP into theBlocks/directory.
Metabox engine
Config-driven metaboxes with no plugin dependencies. All field types render, validate, sanitize, and save through TAW\Core\Metabox\Metabox.
Supported field types: text, textarea, wysiwyg, url, number, range, select, checkbox, color, image, files, group, repeater, post_select
- Conditional logic — show or hide fields based on other field values. Evaluated live in the admin with Alpine.js and server-side on save. Operators:
==,!=,contains,empty,!empty. - Tabbed layouts — group fields into tabs using the
tabskey; each tab references field IDs from thefieldsarray. - Responsive grid — control field width with the
widthoption (e.g.'50'for two-column). show_oncallable — conditionally hide an entire metabox per post.- Retrieval helpers —
Metabox::get(),::get_bool(),::get_image_url(),::get_color(),::get_posts(),::get_repeater()cover every field type.
Options page
TAW\Core\OptionsPage— site-wide settings stored inwp_optionsusing the same field config as metaboxes.- Supports tabbed layouts, validation, and all field types except
repeaterandgroup. - Retrieval via
OptionsPage::get()andOptionsPage::get_image_url().
Forms
TAW\Core\Form\Form— config-driven frontend forms with CSRF (nonces), honeypot spam protection, field validation, and PRG redirect after success.- Supports
text,email,textarea,select, and any standard HTML input type. - Sends dual emails (to-self and to-client) via
Mailerwhen templates are configured; falls back to plain-textwp_mail()otherwise. TAW\Core\Form\SubmissionsHandler— stores successful submissions as ataw_submissionCPT in WP Admin.- Optional webhook forwarding (n8n, Zapier, Make, etc.) with HMAC-signed payloads. Configure endpoint and secret under Settings → Form Webhook.
Transactional email
TAW\Core\Mail\Mailer— fluent wrapper aroundwp_mail()with ato(),subject(),template(),setVariables(),send()chain.TAW\Core\Mail\MailTemplate— resolves templates frommails/html/{name}.html(production) ormails/{name}.mjml(dev, compiled viaspatie/mjml-php). Uses{{variable}}placeholder syntax.TAW\Core\Mail\MailTester— admin page under Tools → Test Emails for sending test emails against any compiled template without a real form submission.
Navigation menus
TAW\Core\Menu\Menu— typed wrapper around WordPress nav menus. Load by location slug withMenu::get('primary').TAW\Core\Menu\MenuItem— exposestitle(),url(),openInNewTab(),hasChildren(),children(),isActive(),isInActiveTrail(),classes(), and more. WP auto-classes are filtered out by default.
Asset pipeline
- Per-block assets —
style.scss(orstyle.css) andscript.jsinside any block folder are auto-detected and auto-enqueued only on pages that use that block. - Critical CSS —
resources/scss/critical.scssis compiled and inlined in<head>to eliminate a render-blocking network round-trip for above-fold styles. - Async CSS —
app.css/app.scssare loaded non-render-blocking viamedia="print"+onloadswap. - ES module JS — block scripts and the main
app.jsare loaded astype="module". - Content-hashed filenames — all production assets are cache-busted automatically.
vite_asset_url($path)/vite_is_dev()— global helpers available anywhere in your templates.- Tailwind CSS v4 via
@tailwindcss/vite. Alpine.js v3 for interactivity.
Performance
Theme::performance([...]) (forwarded to TAW\Core\Support\Performance) supports:
remove_bloat— strips unnecessary WordPress front-end scripts, styles, and features.remove_emoji— disables WordPress emoji detection scripts.remove_meta_tags— removes default WordPress generator and discovery meta tags.remove_oembed— disables oEmbed support and associated scripts.preconnect_origins— emits<link rel="preconnect">hints for external origins.preload_fonts— preloads self-hosted WOFF2 font files resolved viaViteLoader.preload_images— emits<link rel="preload">for above-fold images by attachment ID and size.
Image helper
TAW\Helpers\Image::render($id, $size, $alt, $options)— generates<img>with correctloading,fetchpriority,decoding,srcset, andsizesattributes. Pass['above_fold' => true]for hero images.TAW\Helpers\Image::preload_tag($id, $size)— generates a<link rel="preload">tag for your single most important image.
SVG support
TAW\Helpers\Svg::register()— enables sanitized SVG uploads in WordPress (called automatically byTheme::boot()).Svg::render($id, $alt, $options)— renders a sanitized<img>tag.Svg::inline($id, $options)— outputs inline SVG markup for CSS/animation targeting.Svg::url($id)— returns the URL for a stored SVG attachment.
REST API
GET /wp-json/taw/v1/search-posts— powers thepost_selectmetabox field. Supportss,post_type,per_page, andexcludeparameters. Requiresedit_postscapability.
Theme updater
TAW\Core\Theme\ThemeUpdater—removed in a later releasehooked into the WordPress update system to check a GitHub Releases URL for new versions. Shows the standard Update Available notice in admin. Updates are cached for 6 hours. Deprecated — see the v1.14 entry below.
Boot process
TAW\Core\Theme::boot()— single entrypoint that wires block loading, Vite assets, REST endpoints, and SVG support into WordPress hooks.TAW\Core\Theme::performance([...])— configures performance behavior after boot.