TAW Core Tools
Overview of the developer tools TAW Core exposes for the TAW Theme, including CLI commands, runtime APIs, forms, mail, and debugging helpers.
What counts as a tool in TAW Core
TAW Core tools are the developer-facing building blocks the theme uses: command-line utilities and runtime PHP APIs.
These tools fall into two groups:
-
CLI commands — Symfony Console commands exposed through the
bin/tawscript in your theme. Use them to generate and manage blocks and other theme artifacts. -
Runtime APIs and helpers — PHP classes, functions, and configuration points that run inside WordPress, such as theme boot configuration, performance tuning, block registration, admin UI engines, config-driven forms, mail templates, and debugging helpers.
TAW Theme builds on top of these TAW Core primitives. When you configure the theme or call theme helpers, you are usually driving these lower-level tools under the hood.
How TAW Core boots into the theme
The theme template boots TAW Core via the TAW\Core\Theme::boot() entrypoint. You can then adjust performance behavior with Theme::performance([...]).
This boot process wires TAW Core tools into WordPress so that CLI commands, block registration, metaboxes, and other helpers are available within your theme.
Metabox
TAW Core metaboxes accept a fields configuration array, and each field defines a type that the TAW\Core\Metabox\Metabox engine knows how to render, validate, and save.
The canonical implementation lives in the TAW\Core\Metabox\Metabox class (src/Core/Metabox/Metabox.php), which switches on the field type and handles rendering and saving.
Common field options
Most field types support a shared set of options you can mix and match as needed:
-
id— required unique key used for saving and retrieving the meta value. -
label— human-readable label shown next to the field in the metabox UI. -
description— help text shown below the field for additional guidance. -
required— boolean flag to mark the field as required in the UI/validation layer. -
width— layout hint for field width within the metabox row. -
placeholder— placeholder text for text-like inputs. -
conditions— show/hide logic based on other field values, withfield,value, andoperatorkeys. -
readonly— boolean flag that renders the field as non-interactive text (with a lock icon next to its label) instead of an editable control, for values an external process (a sync pipeline, a computed value) authoritatively owns.
Use these common options alongside the type-specific options below.
readonly is enforced on both ends, not just visually: no <input>/<select>/<textarea> is rendered at all (nothing for devtools to re-enable and submit), and Metabox::save() skips the field entirely — a forged $_POST key is ignored regardless. It composes rather than being its own field type: on a group field it propagates to every sub-field, and on a repeater field it disables Add/Remove/reorder for the whole row list (not just the sub-field values), propagating readonly to every sub-field in every row. Not yet enforced by VisualEditorEndpoint or the fields:set/seo:inject CLI commands — those are separate write paths that don't consult this flag.
Supported field types
Each field below uses a type value that maps directly to the metabox renderer.
A full metabox fields configuration looks like this:
new Metabox([
'id' => 'taw_hero',
'title' => 'Hero Section',
'screens' => ['page'],
'fields' => [
// Layout with widths
[
'id' => 'heading',
'label' => 'Heading',
'type' => 'text',
'width' => '50',
'required' => true
],
[
'id' => 'subheading',
'label' => 'Subheading',
'type' => 'text',
'width' => '50'
],
// Rich text
[
'id' => 'body',
'label' => 'Body',
'type' => 'wysiwyg',
'rows' => 6
],
// Select with options
[
'id' => 'style',
'label' => 'Style',
'type' => 'select',
'options' => ['light' => 'Light', 'dark' => 'Dark']
],
// Toggle
[
'id' => 'show_cta',
'label' => 'Show CTA',
'type' => 'checkbox'
],
// Color picker
[
'id' => 'bg_color',
'label' => 'Background',
'type' => 'color',
'default' => '#ffffff'
],
// Range slider
[
'id' => 'min_height',
'label' => 'Min Height',
'type' => 'range',
'min' => 400,
'max' => 900,
'step' => 50,
'unit' => 'px',
'default' => 600
],
// Image
[
'id' => 'image',
'label' => 'Background Image',
'type' => 'image'
],
// Post selector (single)
[
'id' => 'featured_post',
'label' => 'Featured Post',
'type' => 'post_select',
'post_type' => 'post'
],
// Post selector (multi, with max)
[
'id' => 'related',
'label' => 'Related Posts',
'type' => 'post_select',
'post_type' => 'post',
'multiple' => true,
'max' => 3
],
],
]);
Conditional fields
Fields can show or hide based on other field values. Conditions are evaluated live in the admin UI using Alpine.js, and also server-side during save.
'fields' => [
['id' => 'show_cta', 'label' => 'Show CTA', 'type' => 'checkbox'],
['id' => 'cta_text', 'label' => 'CTA Text', 'type' => 'text',
'conditions' => [
['field' => 'show_cta', 'operator' => '==', 'value' => '1'],
]],
['id' => 'cta_url', 'label' => 'CTA URL', 'type' => 'url',
'conditions' => [
['field' => 'show_cta', 'operator' => '==', 'value' => '1'],
]],
],
Supported operators: ==, !=, contains, empty, !empty
All conditions in the array use AND logic — every condition must pass for the field to show.
Tabbed fields
Group fields into tabs using the tabs key. Each tab references field IDs from the fields array.
new Metabox([
'id' => 'taw_hero',
'title' => 'Hero Section',
'screens' => ['page'],
'fields' => [
['id' => 'heading', 'label' => 'Heading', 'type' => 'text'],
['id' => 'image', 'label' => 'Image', 'type' => 'image'],
['id' => 'bg_color', 'label' => 'Background','type' => 'color'],
['id' => 'show_cta', 'label' => 'Show CTA', 'type' => 'checkbox'],
['id' => 'cta_text', 'label' => 'CTA Text', 'type' => 'text'],
],
'tabs' => [
['id' => 'content', 'label' => 'Content', 'fields' => ['heading', 'image']],
['id' => 'design', 'label' => 'Design', 'fields' => ['bg_color']],
['id' => 'cta', 'label' => 'CTA', 'fields' => ['show_cta', 'cta_text']],
],
]);
Other Metabox config options
| Option | Default | Description |
|---|---|---|
screens | ['page'] | Post types, page slugs, or page template filenames to attach to — accepts an array |
context | 'normal' | Position: 'normal', 'side', 'advanced' |
priority | 'high' | Order: 'high', 'default', 'low' |
prefix | '_taw_' | Meta key prefix applied to all field IDs |
icon | (none) | SVG string — displayed as the metabox icon |
show_on | (none) | callable(WP_Post): bool — return false to hide the metabox |
Metabox Retrieval API
Use these static helpers inside getData() or anywhere in your templates.
use TAW\Core\Metabox\Metabox;
// Plain text / any scalar value
$heading = Metabox::get($postId, 'hero_heading');
// Checkbox → boolean (saves as '1'/'0', returns bool)
$showCta = Metabox::get_bool($postId, 'show_cta');
// Image attachment ID → URL
$imageUrl = Metabox::get_image_url($postId, 'hero_image', 'large');
// Color with fallback
$bgColor = Metabox::get_color($postId, 'bg_color', '#ffffff');
// post_select → array of post IDs (works for single and multi)
$featuredId = Metabox::get_posts($postId, 'featured_post')[0] ?? null;
$relatedIds = Metabox::get_posts($postId, 'related_posts');
// repeater → array of rows, each an associative array
$teamMembers = Metabox::get_repeater($postId, 'team_members');
foreach ($teamMembers as $member) {
echo esc_html($member['name'] ?? '');
echo esc_html($member['role'] ?? '');
}
Forms
TAW\Core\Form\Form is a config-driven frontend form builder that handles CSRF protection, honeypot spam filtering, rate limiting, optional Cloudflare Turnstile bot verification, field validation, AJAX submission (no page reload), email delivery, and automatic submission persistence.
Registration and rendering
Forms must be registered before templates load. The correct place is inside the block's boot() method, wrapped in add_action('init', ...) so translation functions are safe. Display the form in your template with Form::display().
use TAW\Core\Form\Form;
// In your MetaBlock::boot():
public static function boot(): void
{
add_action('init', static function () {
Form::register([
'id' => 'contact',
'submit_label' => 'Send Message',
'messages' => ['success' => "Thanks! We'll be in touch."],
'email' => [
'to_self' => ['subject' => 'New contact', 'template' => 'contact-self'],
'to_client' => ['subject' => 'Got your message', 'template' => 'contact-client'],
],
'fields' => [
['id' => 'name', 'label' => 'Name', 'type' => 'text', 'required' => true],
['id' => 'email', 'label' => 'Email', 'type' => 'email', 'required' => true],
['id' => 'message', 'label' => 'Message', 'type' => 'textarea', 'required' => true],
],
]);
});
}
// In the block's index.php template:
Form::display('contact');
When both email.to_self.template and email.to_client.template are set, delivery uses Mailer + MailTemplate. Otherwise Form falls back to plain-text wp_mail().
Input field types
| Type | Description |
|---|---|
text | Single-line text |
email | Email address — validated with is_email() |
tel | Phone number |
url | URL |
number | Numeric input |
textarea | Multi-line text; accepts rows (default 4) |
select | Dropdown; pass options as ['value' => 'Label'] |
radio | Radio group; pass options; accepts layout ('horizontal' default / 'vertical') |
checkbox | Boolean toggle; value is '1' when checked |
checkbox_group | Multiple checkboxes; pass options; accepts layout; stored as comma-separated string |
date | Native date picker; accepts min_date and max_date (ISO format YYYY-MM-DD) |
Any other value (e.g. password, hidden) is passed straight through as the HTML type attribute.
Security
Every form has CSRF (nonce) protection and honeypot spam filtering by default — no configuration needed.
Rate limiting is on by default too: 5 attempts per 60 seconds, per IP, per form — backed by WP transients, no external cache (Redis/Memcached) required. 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 the default
// 'rate_limit' => false, // or disable entirely
'fields' => [...],
]);
Cloudflare Turnstile is opt-in bot verification. Site/secret keys are PHP constants defined in wp-config.php — the same pattern as database credentials, never a metabox/OptionsPage field, since those are readable via the REST API by anyone with edit_posts.
// wp-config.php
define('TAW_TURNSTILE_SITE_KEY', '0x...');
define('TAW_TURNSTILE_SECRET_KEY', '0x...');
Form::register([
'id' => 'contact',
'turnstile' => true,
'fields' => [...],
]);
Get real keys from the Cloudflare Turnstile dashboard. If a form opts in but keys aren't configured yet, the widget silently doesn't render and no verification runs — a WP_DEBUG-only notice flags the misconfiguration to developers, not visitors. Turnstile::verify() fails closed on any network error or malformed response from Cloudflare's API.
Field validation rules, beyond required:
| Rule | Applies to | Effect |
|---|---|---|
min_length | text-like fields | Rejects values shorter than N characters |
max_length | text-like fields | Rejects values longer than N characters |
pattern (+ pattern_message) | text-like fields | PHP regex, matched against the whole value |
min / max | number fields | Numeric range |
['id' => 'name', 'type' => 'text', 'min_length' => 2, 'max_length' => 80],
['id' => 'phone', 'type' => 'tel', 'pattern' => '[0-9+ ()-]{7,20}', 'pattern_message' => 'Enter a valid phone number.'],
['id' => 'guests','type' => 'number', 'min' => 1, 'max' => 20],
These render as native HTML minlength/maxlength/pattern/min/max attributes for client-side UX, but the authoritative check always runs server-side — HTML attributes are trivially removable from the DOM. An empty, non-required field never fails these checks.
Custom per-field error messages — every rule (required, the built-in email format check, min_length, max_length, pattern, min, max) accepts a {rule}_message override; falls back to a 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.'],
Form-level default messages — to set validation copy once for an entire form (e.g. translating every rule for a non-English site) instead of repeating a {rule}_message on every field, pass a top-level messages entry per rule. Precedence: field-level {rule}_message > form-level messages.{rule} > built-in English default. required/min_length/max_length/pattern/min/max templates take the same sprintf() placeholders as the built-in defaults (field label as %s/%1$s, the rule's numeric bound as %2$d/%2$s); email takes no placeholders.
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' => [...],
]);
Multi-column layout
Fields live inside a 12-column CSS grid. Use the width key (as a percentage) to control how many columns a field spans. On mobile all fields collapse to full width.
'fields' => [
['id' => 'name', 'type' => 'text', 'label' => 'Name', 'width' => 50],
['id' => 'company', 'type' => 'text', 'label' => 'Company', 'width' => 50],
['id' => 'phone', 'type' => 'tel', 'label' => 'Phone', 'width' => 33],
['id' => 'email', 'type' => 'email', 'label' => 'Email', 'width' => 67],
['id' => 'message', 'type' => 'textarea', 'label' => 'Message', 'width' => 100],
],
width value | Grid span |
|---|---|
≤ 25 | 3 / 12 columns |
≤ 33 | 4 / 12 columns |
≤ 50 | 6 / 12 columns |
≤ 67 | 8 / 12 columns |
≤ 75 | 9 / 12 columns |
> 75 or omitted | 12 / 12 columns (full width) |
Structural field types
Structural fields are cosmetic only — they have no id, no validation, and produce no submission data.
['type' => 'heading', 'label' => '1. Personal Data', 'subtitle' => 'General identification'],
['type' => 'divider'],
['type' => 'html', 'content' => '<p class="text-sm text-gray-500">All fields marked * are required.</p>'],
| Type | Description |
|---|---|
heading | Dark section banner with label and optional subtitle |
divider | Horizontal rule (<hr>) |
html | Raw HTML via content key — rendered with wp_kses_post |
All fields (including structural) accept width (percentage) for column placement.
Conditional fields
Fields can show or hide based on other field values. Conditions are evaluated in the browser and re-enforced on the server — hidden fields are excluded from validation and submission data regardless of client-side state.
By default all conditions use AND logic. Add 'relation' => 'any' to switch to OR:
// AND logic (default) — show 'cta_text' only when 'show_cta' is checked
['id' => 'show_cta', 'label' => 'Show CTA', 'type' => 'checkbox'],
['id' => 'cta_text', 'label' => 'CTA Text', 'type' => 'text',
'conditions' => [
['field' => 'show_cta', 'operator' => '==', 'value' => '1'],
]],
// OR logic — show 'spouse_name' when married OR cohabiting
['id' => 'spouse_name', 'label' => 'Spouse / Partner name', 'type' => 'text',
'conditions' => [
'relation' => 'any',
'rules' => [
['field' => 'estado_civil', 'operator' => '==', 'value' => 'married'],
['field' => 'estado_civil', 'operator' => '==', 'value' => 'cohabiting'],
],
]],
Supported operators: ==, !=, >, <, >=, <=, contains
Multi-step forms
Replace the top-level fields key with steps. Each step has a title (shown in a numbered indicator) and its own fields array. All field types, widths, and conditions work identically inside steps.
Form::register([
'id' => 'application',
'submit_label' => 'Submit',
'next_label' => 'Continue', // optional; default "Next"
'prev_label' => 'Back', // optional; default "Back"
'messages' => ['success' => 'Your form has been received.'],
'steps' => [
[
'title' => 'Personal Info',
'fields' => [
['type' => 'heading', 'label' => '1. General Data'],
['id' => 'nombre', 'label' => 'Name', 'type' => 'text', 'required' => true, 'width' => 50],
['id' => 'email', 'label' => 'Email', 'type' => 'email', 'required' => true, 'width' => 50],
],
],
[
'title' => 'Declaration',
'fields' => [
['type' => 'html', 'content' => '<p>I declare that all information provided is true.</p>'],
['id' => 'confirm', 'label' => 'I confirm', 'type' => 'checkbox', 'required' => true],
],
],
],
]);
Next validates required fields in the current step before advancing. Back navigates without validation. Submit only appears on the last step. All fields from all steps are submitted together in a single AJAX request; if server validation fails, the form auto-navigates back to the step containing the first failing field.
Submission persistence
TAW\Core\Form\SubmissionsHandler is wired up automatically by Theme::boot() — no manual instantiation needed. Every successful submission is saved as a taw_submission CPT entry viewable in WP Admin → Submissions.
Configure the webhook endpoint and HMAC secret under Settings → Form Webhook in the WordPress admin. TAW Core signs outbound submission payloads with HMAC-SHA256 so your receiver can verify authenticity.
Per-form webhooks — each form can fire to its own webhook target instead of one shared site-wide URL, resolved in precedence order:
- An admin-configured override for that specific form (Settings → Form Webhook page's Per-Form Webhooks table — one row per registered form).
- A code-level default set via the form's own
webhookconfig key:Form::register([ 'id' => 'contact', 'webhook' => ['url' => 'https://n8n.example.com/webhook/contact', 'secret' => 'optional-hmac-secret'], 'fields' => [...], ]); - The Default Webhook at the top of the same settings page — the site-wide fallback for any form with neither of the above.
A form with none of the three configured just doesn't fire a webhook — the submission is still saved to the taw_submission CPT either way.
Payload shape:
{
"event": "new_submission",
"form_id": "contact",
"post_id": 142,
"submitted_at": "2026-02-07T12:30:00+00:00",
"site_url": "https://example.com",
"page_url": "https://example.com/contact",
"ip": "203.0.113.42",
"data": { "name": "Jane Doe", "email": "jane@example.com", "message": "Hello!" }
}
page_url — the full URL of the page the form was actually submitted from — is 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 several different pages; this is what lets a downstream automation tell those submissions apart without needing a separate form_id per page.
Customizing the payload:
// In the theme's inc/customizations.php:
add_filter('taw_form_webhook_payload', function (array $payload, string $formId, int $postId, array $data) {
// Route the same form's submissions to different n8n destinations
// depending on which section of the site they came from.
$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);
taw_form_webhook_payload runs before the HMAC signature is computed, so the signature always covers exactly what's sent — filter freely without breaking signature verification on the receiving end.
TAW\Support\EmailConfig::useEmailit() routes all wp_mail() calls — form submissions, password resets, WooCommerce order emails, anything else that goes through wp_mail() — through Emailit's API instead of the site's default mail transport.
This is a per-site, opt-in, paid add-on — not every client site needs it. Gate the call on a defined('EMAILIT_API_KEY') check so it's a true no-op on sites that don't define the constant.
// In the theme's inc/customizations.php, before Theme::boot():
use TAW\Support\EmailConfig;
if (defined('EMAILIT_API_KEY')) {
EmailConfig::useEmailit(
apiKey: EMAILIT_API_KEY,
from: defined('EMAILIT_FROM_EMAIL') ? EMAILIT_FROM_EMAIL : get_bloginfo('admin_email'),
fromName: defined('EMAILIT_FROM_NAME') ? EMAILIT_FROM_NAME : '',
);
}
Requires the official SDK, installed only on sites that use it:
composer require emailit/emailit-php
EMAILIT_API_KEY (and optionally EMAILIT_FROM_EMAIL / EMAILIT_FROM_NAME) are site-specific secrets that belong in that site's wp-config.php — never commit them into the theme repo. If the SDK isn't installed, the API key is empty, or the Emailit API call throws, EmailConfig falls back to normal wp_mail() transparently rather than silently dropping the email.
Options page
TAW\Core\OptionsPage\OptionsPage provides site-wide settings stored in wp_options, using the same field config format as metaboxes. Configure it in inc/options.php.
new OptionsPage([
'id' => 'taw_settings',
'title' => 'TAW Settings',
'menu_title' => 'TAW Settings',
'capability' => 'manage_options',
'icon' => 'dashicons-screenoptions',
'position' => 2,
'fields' => [
['id' => 'company_name', 'label' => 'Company Name', 'type' => 'text', 'width' => '33.33'],
['id' => 'company_phone', 'label' => 'Phone Number', 'type' => 'text', 'width' => '33.33'],
['id' => 'company_email', 'label' => 'Email Address', 'type' => 'text', 'width' => '33.33'],
['id' => 'footer_text', 'label' => 'Footer Text', 'type' => 'textarea'],
['id' => 'logo', 'label' => 'Logo', 'type' => 'image'],
],
'tabs' => [
['label' => 'General', 'fields' => ['company_name', 'company_phone', 'company_email']],
['label' => 'Footer', 'fields' => ['footer_text']],
],
]);
Options Page reuses the exact TAW\Core\Metabox\Metabox field renderer, so every field type behaves identically to its Metabox counterpart — the only difference is that values are read back with OptionsPage::get() instead of Metabox::get(). The same tabbed layout, width grid spans, validation, conditions logic, and readonly enforcement from metaboxes all apply.
If you're registering this from inc/options.php in a Theme::bootstrapFullSite() scaffold, translated field labels (__('Phone', 'taw-theme')) are safe to use as-is — bootstrapFullSite() (taw/core v1.16.67+) defers both the textdomain load and inc/options.php's own require to after_setup_theme, specifically to avoid WordPress 6.7+'s _load_textdomain_just_in_time notice. Don't call load_theme_textdomain() yourself in inc/customizations.php — it's already handled.
Supported field types
Each field below uses a type value that maps directly to the Options Page renderer.
Other OptionsPage config options
| Option | Default | Description |
|---|---|---|
id | (required) | Unique slug used as the admin menu page slug and the settings group name |
title | Value of id | Page heading shown in the WordPress admin content area |
menu_title | Value of title | Label shown in the WordPress admin sidebar menu |
capability | 'manage_options' | WordPress capability required to view and save the page |
prefix | '_taw_' | Option name prefix applied to all field IDs |
icon | 'dashicons-admin-generic' | Dashicon or SVG string used for the top-level admin menu icon |
position | (none) | Menu order position passed to add_menu_page() |
fields | [] | Top-level field definitions, same format as Metabox |
tabs | [] | Optional tabbed layout — same format as Metabox tabs |
Retrieval
use TAW\Core\OptionsPage\OptionsPage;
$phone = OptionsPage::get('company_phone');
$logo = OptionsPage::get_image_url('logo', 'medium');
Navigation menus
TAW\Core\Menu\Menu wraps WordPress nav menus into a typed tree, giving you full control over markup without wp_nav_menu().
use TAW\Core\Menu\Menu;
$menu = Menu::get('primary');
if ($menu && $menu->hasItems()) {
foreach ($menu->items() as $item) {
echo '<a href="' . esc_url($item->url()) . '"';
if ($item->openInNewTab()) echo ' target="_blank" rel="noopener"';
echo '>' . esc_html($item->title()) . '</a>';
if ($item->hasChildren()) {
foreach ($item->children() as $child) {
// render child item
}
}
}
}
Menu API
| Method | Returns | Description |
|---|---|---|
Menu::get($location) | ?Menu | Load a menu by its registered location slug |
$menu->items() | MenuItem[] | Root-level items |
$menu->hasItems() | bool | |
$menu->name() | string | The menu name set in WordPress admin |
MenuItem API
| Method | Returns | Description |
|---|---|---|
title() | string | Menu item label |
url() | string | Destination URL |
target() | string | '_self' or '_blank' |
openInNewTab() | bool | True when target is _blank |
hasChildren() | bool | |
children() | MenuItem[] | Direct child items |
isActive() | bool | Current page matches this item |
isActiveParent() | bool | A child of this item is the current page |
isActiveAncestor() | bool | A descendant of this item is the current page |
isInActiveTrail() | bool | This item or any ancestor/descendant is the current page |
classes() | string[] | Custom classes only (WP auto-classes filtered out) |
wpClasses() | string[] | All classes including WP's auto-generated ones |
objectType() | string | Object type ('page', 'post', 'custom', etc.) |
objectId() | int | The underlying post/term ID |
description() | string | Item description set in WordPress menu editor |
wpPost() | WP_Post | The raw WP menu item object |
Menus (primary, footer, etc.) are registered via register_nav_menus() in functions.php. Assign menus to locations in WordPress Admin → Appearance → Menus.
REST API
TAW Core registers the following REST endpoints automatically via Theme::boot():
| Method | Endpoint | Purpose |
|---|---|---|
GET | /taw/v1/search-posts | Post search powering post_select fields. Requires edit_posts. |
POST | /taw/v1/visual-editor/save | Save Visual Editor changes. Requires edit_posts. |
GET | /taw/v1/visual-editor/fields | Load all registered fields and current values for the editor panel. Requires edit_posts. |
GET | /taw/v1/icons | Lucide icon search powering the icon field type. Requires edit_posts. Only registered when Lucide::enable() was called — see Icon System. |
search-posts
TAW\Core\Rest\SearchEndpoints powers the post_select metabox field.
Example request:
GET /wp-json/taw/v1/search-posts?s=hero&post_type=page&per_page=5
Authorization: Cookie (requires edit_posts capability)
Example responses:
[
{
"id": 42,
"title": "Home",
"post_type": "page",
"status": "publish",
"date": "2025-01-15T10:30:00",
"edit_url": "https://example.com/wp-admin/post.php?post=42&action=edit",
"permalink": "https://example.com/",
"thumbnail": "https://example.com/wp-content/uploads/hero.jpg"
}
]
{
"code": "rest_forbidden",
"message": "Sorry, you are not allowed to do that.",
"status": 401
}
Query parameters
Search string. Omit to return the most recent posts.
Post type(s) to search — comma-separated for multiple (e.g. post,page). Defaults to post. Passing page is handled correctly even though WordPress treats it as a special case internally.
Results per page. Accepts 1–50. Defaults to 10.
Comma-separated post IDs to exclude from results.
icons
TAW\Core\Rest\IconsEndpoint powers the icon metabox field's wp-admin picker. Only registered when Lucide::enable() has been called.
Example request:
GET /wp-json/taw/v1/icons?search=arrow&per_page=20
Authorization: Cookie (requires edit_posts capability)
Example response:
[
{ "name": "arrow-right", "svg": "<svg xmlns="http://www.w3.org/2000/svg" ...>...</svg>" },
{ "name": "arrow-left", "svg": "<svg xmlns="http://www.w3.org/2000/svg" ...>...</svg>" }
]
Search string matched against icon names and keywords (tags, categories, aliases). Omit to return the first results in the vendored index.
Results per page. Accepts 1–120. Defaults to 60.
Visual Editor
TAW\Core\Editor\VisualEditor provides inline admin editing on the frontend. It is opt-in — you must explicitly enable it before calling Theme::boot(). In taw-theme scaffolds using Theme::bootstrapFullSite(), functions.php is framework-owned, so this goes in inc/customizations.php instead, which bootstrapFullSite() guarantees loads before boot():
// inc/customizations.php
use TAW\Core\Editor\VisualEditor;
VisualEditor::enable();
Once enabled, authenticated users with edit_posts capability see an Edit Visually button in the WordPress admin bar. Appending ?taw_visual_edit=1 to any URL also activates the editing shell.
What works automatically
No template changes are required for the core experience:
- All MetaBlock sections are wrapped in a clickable container (
data-taw-block-section) with hover and active outlines. - Clicking a section on the page opens its fields in the editor panel.
- Typing in a panel text field updates the matching text on the page in real time (content-matching heuristic — works when the field value appears as a discrete text node).
- The panel shows only the blocks queued for the current page via
BlockRegistry::queue().
All registered metabox fields appear in the editor panel automatically. Add 'editor' => false to a field definition to exclude it from the panel.
Changes are saved via POST /wp-json/taw/v1/visual-editor/save using the same sanitization pipeline as metaboxes.
Optional template annotations
Add annotations to make inline editing precise and to enable "Edit inline on page" mode:
// Wrap a value so it's directly clickable on the page
<?= Editor::field($data['headline'], 'hero', 'headline', 'h2') ?>
// Add data attributes to an existing element (e.g. <img>)
<img <?= Editor::attrs('hero', 'hero_image') ?> src="...">
Without annotations the panel still shows and saves all fields, and live preview works via content matching. Annotations give the editor a direct DOM reference, making live updates exact.
Use TAW\Core\Mail\MailTemplate to work with HTML or MJML templates from your theme and TAW\Core\Mail\Mailer to send emails with variable replacement.
Pre-compiled HTML templates live at mails/html/{name}.html (used in production). MJML source files live at mails/{name}.mjml and are compiled at runtime via spatie/mjml-php during development. Each template supports {{variable_name}} placeholders.
A minimal example of sending a templated email:
use TAW\Core\Mail\Mailer;
$sent = (new Mailer())
->to('support@acme-agency.test')
->subject('New contact form submission')
->template('contact') // → mails/html/contact.html (prod) or mails/contact.mjml (dev)
->setVariables([
'name' => 'Jane Chen',
'email' => 'jane@acme.com',
'message' => 'I would like to discuss a new project.',
])
->send();
if (! $sent) {
// Handle failed mail transport (log, retry, etc.).
}
Mailer uses the underlying wp_mail() transport. Make sure your WordPress site is configured with a working mail provider (SMTP or transactional service) so test and production emails are delivered reliably.
TAW\Core\Mail\MailTemplate compiles templates from your theme, performs {{var}} replacement, and is responsible for producing the final HTML payload that Mailer sends.
Admin entrypoints and testing
TAW Core adds admin screens to help you operate and test these modules without writing ad-hoc scripts.
-
Tools → Test Emails — register
MailTesterinfunctions.phpto get a page for sending test emails using your templates and variables to verify layout and delivery in your environment:(new \TAW\Core\Mail\MailTester())->register(); -
Settings → Form Webhook — configures the webhook URL and HMAC secret that
SubmissionsHandleruses when posting saved submissions to an external endpoint.
Use these screens to confirm templates render as expected and form submissions reach your downstream systems before wiring them into live flows.
Icon System
TAW Core vendors the full Lucide icon set (~1,750 icons) directly inside the package — resources/icons/lucide/ (SVGs) plus resources/icons/lucide-index.json (a searchable name/keyword index). The wp-admin icon picker reads only these local files, so it never makes a network call. Re-vendor the set with php bin/taw icons:sync whenever Lucide ships new icons.
The icon picker is opt-in. Call this once, in inc/customizations.php, before Theme::boot():
TAW\Core\Icons\Lucide::enable();
Without it, an icon field renders an inline notice instead of the picker.
Using the icon field type
Declare it exactly like any other Metabox or OptionsPage field — see the Icon entry above for the full declaration/usage example. The stored value is a bare icon name (e.g. house), sanitized with sanitize_key().
Rendering icons in templates
Lucide::render() needs no enable() call — the same relationship Svg::register() (upload support) has to Svg::inline()/Svg::render() (template output). It reads the vendored SVG and merges class, attr, and title onto the root element.
use TAW\Core\Icons\Lucide;
echo Lucide::render('arrow-right', [
'class' => 'w-5 h-5 text-blue-600',
'title' => 'Next',
]);
Lucide's SVGs use stroke="currentColor", so CSS/Tailwind text-color utilities control icon color for free. Lucide::render() returns an empty string for an unknown or malformed name — safe to echo unconditionally.
Icon search REST endpoint
GET taw/v1/icons?search=&per_page= powers the wp-admin picker (see REST API below) and is only registered once Lucide::enable() has been called. Requires edit_posts plus a valid wp_rest nonce, same as the other TAW REST routes.
Media Folders
Nestable Media Library folders, built on a single hierarchical taxonomy (taw_media_folder) registered on attachment with show_in_rest enabled. That one flag gives the whole feature its REST layer for free, straight from WordPress core — there's no custom REST endpoint class:
- Full folder (term) CRUD, including re-nesting via
parent, atwp/v2/taw_media_folder. - A
taw_media_folderquery param on the existingwp/v2/mediaroute, for filtering by folder and for reassigning a file's folder (PATCH wp/v2/media/<id>).
Opt-in at the framework level, but on by default in the taw-theme scaffold's inc/customizations.php:
TAW\Core\Media\MediaFolders::enable();
Remove that line for a site that doesn't need folder organization. Only the upload_files capability is required — not manage_options.
Three admin surfaces
Media → Folders
A dedicated screen: a folder tree (create, rename, delete, drag-and-drop to re-nest) alongside a drag-and-drop attachment grid, including an "Unfiled" pseudo-folder for attachments with no folder assigned. Entirely custom markup and REST calls — no WordPress core Grid-view (Backbone) internals are touched here.
Classic List view
The existing Media Library list screen (upload.php?mode=list) gets a folder filter dropdown, a "Folder" column, and a "Move to folder…" bulk action — for anyone who prefers browsing there instead.
Grid view sidebar
A FileBird-style sidebar (Alpine.js) bolted onto the default Media Library Grid view — the same folder tree and full CRUD as the dedicated screen. Clicking a folder filters the grid live via an ajax_query_attachments_args filter plus a narrow bridge that sets props on wp.media's existing Backbone query object, not a Backbone view override. Stays in sync with the List view's dropdown via the same taw_media_folder param. Grid thumbnails (single or multi-selected) are draggable straight onto a folder row to file them, and internal drags are kept from triggering WordPress core's own upload-dropzone overlay. Two independent sort controls, each remembered per-browser: the folder tree by name or creation order, and the file grid by name, upload date, or file size.
File-size sorting reads a dedicated _taw_media_filesize postmeta value, since WordPress core only stores file size nested inside a serialized metadata array that SQL can't ORDER BY directly. It's populated on every new upload and backfilled once for pre-existing attachments the first time anyone sorts by size.
A folder's position in the tree is its only "category" — one folder per attachment (wp_set_object_terms()), with no separate tagging layer on top.
Helpers
TAW Core ships static helper classes for common operations, all PSR-4 autoloaded under TAW\Helpers\.
Framework paths (TAW\Helpers\Framework)
Resolve absolute paths and public URLs relative to either the taw/core package itself or your theme root. Useful for referencing packaged assets without hard-coding paths.
use TAW\Helpers\Framework;
// Absolute filesystem path within taw-core
Framework::path('assets/admin.css');
// Public URL within taw-core
Framework::url('assets/admin.css');
// Absolute filesystem path within the active theme
Framework::themePath('resources/');
// Public URL within the active theme
Framework::themeUrl('resources/');
Debug utilities (TAW\Helpers\Dump)
Formatted debug helpers intended for local and development environments only. Do not leave these calls in production code.
use TAW\Helpers\Dump;
// Die and dump — outputs a formatted value and halts execution
Dump::dd($value);
// Log — writes a formatted value to the error log without halting
Dump::log($value);