Changelog
Release history for TAW Theme and the taw/core framework package.
September 2026 — submit_icon on Form::register()
Form::renderSubmitButton() hardcoded its markup to a spinner + label span, with no slot for an icon — any site wanting a non-default submit button icon had to fork Form.php. Tagged v1.39.0, with a paired taw-theme release tagged v1.11.12. Flagged while pixel-matching a Contact form's submit button (a trailing "send" icon) against a Figma spec.
Form::register([
'id' => 'contact',
'submit_label' => 'Send message',
'submit_icon' => 'send',
'fields' => [...],
]);
submit_icon accepts a Lucide icon name (rendered via Lucide::render() — no Lucide::enable() needed, same as any direct template call to it) or raw '<svg>...</svg>'/HTML, printed as-is inside a decorative <span class="taw-btn-icon" aria-hidden="true">, after the label. Nothing renders when unset, empty, or an unrecognized icon name.
September 2026 — gradient_text destroyed word-boundary whitespace
sanitizeGradientTextValue() ran each segment's text through sanitize_text_field(), whose own trim() silently ate a segment's leading/trailing space — exactly where it sits whenever a highlighted phrase is mid-sentence rather than the trailing segment, gradient_text's primary use case (e.g. "Three ways we help you own more and rent less."). renderGradientText() concatenates segments with no separator of its own, so the words rendered smashed together with no space at all. Tagged v1.37.1, with a paired taw-theme release tagged v1.11.11. Reported (with a clean repro) while migrating real heading+highlight pairs on a client site to gradient_text — no content had shipped on the buggy behavior yet.
Fixed by restoring a single boundary space wherever the original segment text had one — never anything else from the original string, so the fix can't reopen anything sanitize_text_field() just closed.
September 2026 — OptionsPage support for fields:set/get, plus gradient_text and hubspot_form
fields:set/fields:get only ever accepted a numeric post ID and wrote via update_post_meta() — there was no CLI path to write a site-wide OptionsPage field, despite the CLI's own docs claiming Metabox/OptionsPage support. Tagged v1.37.0, with a paired taw-theme release tagged v1.11.10.
Pass the literal options in place of the post ID to target a site-wide field instead — resolved via new OptionsPage::getFieldConfig() (a bare-id lookup) and written via new OptionsPage::writeOption() (update_option(), sanitized through the same Metabox::sanitizeForStorage() Content\Importer already uses for options-registry values):
php bin/taw fields:set options company_phone "555-1234"
Also two new field types, wired into both Metabox and OptionsPage:
gradient_text— an ordered list of{text, highlighted}segments, replacing the old convention of a plainheadingfield plus a single always-trailinghighlightfield (which can't express a heading like "How can we help?", where the highlighted run isn't last). Render withMetabox::renderGradientText($segments, $highlightClass).hubspot_form—{portal_id, form_id, region}, rendered via newTAW\Core\Integrations\Hubspot::render()/isConfigured(), for a HubSpot embed with a fallback to the site's nativeFormblock when unconfigured.
September 2026 — per-request override for critical CSS via taw_critical_css_entry
enqueueThemeAssets()'s call to ViteLoader::inlineCriticalCss() hardcoded resources/scss/critical.scss with no way to override it — a client theme building full-page event-invite microsites with three distinct visual templates, each needing its own correctly-styled above-the-fold hero, had no way to load a different critical file without one site-wide critical.scss covering every template's layout at once. Tagged v1.33.1, with a paired taw-theme release tagged v1.11.9.
The call site now resolves through apply_filters('taw_critical_css_entry', 'resources/scss/critical.scss') — purely additive, unhooked behavior unchanged. A theme can now do:
add_filter('taw_critical_css_entry', function (string $entry) {
return is_singular('event_invite') ? 'resources/scss/critical-event-invite.scss' : $entry;
});
September 2026 — on_submit callbacks now receive the submission's post ID
Form::process() called SubmissionsHandler::saveSubmission() but discarded its return value entirely, so an on_submit callback only ever received the submitted $data — never the ID of the taw_submission record just created for it, with no way to correlate a callback's own side effects back to that record without re-querying by form ID and a timestamp guess. Tagged v1.33.0, with a paired taw-theme release tagged v1.11.8.
The callback's signature is now function(array $data, int|false $postId): void — false if the save itself failed. Every existing on_submit callback written against the old one-argument signature keeps working unchanged: PHP silently ignores extra arguments passed to a closure declaring fewer parameters than it's invoked with. Also new: saveSubmission() now records a _taw_user_agent meta key from $_SERVER['HTTP_USER_AGENT'], alongside the existing _taw_user_ip/_taw_page_url — generic technical context useful for any form submission, not specific to one theme.
September 2026 — rich-text metabox values left raw HTML entities for the browser to decode
Metabox::render()'s and OptionsPage::render_repeater_row()'s Alpine x-data seed values came straight from get_post_meta()/get_option() — for a wysiwyg (or any rich-text) field, that value already carries HTML entities baked in (a literal " saved as "). esc_attr() left that " in the rendered markup, and the browser HTML-decoded it right back into a raw, JSON-breaking " inside x-data — a silent Alpine initialization failure on any block with a rich-text field. Tagged v1.32.1, which also documents the Catechism Reader Corpus (v1.32.0, below) in README.md; a paired taw-theme release tagged v1.11.7 picks up both.
Fixed by decoding entities (html_entity_decode(..., ENT_QUOTES | ENT_HTML5, 'UTF-8')) before seeding both metabox and options-page repeater initial values, so the x-data JSON sees the raw value a <textarea>/<input> would actually expose via .value.
September 2026 — Catechism Reader Corpus, edition-parameterized from day one
A generic TAW\Core\Corpus\Catechism\* namespace mirroring Bible Reader Corpus's architecture one hierarchy level deeper — part → section → chapter → numbered question/answer, matching a catechism's own structure. Landed via Relmaur/taw-core#28, tagged v1.32.0.
Every reader/installer/endpoint method takes an $edition slug (registered in CatechismEditions, currently just pius-x — the Catechism of Saint Pius X) rather than assuming a single installed catechism, unlike the Bible corpus's single-corpus design — adding a second edition later (Saint John Paul II's, say) needs one new registry entry plus an installed file, no new code. The MySQL fallback ships alongside SQLite from the start this time, rather than arriving as a follow-up after a production incident: innodb_ft_min_token_size short-word filtering and checked $wpdb queries with real post-import row counts are both built in from day one. One genuine improvement over the Bible installer: since Catechism's MySQL tables are shared across editions (edition + source_id columns), reloading one edition uses a scoped DELETE rather than TRUNCATE — DML, not DDL, so the transaction wrapping around a reload is a real cross-table atomicity guarantee, not just a best-effort one.
September 2026 — MysqlBibleInstaller could report false success
A production install against WPMUdev/MariaDB 10.6.23 once printed a clean [OK] Imported 73 books, 1334 chapters... while GET /wp-json/taw/v1/bible/books kept 404ing with "No Bible corpus is installed" — the table didn't exist at all. A second run genuinely succeeded. The transient root cause was never pinned down, but the reporting bug was real and deterministic regardless: the success message was built from the input JSON's own row counts, never anything read back from MySQL, and every $wpdb->query() call ran unchecked — $wpdb->query() returns false on failure and doesn't throw, so a silently-failed statement just fell through to a normal return. Landed via Relmaur/taw-core#27 (closes #26), tagged v1.31.2, with a paired taw-theme release tagged v1.11.6.
Every query in MysqlBibleInstaller now throws (with $wpdb->last_error) on a false result, and install() returns row counts read back via COUNT(*) after the import — corpus:install reports those, catching a real failure as a clean CLI error instead of a false success or a raw stack trace. Also documented: TRUNCATE TABLE is DDL on InnoDB/MariaDB and causes an implicit commit, so the transaction wrapping around the five-table reload was never a true cross-table atomicity guarantee — the loud-failure behavior is the real safety net now, not the transaction.
September 2026 — MySQL FULLTEXT search ignored short/unindexed words
Verified against a real MySQL server (fsspx-taw theme session): books()/chapter()/full corpus import all confirmed byte-for-byte correct, but +amor +de +Dios returned zero results even though three verses genuinely contain all three words — +amor +Dios (dropping "de") returned the correct three. Landed via Relmaur/taw-core#25 (closes #24), tagged v1.31.1, with a paired taw-theme release tagged v1.11.5.
InnoDB's FULLTEXT indexer never indexes a word shorter than innodb_ft_min_token_size (default 3) at all — a required +word term for a word that was never indexed can never match, so any AND query containing a short word (Spanish is full of them: de/la/el/en/un/...) returned nothing regardless of whether the rest of the query genuinely matched. Not fixable via server config on managed hosting (a global my.cnf setting requiring a restart and full index rebuild). MysqlBibleReader::significantWords() now reads the actual configured innodb_ft_min_token_size at query time and drops any word shorter than it from the required set before searching — the same "silently ignore, don't exclude everything" handling a real search engine applies to short/stop words.
September 2026 — MySQL-backed fallback for the Bible Reader Corpus
bin/taw corpus:install against the real ~18MB Straubinger corpus file on a live client site (WPMUdev managed hosting) fataled with PDOException: could not find driver — pdo_sqlite/sqlite3 are missing from both PHP-FPM and CLI on that host, on two PHP versions, and WPMUdev declined to add them. $wpdb (mysqli) is guaranteed on every WordPress host since WP core itself can't function without it; pdo_sqlite is not guaranteed anywhere. Landed via Relmaur/taw-core#23 (closes #22), tagged v1.31.0, with a paired taw-theme release tagged v1.11.4.
ProtectedSqlite::isAvailable()— real capability detection:extension_loaded('pdo_sqlite')and a realsqlite::memory:connection attempt, since a loaded extension isn't always functional on every host build.TAW\Core\Corpus\Bible\BibleReaderInterfaceextracted (deferred in the original design until a second implementation existed to shape it against — that point has arrived);BibleReaderimplements it with zero internal changes, so every host wherepdo_sqlitealready works sees no behavior change at all.MysqlBibleReader— an independent, MySQL-backed mirror of the same read contract:books()/chapter()via$wpdbjoins,searchVerses()/searchNotes()via MySQL boolean-modeFULLTEXT(+word1 +word2, translating the same AND-of-words semantics the SQLite search fix already established) with hand-built<mark>excerpts (MySQL has nosnippet()).bin/taw corpus:export <sqlite-path> <json-path>— dumps a corpus to portable JSON on a machine that haspdo_sqlite, for installing on a target host that doesn't.bin/taw corpus:install— now content-sniffs.sqlitevs. portable JSON and routes to the matching backend; a.sqlitesource on a host withoutpdo_sqlitenow fails with a clear message pointing atcorpus:export, instead of a barePDOException.BibleEndpointresolves SQLite first (unconditionally, whenever available and installed), MySQL otherwise.
See Bible Reader Corpus for the full reference, and docs/adr/0002-corpus-mysql-fallback.md in taw-core for the architectural reasoning. Rag\Storage's vector chunks remain pdo_sqlite-only — a MySQL-backed vector store is a plausible follow-up, flagged as separate, harder scope (continuous writes from ongoing post saves don't fit the export-once-to-JSON approach this fix relies on).
September 2026 — RAG chatbot becomes opt-in
Theme::boot() unconditionally wired the entire Sovereign Hybrid-RAG Chatbot subsystem — every TAW site got the Settings → TAW Chatbot page, WP-content ingestion, and the public POST taw/v1/chat endpoint whether or not the developer wanted it. Lucide, MediaFolders, and the Bible Reader Corpus's BibleEndpoint all follow an explicit enable() opt-in flag; the chatbot — arguably the subsystem with the highest bar to actually need (an LLM endpoint + TAW_RAG_API_KEY) — was the one exception. Landed via Relmaur/taw-core#20 (closes #19), tagged v1.30.0, with a paired taw-theme release (Relmaur/taw-theme main, tagged v1.11.3).
RagSettings::enable()/isEnabled()— same shape asLucide::enable()/MediaFolders::enable()/BibleEndpoint::enable(). Call it ininc/customizations.phpbeforeTheme::boot().taw-theme'sBlocks/Chatbotwidget now checksRagSettings::isEnabled()in bothheader.php(asset enqueue) andfooter.php(render) — previously it always rendered and would havePOSTed to a REST route that no longer registers on a site that hasn't opted in.inc/customizations.phpgets a commented-outRagSettings::enable();line, matching the Lucide/iconfield type's posture (off by default, one line to turn on).
September 2026 — corpus:install pre-boot fix, bin/taw registration, and Bible search matching
Two bugs found running bin/taw corpus:install against the real ~18MB Straubinger corpus file on a live client site (fsspx-taw), plus one more found while fixing them. Landed via Relmaur/taw-core#18 (closes #17), tagged v1.29.3; bin/taw registration landed directly on taw-theme main, tagged v1.11.2.
ProtectedSqlite/Corpus\Storagesilently killed pre-boot CLI usage. Both carriedif (!defined('ABSPATH')) exit;, butCorpusInstallCommand::execute()callsProtectedSqlite::looksLikeSqliteFile()beforerequire $wpLoad— so the guard fired and exited the whole process with zero output. The exact pitfall this repo's ownCLAUDE.mdalready documents forContent\*(v1.25.1). Fixed by dropping the guard from both, matchingContent\*'s precedent — neither class has a WordPress dependency of its own. A subprocess regression test (Storage\PreBootAutoloadTest, mirroringContent\PreBootAutoloadTest) now runs with noABSPATHdefined at all, the one test shape that can actually catch this.corpus:installwas never wired intobin/taw. The command class shipped intaw-corev1.29.0'ssrc/CLI/, but canonicaltaw-theme'sbin/tawnever got the matching registration (comparecontent:reindex-kb, which got both a class and a registration when it shipped) — the command was unreachable from any fresh checkout.- Bible search required an exact contiguous phrase.
BibleReader::escapeFtsPhrase()wrapped the whole query as one FTS5 literal phrase, sosearchVerses('Dios amor')silently returned nothing unless those words were adjacent in that exact order — which fails for almost any realistic multi-word search. Every word is now quoted independently and ANDed together.
September 2026 — Bible Reader Corpus
A read-only REST surface over a developer-installed reference corpus — currently a Straubinger-translation Spanish Catholic Bible, built for the fsspx-taw client site's Bible reader (theme-side page/Block shipping separately) but framework-level and reusable at the storage layer. Landed via Relmaur/taw-core#16 (closes #15), tagged v1.29.0.
TAW\Core\Storage\ProtectedSqlite— the RAG chatbot's "protected uploads directory + exception-mode PDO open + magic-byte SQLite validation" plumbing, extracted and shared rather than duplicated for this new subsystem.TAW\Core\Corpus\Storage—wp-content/uploads/taw-private/corpus/, deliberately separate from the RAG chatbot'staw-private/rag/: a reference corpus meant for direct reading and a RAG knowledge base meant for chatbot embedding are different concerns that happen to share storage mechanics.php bin/taw corpus:install <path> <filename>— CLI-only, not a wp-admin upload form. A curated dataset a developer places once per environment, not end-user content.TAW\Core\Corpus\Bible\BibleReader— books grouped by testament/division, per-chapter verses + overlapping section headings + overlapping notes, and FTS5 search over verses or notes. Notfinal— subclassable so a theme can point at a different installed filename or override read behavior.GET /taw/v1/bible/books,.../books/{slug}/chapters/{n},.../search— opt-in (BibleEndpoint::enable()), public (no auth — this is public Scripture text), rate-limited regardless. The endpoint resolves its reader throughapply_filters('taw_corpus_bible_reader', ...).
See Bible Reader Corpus for the full reference, and docs/adr/0001-reference-corpus-storage.md in taw-core for the architectural reasoning behind each of these decisions.
September 2026 — RAG chatbot: de-index posts that become ineligible
PostIndexer::onSavePost() only ever handled the "should this post be indexed" path — a post that lost eligibility after being indexed (unpublished, moved to draft/private, trashed, or password-protected) stayed searchable through search_knowledge_base indefinitely, since the method just returned without touching its existing vectors. A real privacy gap, not just a staleness one. Landed via Relmaur/taw-core#13, tagged v1.28.1, with a matching taw-theme v1.11.1 (composer.lock only — no theme code changed).
- Eligibility now also excludes password-protected posts outright (
post_password === ''), not just publish status + indexed post type. - Any post that isn't (or is no longer) eligible has its vectors removed immediately, inline — unlike the embed-on-publish path, a delete has no external API call to avoid blocking on, so there's no reason to defer it to WP-Cron.
September 2026 — Sovereign Hybrid-RAG Chatbot: content-agnostic knowledge bases
A visitor-facing chat widget that answers from any number of named knowledge bases — the site's own WordPress content, plus any .sqlite file an admin uploads from wp-admin — with an OpenAI-compatible LLM doing semantic search across whichever one a question calls for. Landed via Relmaur/taw-core#12 (closes #11), tagged v1.28.0, with a paired taw-theme release (Relmaur/taw-theme#3).
This supersedes v1.27.0 (#10, tagged the same day), which shipped the same chat widget and REST endpoint but with the reference-data layer hardcoded to two specific schemas (lookup_bible/lookup_catechism). On review that was the wrong shape — v1.27.0 was superseded before any real consumer integrated against it. If you're on v1.27.0, upgrade straight to v1.28.0; there's no migration path from the old schema-specific tools, only a clean replacement.
- Content-agnostic by design. No particular schema is assumed or required of an uploaded
.sqlitefile — every table is scanned, every column with SQLite TEXT affinity is extracted, chunked, embedded, and made searchable. A table with no text column is skipped. Settings → TAW Chatbot → Knowledge Bases— upload a.sqlitefile with a name and description; that's the entire setup. Ingestion runs via WP-Cron, never on the upload request. The upload handler validates the SQLite magic-byte header before accepting a file.- The site's own content is just another knowledge base — a built-in, non-deletable
wp-contententry, unified into the same registry and the same search tool as uploaded ones, rather than a separate dedicated tool. - One tool, not three.
search_knowledge_base(knowledge_base, query)replaces the oldlookup_bible/lookup_catechism/search_unstructured_archivetrio. Itsknowledge_baseenum is built fresh from the registry on every request, so a newly-uploaded knowledge base is searchable the moment ingestion finishes. Settings → TAW Chatbot— LLM base URL/model, indexed post types for the site's own content, chunk size/overlap, max tool-call iterations, and whether anonymous visitors can chat. The LLM API key is wp-config-constant-only (TAW_RAG_API_KEY), never an options-table field.POST /wp-json/taw/v1/chat— public by default, defended by unconditional rate limiting (20 requests/10 min per IP) rather than a login requirement.Blocks/Chatbot(taw-theme) — a site-wide floating widget, unchanged by this redesign since it only depends on the/taw/v1/chatrequest/response shape. Markdown replies render viamarked, sanitized withDOMPurify.
Downstream: content:import-reference is gone (upload replaces it); content:reindex-kb <id> is the new manual re-ingestion escape hatch for one uploaded knowledge base, alongside the existing content:reindex for the site's own content.
September 2026 — Content Interchange → whole-site state migration
Content Interchange now moves a site's state, not just its content. All new behaviour is opt-in and backward compatible; the importer accepts schema 1.0 and 1.1. Landed via Relmaur/taw-core#8 (closes #7), tagged v1.26.0, with a paired taw-theme release.
Acceptance criterion: content:export --migrate then content:import --yes against the same site reports 0 created / 0 updated / 0 deleted.
- Post authorship. Every exported post now carries
author({login, email}— never a numeric ID) pluscomment_status/ping_status. On import the author resolves by login, then email, then falls back to the importing user with a warning. --with-usersexports ausers[]section (login,email,display_name,roles[], profile meta,user_registered). Password hashes only with the separate--with-user-passwords. Import matches by login → email, creates if absent, and sanitises roles against the roles the target site actually defines — a role the target lacks is never granted.--with-commentsexports comments on the exported posts withparent_refthreading; import remaps them to their post by slug, rebuildscomment_parent, and recomputes counts.--with-settingsexports a second, opt-in option allowlist for environment settings —permalink_structure,timezone_string,sticky_posts(as slugs),date_format, and more — plus ataw_content_export_settings_optionsfilter. These are import-gated too:content:importskips them unless you also pass--with-settings.content:export --migrate=--with-users --with-settings --all-media --include-drafts(not passwords or comments — those stay explicit). The automatic rollback snapshot is now maximal-scope, so undoing a--migrateimport is complete.- Slug-less drafts no longer duplicate on re-import.
draft/pending/auto-draftposts are excluded by default;--include-draftsre-adds them and matches slug-less posts on a composite(type, sha1(title|date_gmt))key. - Metabox-backed CPTs auto-export. Any post type with a
TAW\Core\Metabox\Metaboxattached is now included even whenpublic => false. Sites that added a manualtaw_content_export_post_typesfilter just for a content CPT (for example FSSPX'sactivityMass schedule) can drop it. --all-mediaalso exports attachments not referenced by any post/field; media records now carrytitle/description, set on the sideloaded attachment.- Import ordering is deterministic and dependency-first: users → terms → media → posts → comments → settings.
content:diffdiffs the newusersandcommentssections.- The
Tools → TAW Dataexport screen gains checkboxes for the new scopes (passwords / comments behind an "Advanced" disclosure).GET /wp-json/taw/v1/content/exportstays content-only — no user or settings export over REST. - Downstream: the
taw-themescaffoldbin/tawalready registerscontent:*. Any site keeping anowner: sitecontent-sync skill should update itspull/pushsections for the new--with-*/--migrateflags.
September 2026 — Content interchange round-trip fixes
Three bugs in the v1.25.0 Content Interchange, all reproduced live against a populated site and regression-tested. Landed via Relmaur/taw-core#6 (closes #5), tagged v1.25.1.
content:importprinted nothing (exit 0, no plan table, no "no changes" line).ContentImportCommandreferences aTAW\Core\Content\*class beforerequire wp-load.php; that autoloaded the class file, whoseif (!defined('ABSPATH')) exit;guard fired (WordPress not booted yet) and silently killed the process. The guard is removed from the sixContent\*classes thecontent:*commands load before boot — they are pure class definitions, likeTAW\Helpers\Framework/TAW\CLI\WpLoader, which already omit it. This also fixescontent:diff, which was silent for the same reason.page_on_front/page_for_postsbroke the round-trip. The exporter renders them as page slugs (portable); the importer compared the slug against the stored integer ID → a false "changed", and an apply would have written the slug intopage_on_front, breaking the static front page. The importer now resolves these back to the local post ID before both the diff and the write.post.parent(slug) andfeatured_media(filename) were audited — already resolved on write;featured_mediais now also diffed by filename.- Empty / default values showed as changes. A field stored as
""(or absent, or at its default) compared unequal to the same empty value in the snapshot. Both sides now normalize throughFieldCodec::decodeplus a canonical key that collapses""/null/[]/false/ missing to one token, and aligns"1"↔true,"[…]"↔ the decoded array,"42"↔ 42.
Also: Importer::apply() now skips records the dry-run shows unchanged, so a clean export → import writes nothing and re-running an import doesn't churn post_modified dates; a stray "Undefined array key policy" warning is gone; and content:import --json no longer trails the "Dry run" note after the JSON.
taw-theme picks up taw/core v1.25.1 (v1.9.1).
September 2026 — Portable content interchange
TAW sites had no first-class way to move content between environments or to hand it to a code agent. wp export (WXR) doesn't understand _taw_* meta; a full DB copy is all-or-nothing and needs SSH. taw/core v1.25.0 generalizes TAW's own serialize → review → apply pattern (seo:extract/inject, fields:get/set) to whole-site content.
TAW\Core\Content\Exporter builds a portable JSON snapshot: meta (schema 1.0 + source + a block/field registry_fingerprint for drift warnings), options (every _taw_* decoded, plus an allowlisted core set with page_on_front/page_for_posts resolved to slugs), terms, posts (pages/posts + public CPTs except taw_submission; every _taw_* field decoded by its registered type), and media (referenced attachments, keyed by filename). Users, revisions, comments, transients and nav_menu are never exported. Scope with --types / --since / --posts / --no-media.
TAW\Core\Content\Importer consumes a snapshot or a change-set (content:diff emits one). Records match by natural key — never numeric ID. Media is matched by filename or sideloaded, and the old → new ID map is rewritten into post_content and image/files values before any write. plan() is a mandatory dry-run (field-level diff, zero writes); apply() writes a full rollback snapshot to wp-content/uploads/taw-private/ first, then writes meta through the new Metabox::writeMeta() primitive. Per-record conflict policy update / create / skip.
wp-admin: Tools → TAW Data — an Export button and an Import flow that shows the dry-run diff as a review table with an explicit "Apply N changes" (no one-click apply). Plus GET /wp-json/taw/v1/content/export (capability export).
REST-registered field meta: Theme::boot() now exposes every TAW field over wp/v2 — register_post_meta() for scalars (with the field's sanitizer and an edit_post auth_callback), register_rest_field() for the decoded repeater/files/post_select shapes, and register_setting(… show_in_rest) for OptionsPage fields. This unblocks headless front-ends and external tooling. It does not add a mobile editing UI. Opt out with add_filter('taw_register_meta_in_rest', '__return_false').
Also: Metabox::writeMeta() / sanitizeForStorage() — one shared meta-write primitive (sanitize + wp_slash + update_post_meta, matching Metabox::save()); fields:set moved onto it, fixing a latent bug where a repeater value containing " or \ was corrupted by update_post_meta's internal wp_unslash.
taw-theme picks up taw/core v1.25.0 and registers the three content:* commands in bin/taw (v1.9.0). Landed via Relmaur/taw-core#4 (closes #3).
See Content Interchange.
September 2026 — User-enumeration lockdown
A client-site incident surfaced a gap that host WAFs and “hide users endpoint” plugins routinely leave open: they match only the /wp-json/wp/v2/users path form, so GET /?rest_route=/wp/v2/users (the query-routed REST form) still returns 200 and leaks id, name, and the author slug (≈ login name) for every user on the site.
taw/core v1.24.0 adds TAW\Core\Security\Hardening::hideUsersEndpoint(), wired into Theme::boot() by default. It filters at rest_endpoints — REST dispatch, after the route is resolved — so one filter closes every routing form at once: /wp-json/, ?rest_route=, and /batch/v1.
- Only anonymous requests are affected. Any logged-in user keeps the full API, so the block editor's
/wp/v2/users?who=authorsauthor selector still works for Editors (who lacklist_users). /wp/v2/users/mealways stays available — the mobile apps, Jetpack, and the block editor need the authenticated self-lookup, and it already returns401without a login.- Escape hatch for headless / integration sites:
add_filter('taw_security_hide_users_endpoint', '__return_false').
taw-theme scaffold gains inc/security.php — site-owned (never touched by update-theme, same as inc/performance.php), required from inc/customizations.php. It calls the new core helper and adds a parse_request handler that 301-redirects logged-out ?author=N probes to the home page. That redirect stays scaffold-level on purpose: it is site policy, it kills author-archive query URLs, and it overlaps with security-plugin behavior.
See Security / Hardening.
September 2026 — The companion keeps itself current
Every companion release used to mean building a plugin zip and uploading it to each fleet site by hand — the managed hosts have no shell and no proc_open, so the CLI update path didn't reach them.
taw-hub-companion 0.2.0 adds a self-updater (src/Update/Updater.php, self-contained — no taw/core, mirrors TAW\Core\Theme\ThemeUpdater). It feeds WordPress's plugin-update transient from the plugin's own GitHub releases:
- Every site shows the standard "Update available" row;
wp plugin update taw-hub-companionworks. - Unless
TAW_HUB_COMPANION_AUTO_UPDATEisfalseinwp-config.php, the plugin opts itself into WordPress's background auto-update cron — a tagged release reaches the whole fleet within a cron cycle (~12 h), no clicking.
Releases are now tagged (vX.Y.Z). A CI workflow builds the plugin zip (git archive, dev files stripped via .gitattributes, no vendor/) and publishes it as the release asset the updater points at; the tag must match both the Version: header and TAW_HUB_COMPANION_VERSION. A site on a pre-0.2.0 build has no updater and needs one manual bump to 0.2.0, then self-maintains.
The Hub-driven "push this update to the fleet now" route is a separate follow-up.
See TAW Hub Companion.
September 2026 — One-command fleet enrolment
Joining a site to a TAW Hub fleet used to end with a manual step: read the site's public key and key id, then register them on the Hub by hand. php bin/taw hub:enroll closes that gap.
It reads the identity the taw-hub-companion plugin generated on activation (taw_hub_companion_public_key / _key_id), the Hub URL (TAW_HUB_URL), and a one-time enrolment token minted on the Hub (--token, or TAW_HUB_ENROLMENT_TOKEN), and POSTs them to the Hub's POST /api/fleet/enroll (taw-hub ADR-0011).
That endpoint isn't signature-guarded — the Hub doesn't know the site yet, so the token is the credential (single-use, 30-minute TTL, hashed at rest). But every response is signed with the Hub's Ed25519 identity, and hub:enroll verifies that signature against TAW_HUB_PUBLIC_KEY before trusting the reply. A signed 409 site_already_enrolled counts as idempotent success (a dropped connection after the Hub consumed the token); a signature-verification failure is a hard stop — enrolment is reported as not confirmed.
--base-url sets the URL the Hub reaches the site at (home_url() is often wrong behind a proxy or Herd's :80), --dry-run prints the request without sending, --insecure skips TLS verification for a local self-signed Hub. The hub-connect skill now runs this automatically on taw/core ≥ v1.23.0.
See TAW Hub Companion.
September 2026 — Fleet vulnerabilities and file integrity
Two more slices of the fleet-security work, both on the companion side.
Vulnerabilities, without a paid feed
ADR-0013 slice 2 was going to have the Hub call a paid vulnerability API (WPScan). It doesn't need to: every fleet site already runs a security scanner — Defender Pro on the WPMU DEV sites, Wordfence elsewhere — matching core, plugins and themes against a vulnerability database continuously, with severity and CVSS.
GET /wp-json/taw-hub/v1/vulnerabilities — signed, read-only, DB-read-only. A per-scanner read adapter (src/Security/) reads the scanner's stored results (it never triggers a scan) and normalizes them; ScannerRegistry picks whichever scanner the site runs.
- Defender / Defender Pro — reads the
defender_scan/defender_scan_itemtables:vulnerabilityitems (one finding per bug, with CVSS), plugins closed on wp.org (→removed), and abandoned plugins (→abandoned). Verified against Defender 6.2.4. Checked first — it's the fleet standard. - Wordfence — reads the
wfIssuestable for known vulnerabilities, abandoned plugins, and directory removals; reportslast_scan_atso the Hub can spot a stale scanner. Verified against Wordfence 8.2.x.
Both adapters degrade to an empty list on any schema change.
Each finding: { component_type, slug, installed_version, severity, cvss_score, kind, link, detected_at }, where kind separates a live vulnerability from the weaker abandoned / removed / outdated signals. Plain "update available" stays out — that's /inventory's job.
File integrity and backdoor-on-update diffing
GET /wp-json/taw-hub/v1/inventory/checksums — a per-component SHA-256 file manifest. Ground truth for the Hub to detect a webshell dropped into a plugin folder, diff one version of a component against the next on update (the "quiet backdoor on update" signal from the overview — new phone-home, new application passwords, a new drop-in), and dedupe fleet-wide analysis by (slug, version, tree_hash).
Summary mode (no slug) returns just tree_hash + file_count per active component — a cheap "did anything change" poll; detail mode (?slug=) adds the full relpath → sha256 map. Only executable / script file types are hashed; node_modules and .git are skipped; symlinks are not followed. tree_hash has a pinned canonical form (byte-sorted "<relpath>:<sha256> " lines, sha256'd) so the Hub can recompute it from a wordpress.org release zip and compare.
Both routes are subprocess-free and work on every host. The Hub-side ingest, storage, security.vuln.detected event, dashboards and version-diff analysis land under ADR-0013.
See TAW Hub Companion.
September 2026 — Fleet inventory: /inventory on the companion
The first slice of a fleet-security initiative: giving the TAW Hub the visibility to defend a fleet against the WordPress plugin threats that AI-accelerated exploitation has made sharper — known-vuln plugins, quiet supply-chain backdoors, and abandoned plugins left installed for years.
taw-hub-companion 0.1.3 adds a signed, read-only, subprocess-free GET /wp-json/taw-hub/v1/inventory route — a software bill of materials for the site: every plugin, must-use plugin, drop-in and theme, with the metadata the Hub needs to correlate against vulnerability feeds, spot abandoned components, and flag pending updates.
The signal that isn't in wp plugin list: update_source per component — wordpress_org / external / disabled / unknown, i.e. who, if anyone, is watching this for security updates. unknown — in none of WordPress's update channels — is the abandoned-plugin tell.
Works on every host (like /health and /logs, it never spawns a subprocess). The response shape is frozen as the Hub's ADR-0013 contract and pinned by a schema_version; the companion mirrors the Hub's inventory-snapshot.schema.json into its own test fixtures and validates its output against it, so the two implementations can't silently drift. +11 tests (78 total).
The Hub-side half — snapshot storage, a daily poll, a flattened component projection, and an /inventory dashboard with a fleet pending-update digest and end-of-life-PHP flags — lands under the same ADR. Vulnerability-feed correlation and abandoned-component risk scoring are the next slices.
See TAW Hub Companion.
September 2026 — Metabox order follows the full template hierarchy
MetaboxOrder::lockFromTemplate() derives a page's metabox order from the BlockRegistry::render() sequence in the template that will render it. It previously resolved only two cases: an explicitly-selected page template (_wp_page_template) and front-page.php for the static front page. Pages on WordPress's filename conventions — page-{slug}.php and home.php (the posts page) — write no template meta, so their edit screens fell back to raw metabox registration order.
Resolution now mirrors WordPress's own hierarchy for all four cases, tried highest-priority first: _wp_page_template → front-page.php → home.php → page-{slug}.php. The rules live in one shared method, Metabox::templateCandidatesForPost(), used by both MetaboxOrder and the screens template matching in Metabox, so the two can no longer drift apart.
Backward-compatible — pages that already ordered correctly are unaffected. See Locking Metabox Order.
September 2026 — First sites on the TAW Hub fleet
Two sites enrolled end to end — a local dev site and the production mlizardo.com (WPMU DEV managed hosting). Signed /health, /logs, and /framework/sync all verified over the wire, including over the public internet through a managed host's edge layer.
The rollout hardened TawRunner (which backs /framework/sync and /taw):
- 0.1.1 — resolve a real CLI
php.PHP_BINARYisphp-fpmunder the SAPI that serves the Hub's requests, sobin/tawnever actually ran; now it resolves a CLI interpreter beside the SAPI binary / onPATH, override withtaw_hub_companion_php_binary. - 0.1.2 — degrade cleanly where
proc_openis disabled (most managed hosts)./framework/syncand/tawreturn503 exec_unavailable;/healthreports"exec_available": false;/healthand/logskeep working (they never shell out). Error responses no longer leak stack traces or server paths.
See TAW Hub Companion.
September 2026 — Structured logging for the framework
taw/core gains TAW\Core\Log\Logger, an always-on structured log facade that replaces the hand-rolled error_log('[TAW …] …') calls the framework used internally (in Form, Turnstile, EmailConfig, Svg).
Every entry carries both a human message and a machine-stable, dot-namespaced code (subsystem.event — form.email_delivery_failed, mail.emailit_send_failed, svg.sanitizer_library_missing, …), plus a structured context array. The code is the part an AI agent or the TAW Hub filters on; the message is what a person reads.
Two sinks are active by default: PHP's error_log() (one readable line) and wp-content/taw-logs/taw.log.jsonl (size-rotated JSON Lines). Read it back with the new php bin/taw log:tail (--level, --code, --since, --json), or via TAW\Core\Log\LogReader. Level helpers are PSR-3 minus alert/emergency. Extend with the taw_core_log_sinks and taw_core_log_entry filters.
Theme code (blocks, inc/, custom on_submit callbacks) should use Logger instead of error_log() from now on.
taw-hub-companion adds a signed, read-only GET /wp-json/taw-hub/v1/logs route that serves this file to the Hub (filters: limit ≤ 500, level, code prefix, since), so fleet operators can see a site's errors without shell access.
See Logging and TAW Hub Companion → Routes.
August 2026 — Site-authored skills survive update-theme
.claude/skills/ and .agents/skills/ are framework-owned scaffold paths, so update-theme (and the weekly framework-sync.yml) used to sync them with rsync -a --delete — which silently destroyed any skill a client site had authored for its own workflows, since it wasn't in the canonical taw-theme repo.
Those two paths are now reconciled one skill folder at a time instead. A skill present in the canonical repo is refreshed to the canonical copy every sync, exactly as before. A skill folder that exists only in your project is resolved by the owner: key in its SKILL.md YAML frontmatter:
SKILL.md frontmatter | What the sync does |
|---|---|
owner: site | Preserved untouched, and named in the sync report |
owner: taw | Deleted — a framework skill that was retired upstream |
no owner: key | Preserved, but flagged in the report for a human to resolve |
Every framework skill in the canonical repo now carries owner: taw; this is also how you tell framework and site skills apart at a glance (grep -rl 'owner: site' .claude/skills).
Migrating an existing site skill: add owner: site to its SKILL.md frontmatter and commit it to your project repo. No manifest edit and no per-site config file — the marker travels with the skill. Until it's marked, the sync reports it under a "needs a human decision" warning rather than deleting it.
The skills-dir manifest type and the owner: marker are declared in taw/core's resources/update-manifest.json under skillsReconcile; TAW\CLI\SyncCommand implements the reconcile. See AI Integration.
August 2026 — Form image/wysiwyg fields, on_submit hook, and PagePassword
Form gains two new field types and a way to run custom logic after a submission. image renders a file input, uploads via media_handle_upload(), and validates the result as a genuine image server-side (wp_attachment_is_image() — the accept="image/*" attribute is a client-side hint only, not the real check). wysiwyg renders WordPress's own classic editor (wp_editor(), no media buttons) — free paste-from-Word cleanup via TinyMCE's built-in paste plugin, sanitized server-side with wp_kses_post(). A new 'on_submit' => callable config key runs after a submission is saved, for anything beyond "save it and maybe email it" — most commonly, creating a real post from the submitted data.
New TAW\Core\Auth\PagePassword gates a page template behind a single shared password, declared directly in the template before any output — not a wp-admin toggle. Fails closed (denies access) rather than gracefully degrading when misconfigured, since this is access control, not a supplementary check. See Page Password Protection and On Submit Callback.
taw-theme ships a working example of both together: page-client-submit.php, a password-gated portal where a client fills in a title, WYSIWYG body, category, and featured image, and gets a draft post created for later review — enabled by default, gated on a TAW_CLIENT_PORTAL_PASSWORD constant in that site's wp-config.php.
A dedicated security pass on both new features (prompted by explicit request, not incidentally) caught and fixed four real issues before release: neither the gate screen nor the unlocked page sent nocache_headers(), meaning a full-page cache/CDN could have served a cached, unlocked page to a visitor who never entered the password; the default unlock-cookie scope id was an unsalted md5($password), visible as a cookie name — now hash_hmac('sha256', $password, wp_salt('auth')); on_submit's catch caught any \Throwable and echoed its message straight to an anonymous submitter, risking internal-detail leakage on an unexpected error — narrowed to \RuntimeException only, with anything else logged server-side and a generic message shown instead; an image field's upload was left permanently orphaned in the Media Library if on_submit subsequently rejected the submission — now cleaned up automatically. Two remaining limitations are documented rather than fixed in this pass: PagePassword doesn't restrict WordPress's own REST API for the underlying post, and rate-limiting relies on a client-supplied X-Forwarded-For header unless a trusted reverse proxy sits in front of the site.
August 2026 — Grid-view sidebar: fixed uploads not auto-appending
The Media Folders Grid-view sidebar's refreshGridQuery() calls WordPress core's own library._requery() to force a re-fetch after a folder switch, sort change, or drag-and-drop move — but core's _requery() always swaps in a brand-new Backbone Query collection, and only the original collection (wired up once, at Grid boot) is bound to auto-append newly-completed uploads from wp.Uploader.queue. Since sidebar initialization itself calls refreshGridQuery() on every Grid-view page load (to apply the remembered file sort), that binding was getting dropped almost immediately — so a file uploaded while viewing "All Files" never appeared until a hard page reload.
Fixed by re-attaching library.observe(wp.Uploader.queue) to the fresh collection every time _requery() replaces it, restoring live-append behavior after every folder/sort change, not just at initial page load.
August 2026 — Modern image formats (AVIF/WebP) via WordPress core
Performance::configure(['modern_image_formats' => true]) (the default) hooks WordPress core's own image_editor_output_format filter — added in WP 5.8, not a plugin — so newly-uploaded JPEG/PNG images generate their subsizes as AVIF, falling back to WebP, checked at runtime via wp_image_editor_supports() rather than assumed. Does nothing on a host whose image library can't encode either format.
This is a replace, not a dual-format generation — the resulting files simply are AVIF/WebP, so Image::render() and everything else built on wp_get_attachment_image_src() picks this up automatically with zero template changes. Verified against a real upload on a live site, which also caught a real bug: WordPress core calls this filter with a null filename in some code paths, which fataled the original non-nullable signature — fixed, with a regression test locking it in. See Modern image formats.
August 2026 — app.css loads async; critical.scss now carries real content
ViteLoader's CSS pipeline documentation always described app.css as loading asynchronously via the media="print" swap — the code didn't actually do that until now; it was synchronous and render-blocking. Fixed to match, using the same async pattern already used for non-critical block CSS.
This makes critical.scss load-bearing rather than decorative: an empty placeholder there now produces a real flash of unstyled HTML on every page, not just a marginally slower paint. The default theme scaffold's critical.scss now carries real, hand-authored layout-only CSS for the header and Hero/Button markup — enough geometry to prevent layout shift, without needing full color/typography fidelity. A scoped Tailwind @source for critical.scss was tried first and doesn't work in this build (two independent reasons, written up in full) — hand-authored is the practical answer for now. See critical.scss must contain real content.
August 2026 — Cache headers for Vite's build assets
Performance::configure(['build_asset_cache_htaccess' => true]) (the default) writes a .htaccess scoped to Vite's own {dist}/assets/ directory, setting Cache-Control: max-age=31536000, public, immutable for every file there — safe because everything Vite writes to that directory is content-hashed. Deliberately scoped by directory rather than matched by extension in the root .htaccess, since wp-content/uploads/ images share the same extensions but aren't hashed.
This only works on Apache. On a site detected running behind nginx, a dismissible wp-admin notice now shows a logged-in admin the exact nginx config block to paste in, with the site's real theme/build path filled in — discovered live: Local by Flywheel's own default site environment runs nginx, not Apache. See Cache headers for build assets.
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.