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'] ?? '');
}
Locking metabox order
By default WordPress lets each user drag-and-drop metaboxes into their own order, saved per user — so the same screen looks different for every editor. MetaboxOrder forces one fixed order and disables dragging.
use TAW\Core\Metabox\MetaboxOrder;
// Explicit order.
MetaboxOrder::lock('page', ['hero_settings', 'video_settings', 'faq_settings']);
// Or derive the order per page from its template's BlockRegistry::render()
// sequence — call once in functions.php.
MetaboxOrder::lockFromTemplate(); // screen defaults to 'page'
lockFromTemplate() statically scans the template file that will render each page (it is never executed in wp-admin), reads the BlockRegistry::render('block_id') calls in source order, and maps each block to the metabox(es) it registered. Boxes not tied to a block on the page (core WordPress boxes, for example) keep their relative position and render after the ordered ones.
Template resolution mirrors WordPress's own hierarchy, not just the Page Attributes dropdown. Candidates are tried highest-priority first:
| Order | Template | Applies when |
|---|---|---|
| 1 | _wp_page_template | A page template is explicitly selected in Page Attributes. |
| 2 | front-page.php | The page is the static front page (Settings → Reading). |
| 3 | home.php | The page is the posts page (Settings → Reading). |
| 4 | page-{slug}.php | A template file matches the page slug. |
Cases 2–4 write no post meta — WordPress applies them by convention. Pages matching none are left unordered. This resolution and the screens template matching in Metabox share one method, Metabox::templateCandidatesForPost(), so they cannot drift apart.
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) |
image | Renders <input type="file" accept="image/*">. Uploaded via media_handle_upload() on submit and verified as a real image server-side (wp_attachment_is_image() — the accept attribute is only a client-side hint). The field's value in on_submit's $data is the resulting attachment ID (0 when optional and omitted). Automatically adds enctype="multipart/form-data" to the <form> tag. |
wysiwyg | Renders WordPress's own classic editor (wp_editor(), with media_buttons off) — gives TinyMCE's built-in paste-from-Word cleanup for free. Sanitized server-side with wp_kses_post(). |
Any other value (e.g. password, hidden) is passed straight through as the HTML type attribute.
Submit button
submit_label sets the button text ('Send Message' by default, or 'Submit' for a multi-step form's final step). submit_icon optionally renders an icon after the label — a Lucide icon name (via Lucide::render(), no Lucide::enable() needed — same as any direct template call to it) or raw '<svg>...</svg>'/HTML, printed as-is:
Form::register([
'id' => 'contact',
'submit_label' => 'Send message',
'submit_icon' => 'send',
'fields' => [...],
]);
The icon renders inside its own <span class="taw-btn-icon" aria-hidden="true"> — decorative, since the button's accessible name already comes from the label. Nothing renders when submit_icon is unset, empty, or an icon name Lucide::render() doesn't recognize.
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.
On Submit Callback
The on_submit config key runs custom logic right after a submission is saved (the taw_submission CPT record is guaranteed to exist first, same ordering as the email step) — the escape hatch for anything beyond "save it and maybe email it," like creating a real post from the submitted data:
Form::register([
'id' => 'client_post_submission',
'fields' => [
['id' => 'post_title', 'label' => 'Title', 'type' => 'text', 'required' => true],
['id' => 'post_body', 'label' => 'Body', 'type' => 'wysiwyg', 'required' => true],
['id' => 'featured_image', 'label' => 'Featured Image', 'type' => 'image'],
],
'on_submit' => function (array $data, int|false $submissionId) {
// $submissionId is the taw_submission record's own post ID — this
// callback creates a *different*, unrelated post from the submitted
// data, so it uses its own $newPostId rather than reusing it.
$newPostId = wp_insert_post([
'post_title' => $data['post_title'],
'post_content' => $data['post_body'],
'post_status' => 'draft',
], true);
if (is_wp_error($newPostId)) {
throw new \RuntimeException($newPostId->get_error_message());
}
if (!empty($data['featured_image'])) {
set_post_thumbnail($newPostId, (int) $data['featured_image']);
}
},
]);
The callback's signature is function(array $data, int|false $postId): void — $data is the same fully-validated, sanitized field data the CPT record and webhook payload are built from, and the second parameter is the taw_submission post SubmissionsHandler::saveSubmission() just created (false if that save itself failed — check before relying on it as a real ID). Name it whatever fits the callback; a callback that doesn't need it can omit the second parameter entirely — PHP silently ignores extra arguments passed to a closure declaring fewer, so every existing on_submit callback written against the old one-argument signature keeps working unchanged.
Throw a \RuntimeException to reject the submission with a message shown to the submitter — not any \Throwable. This handler runs for anonymous, unauthenticated submitters (wp_ajax_nopriv_), so any other exception type is treated as unexpected and logged server-side via error_log() instead, with a generic message shown to the visitor — never an arbitrary exception's own message, which could leak internal detail (file paths, query fragments, and the like).
If an image field already uploaded a file before the callback threw, that attachment is deleted automatically rather than left orphaned in the Media Library. A form with no on_submit behaves exactly as before this existed.
Page Password Protection
TAW\Core\Auth\PagePassword gates a page template behind a single shared password — declared directly in the template, not a wp-admin toggle:
<?php
/**
* Template Name: Client Post Submission
*
* Requires TAW_CLIENT_PORTAL_PASSWORD defined in this site's wp-config.php.
*/
use TAW\Core\Auth\PagePassword;
PagePassword::protect([
'password' => defined('TAW_CLIENT_PORTAL_PASSWORD') ? TAW_CLIENT_PORTAL_PASSWORD : '',
'title' => 'Client Submission Portal',
]);
get_header();
// ... the real template — only reached once unlocked ...
Must be the very first thing in the template — before get_header(), before any output. It needs to send a redirect and set a cookie, which requires no prior output; on a locked visit it renders its own minimal, standalone gate screen and calls exit, so the calling template's code below protect() never runs until unlocked.
Like Form's Turnstile secret key, the password itself should live in that site's wp-config.php as a PHP constant, never hardcoded in a committed template file.
Config:
| Key | Default | Notes |
|---|---|---|
password | (required) | Empty or missing fails closed — the gate denies access rather than falling open, since this is access control, not a supplementary check like Form's Turnstile integration. A WP_DEBUG-only notice flags the misconfiguration for developers. |
id | substr(hash_hmac('sha256', $password, wp_salt('auth')), 0, 12) | Explicit scope key for the unlock cookie. The default means two protect() calls sharing the same password automatically share one unlock — no config needed for "this client has several protected pages." |
title | 'Protected Page' | Heading shown on the gate screen. |
duration | 30 days | How long the signed unlock cookie lasts once the correct password is entered. |
Security model:
- The unlock cookie is a signed, stateless token (
hash_hmac('sha256', ..., wp_salt('auth')), not the plaintext password) — a client can't forge one by just setting a cookie manually. - Password comparison uses
hash_equals()(timing-safe). - Gate attempts are rate-limited per-IP via the same
TAW\Core\Form\RateLimiterused byForm. - A correct submission redirects (POST/redirect/GET) rather than re-rendering, so a page refresh never resubmits the password.
- Both the gate screen and the unlocked page send
nocache_headers()— without this, a full-page cache or CDN in front of PHP could serve a cached copy of the unlocked page to a different visitor who never entered the password.
Known limitations, by design: PagePassword gates template rendering only, not WordPress's own REST API — if the underlying post's status is publish, its post_content remains readable via wp/v2/pages/{id} regardless of the gate. Irrelevant for a form-only page (there's no real content in post_content to leak), but matters if this is ever reused to gate an actual content page — keep the post private/draft, or additionally restrict REST access, if the body text itself needs to stay secret. Separately, rate limiting relies on SubmissionsHandler::getUserIp(), which trusts a client-supplied X-Forwarded-For unless a trusted reverse proxy sits in front of the site — so a strong, non-guessable password is the real defense against sustained brute-forcing, not the rate limiter alone.
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. |
POST | /taw/v1/chat | Hybrid-RAG chatbot — see Sovereign Hybrid-RAG Chatbot. Public by default, rate-limited. |
GET | /taw/v1/bible/books | Bible reader — see Bible Reader Corpus. Opt-in, public, rate-limited. |
GET | /taw/v1/bible/books/{slug}/chapters/{n} | Bible reader — one chapter's verses/sections/notes. |
GET | /taw/v1/bible/search | Bible reader — full-text search over verses or notes. |
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.
chat
TAW\Core\Rest\RagChatEndpoint runs a tool-calling chat turn against one or more named knowledge bases — see Sovereign Hybrid-RAG Chatbot for the full picture.
POST /wp-json/taw/v1/chat
Content-Type: application/json
{"message": "Do you have anything about return policies?"}
{
"message": "Yes — according to our policy page, items can be returned within 30 days..."
}
{
"error": "Too many requests. Please try again shortly."
}
The user's message. 1–4000 characters.
Prior turns of the conversation: [{"role": "user"|"assistant", "content": "..."}, ...]. Capped to the most recent 10 turns.
Public by default (RagSettings::publicChatEnabled()) — WordPress's cookie-auth nonce check only protects logged-in callers, so anonymous requests are defended by unconditional rate limiting (20 requests / 10 minutes per IP) instead, regardless of the public/logged-in-only setting.
bible
TAW\Core\Rest\BibleEndpoint — read-only routes over an installed reference corpus. See Bible Reader Corpus for the full picture. Only registered when BibleEndpoint::enable() has been called; all three routes 404 with {"error": "No Bible corpus is installed."} until bin/taw corpus:install has been run.
GET /wp-json/taw/v1/bible/books
GET /wp-json/taw/v1/bible/books/genesis/chapters/1
GET /wp-json/taw/v1/bible/search?q=en+el+principio&scope=verses&limit=20
Book slug (path parameter on the chapter route), e.g. genesis, iohannes.
Search query — every whitespace-separated word is matched individually and ANDed together (a row must contain every word, in any order or position), not raw MATCH operator syntax and not one exact contiguous phrase.
verses (default) or notes.
Results to return. Clamped to 1–50. Defaults to 20.
{
"book": {
"id": 1, "slug": "genesis", "name": "Génesis", "testament": "Antiguo Testamento",
"division": "Pentateuco", "canon": "protocanonical", "book_order": 1, "chapter_count": 50
},
"chapter_number": 1,
"verses": [
{"id": 1, "verse_number": 1, "verse_label": "1", "text": "Al principio creó Dios el cielo y la tierra.", "is_editorial_addition": false}
],
"sections": [
{"id": 1, "kind": "pericope", "heading": "La creación del cielo y de la tierra", "start_chapter": 1, "start_verse": 1, "end_chapter": 1, "end_verse": 1, "position": 1001}
],
"notes": [
{"id": 1, "type": "commentary", "marker": "1", "body": "1. Al principio, es decir...", "start_chapter": 1, "start_verse": 1, "end_chapter": 1, "end_verse": 1, "position": 0}
]
}
{"error": "Chapter not found."}
Public — this is public Scripture text, no auth required — but every route is still rate limited the same way /taw/v1/chat is: 120 requests / 10 minutes per IP for books/chapter reads, 30 / 10 minutes for search (a heavier query and a more attractive scraping target).
catechism
TAW\Core\Rest\CatechismEndpoint — the same read-only posture as bible, one level deeper (part → section → chapter → question/answer) and explicitly scoped to one edition per request. See Catechism Reader Corpus for the full picture. Only registered when CatechismEndpoint::enable() has been called.
GET /wp-json/taw/v1/catechism/editions
GET /wp-json/taw/v1/catechism/pius-x/parts
GET /wp-json/taw/v1/catechism/pius-x/chapters/12
GET /wp-json/taw/v1/catechism/pius-x/search?q=gracia&limit=20
Edition slug (path parameter on every route but editions), e.g. pius-x — registered in TAW\Core\Corpus\Catechism\CatechismEditions.
Search query — same word-splitting/ANDing semantics as bible's q parameter, matched across both question_text and answer_text.
Results to return. Clamped to 1–50. Defaults to 20.
{
"part": {"id": 1, "name": "De la Fe"},
"section": {"id": 3, "title": "El Credo"},
"chapter": {"id": 12, "title": "El primer artículo"},
"paragraphs": [
{"id": 45, "paragraph_number": 1, "question_text": "¿Qué quiere decir Creo en Dios?", "answer_text": "Quiere decir que tengo por cierto..."}
]
}
{"error": "Unknown catechism edition."}
{"error": "This catechism edition is not installed."}
Public, rate limited identically to bible: 120 requests / 10 minutes per IP for parts/chapter reads, 30 / 10 minutes for search. An unknown edition slug and a known-but-not-yet-installed edition 404 with different messages on purpose — a misconfigured customizations.php shouldn't look identical to "just hasn't been installed yet."
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.
Security / Hardening
TAW\Core\Security\Hardening collects opinionated hardening helpers. Unlike the opt-in subsystems above, these are wired into Theme::boot() by default — they are authentication-gated, their failure mode is minimal, and each carries an apply_filters() escape hatch so a headless or integration site can restore stock WordPress behavior without editing framework code.
Hardening::hideUsersEndpoint() — user-enumeration lockdown
Removes the public /wp/v2/users REST collection and the single-user /wp/v2/users/(?P<id>[\d]+) route for anonymous requests. /wp/v2/users/me and every logged-in request are left untouched.
// Already called for you by Theme::boot(). To opt a site back out —
// in inc/customizations.php or a plugin:
add_filter('taw_security_hide_users_endpoint', '__return_false');
The filter runs at rest_endpoints (REST dispatch), after the request has been resolved to a route — so one filter closes every routing form at once:
| Request form | Covered by a typical host WAF / "hide users" plugin | Covered here |
|---|---|---|
/wp-json/wp/v2/users | Usually | Yes |
/?rest_route=/wp/v2/users | Rarely — most rules match only the /wp-json/ path prefix, so this still returns 200 and leaks id, name, and author slug (≈ login name) for every user | Yes |
/batch/v1 sub-request wrapping a users call | No | Yes |
The gate is is_user_logged_in(), not a capability check. The block editor's author selector fetches /wp/v2/users?who=authors for Editors too, who lack list_users — gating on authentication keeps that (and the mobile apps, Jetpack, etc.) working while still closing the hole for logged-out visitors. /wp/v2/users/me already returns 401 without a valid login, so it is not an enumeration vector.
The classic ?author=N → /author/{slug}/ redirect probe is not handled here — it is site policy (it also kills author-archive query URLs) and overlaps with security-plugin behavior, so it lives in the taw-theme scaffold's inc/security.php, not in the framework.
Content Interchange
Moving a TAW site's state — posts / CPT entries, _taw_* fields and options, terms, media, and optionally authorship, users, comments and environment settings — between environments as one reviewable, diffable, rollback-able JSON file. content:export / content:import / content:diff, a Tools → TAW Data admin screen, and GET /wp-json/taw/v1/content/export.
php bin/taw content:export --migrate --output=/tmp/site.json
php bin/taw content:import /tmp/site.json --yes
Content Interchange
The snapshot format, the carried / not-carried table, every --with-* flag and filter, import ordering and rollback, workflow recipes, and the admin screen — on its own page.
REST-registered field meta
Theme::boot() also exposes every registered TAW field over the REST API (TAW\Core\Rest\FieldMetaRegistrar), on each post type its metabox attaches to:
- scalar fields →
register_post_meta()withshow_in_rest, the field's own sanitizer, and anauth_callbackgated onedit_postfor that post. - repeater / files / post_select (stored as JSON strings) → the raw meta stays a string, and a
register_rest_field()computed fieldtaw_<id>exposes the decoded object/array shape (and re-encodes on write), soMetabox::get_repeater()'s physical storage is untouched. - OptionsPage fields →
register_setting(..., 'show_in_rest' => …).
This exposes field values over wp/v2 for headless front-ends and external integrations. It does not add a mobile-app editing UI — classic metaboxes stay desktop-only; on-phone editing is the Visual Editor. Opt out with add_filter('taw_register_meta_in_rest', '__return_false').
Sovereign Hybrid-RAG Chatbot
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 — with an OpenAI-compatible LLM doing semantic search across whichever one a question calls for. Content-agnostic by design: no particular schema is assumed or required of an uploaded file.
"Sovereign" describes data ownership, not hosting. The LLM endpoint is admin-configurable (Settings → TAW Chatbot), defaulting to OpenAI's cloud API but swappable to any OpenAI-compatible endpoint — self-hosted Ollama, vLLM, and similar all work. Your SQLite files and WordPress database never leave the site either way.
Strict separation of concerns: every piece of this — SQLite connections, embeddings, LLM orchestration, REST — lives in taw/core. The consuming theme (taw-theme) owns only the chat widget's Alpine.js/Tailwind presentation and talks exclusively to POST /taw/v1/chat — no LLM base URL or API key ever reaches the browser.
Opt-in (since v1.30.0) — same posture as Lucide::enable()/MediaFolders::enable()/BibleEndpoint::enable(). The settings page, knowledge-base uploads, WP-content ingestion, and POST /taw/v1/chat all stay off — no admin menu, no hooks, no REST route registered — until a theme calls:
// In the theme's inc/customizations.php, before Theme::boot():
TAW\Core\Rag\RagSettings::enable();
taw-theme's Blocks/Chatbot widget checks RagSettings::isEnabled() before enqueuing/rendering itself, so it correctly stands down on a site that hasn't opted in rather than rendering a widget that POSTs to a REST route that doesn't exist.
Knowledge bases
Settings → TAW Chatbot → Knowledge Bases (TAW\Core\Rag\KnowledgeBase\KnowledgeBaseAdminScreen) — upload any .sqlite file with a name and description; that's the entire setup. On ingestion, every table is scanned and every column whose declared SQLite type has TEXT affinity (CHAR/CLOB/TEXT, or no declared type at all) is extracted as "column: value" lines per row, chunked, embedded, and written into a taw_rag_chunks table inside that same file — one file per knowledge base, not two. A table with no text-affinity column is skipped; nothing schema-specific is assumed about what you upload.
The site's own WordPress content is always present as a built-in, non-deletable wp-content knowledge base — it isn't stored anywhere new, since it's fully derived from the chatbot's post-type/chunking settings and the existing content-ingestion pipeline.
php bin/taw content:reindex --post-type=post,page --batch=20 # backfill/refresh the wp-content knowledge base
php bin/taw content:reindex-kb kb-a1b2c3d4 # re-run ingestion for one uploaded knowledge base
Uploading a knowledge base is wp-admin only, by design — there's no CLI import step. The upload handler validates the SQLite magic-byte header before accepting a file, and ingestion always runs via WP-Cron rather than on the upload request itself.
Settings
Settings → TAW Chatbot (TAW\Core\Rag\RagSettings) exposes the API base URL, embedding/chat model names, indexed post types for the wp-content knowledge base, chunk size/overlap, max tool-call iterations, and whether anonymous visitors can chat.
The LLM API key is deliberately not one of these fields — OptionsPage fields are REST-readable by anyone with edit_posts, which makes the options table the wrong place for a secret. Define it in wp-config.php instead:
define('TAW_RAG_API_KEY', 'sk-...');
Vector search
Every knowledge base's taw_rag_chunks table is searched with pure-PHP cosine similarity by default (TAW\Core\Rag\Vector\VectorRepository) — the sqlite-vec loadable extension isn't installed on most PHP hosts, and PDO::loadExtension() only exists on PHP 8.4+'s Pdo\Sqlite driver subclass in the first place, not on a plain PDO connection on any version. VectorCapability::sqliteVecAvailable() detects it at runtime and search falls back to the brute-force path on any failure — it is never a hard dependency.
The chat tool
The orchestrator (TAW\Core\Rag\Orchestrator\ChatOrchestrator) runs an OpenAI-compatible tool-calling loop, capped at the configured max iterations, against a single tool:
search_knowledge_base(knowledge_base, query)(TAW\Core\Rag\Tools\SearchKnowledgeBaseTool) — semantic search over one named knowledge base. Itsknowledge_baseparameter is an enum built fresh from the current registry on every request, so a newly-uploaded knowledge base is searchable the moment ingestion finishes — no redeploy needed.
See POST /taw/v1/chat under REST API for the request/response shape.
Bible Reader Corpus
A read-only REST surface over a developer-installed reference corpus — currently a Straubinger-translation Spanish Catholic Bible (books/chapters/verses/sections/notes, plus full-text search), built for the fsspx-taw client site's Bible reader but framework-level and reusable: the schema itself is fixed (this isn't a generic query layer — see Sovereign Hybrid-RAG Chatbot's knowledge bases for that), but the storage/install/REST plumbing generalizes to any future reference corpus the same shape describes.
Opt-in — call TAW\Core\Rest\BibleEndpoint::enable() in the theme's customizations.php before Theme::boot(), same posture as Lucide::enable()/RagSettings::enable(). Nothing here registers on a site that doesn't call it.
Two storage backends
TAW\Core\Storage\ProtectedSqlite::isAvailable() checks extension_loaded('pdo_sqlite') and attempts a real sqlite::memory: connection — a loaded extension isn't always a functional one on every host build. This is a real, confirmed gap, not theoretical: WPMUdev's managed hosting has no pdo_sqlite/sqlite3 on either PHP-FPM or CLI, on multiple PHP versions, and declined to add it. $wpdb (and the mysqli extension it's built on, not PDO) is guaranteed on every WordPress host, since WP core itself can't function without it.
Every consumer (corpus:install, BibleEndpoint) checks isAvailable() and picks a backend accordingly — zero behavior change on a host where pdo_sqlite already works. The SQLite path is checked first and used unconditionally whenever it's both available and installed.
Installing the corpus file
A reference corpus is a curated, developer-placed dataset, not end-user content — installed via CLI, not a wp-admin upload form. corpus:install accepts either source format, auto-detected by content:
# Raw .sqlite file — needs pdo_sqlite on THIS host:
php bin/taw corpus:install /path/to/bible_straubinger.sqlite bible-straubinger.sqlite
# Portable JSON export — works on any host, no pdo_sqlite required here:
php bin/taw corpus:install /path/to/bible-export.json bible-straubinger.sqlite
.sqlite path — TAW\CLI\CorpusInstallCommand validates the SQLite magic-byte header (the same check the RAG chatbot's knowledge-base upload handler uses), confirms pdo_sqlite is available on this host, then copies the file into a protected uploads subdirectory: TAW\Core\Corpus\Storage, wp-content/uploads/taw-private/corpus/.
Deliberately a separate directory from the RAG chatbot's taw-private/rag/ — a reference corpus meant for direct structured reading and a RAG knowledge base meant for chatbot semantic search are different concerns that happen to share storage mechanics (protected directory + read-only PDO, extracted into TAW\Core\Storage\ProtectedSqlite and shared by both rather than duplicated). Keeping them apart means the RAG ingestion pipeline — which writes a taw_rag_chunks table directly into whatever .sqlite file it ingests — can never mistake a corpus file for a knowledge base upload.
Portable JSON path — for a target host with no pdo_sqlite at all. First, on a machine that does have it:
php bin/taw corpus:export /path/to/bible_straubinger.sqlite /path/to/bible-export.json
TAW\CLI\CorpusExportCommand reads the source via plain PDO (no WordPress dependency — both paths are plain CLI arguments) and writes a JSON export carrying only the columns BibleReader actually reads. Transfer that file to the target server and run corpus:install against it there — TAW\Core\Corpus\Bible\MysqlBibleInstaller creates {$wpdb->prefix}taw_corpus_bible_{books,chapters,verses,sections,notes} (FULLTEXT indexes on verses.text/notes.body) and bulk-loads the export via $wpdb.
The installed filename (.sqlite path) is fixed and versionless — the file's own meta table carries release_channel/generated_at/source_revision, so reader code never needs to know which build is installed. Both paths are safe to re-run: the .sqlite path overwrites the copied file; the JSON path truncates and reloads its MySQL tables inside a transaction.
Reading the corpus
TAW\Core\Corpus\Bible\BibleReaderInterface is implemented by two independent readers with identical output shapes — BibleReader (SQLite, plain PDO, opened via Storage::openReadOnly() — PRAGMA query_only = 1) and MysqlBibleReader (MySQL, plain $wpdb, selected automatically when pdo_sqlite isn't available). Deliberately two separate classes rather than a shared abstract base — SQLite FTS5 and MySQL boolean-mode FULLTEXT differ enough (no snippet() equivalent in MySQL; MysqlBibleReader builds excerpts by hand) that sharing internals would mostly move complexity around, and it keeps the already-shipped BibleReader completely untouched:
$reader = new TAW\Core\Corpus\Bible\BibleReader(); // or MysqlBibleReader() — same contract
$reader->books(); // every book, grouped by testament then division, canonical order
$reader->chapter('genesis', 1); // verses + any overlapping section headings + any overlapping notes
$reader->searchVerses('en el principio'); // every word must match, any order — <mark>-highlighted excerpt
$reader->searchNotes('creación'); // same matching semantics over Straubinger's own footnote commentary
chapter() returns verses/sections/notes as three flat, chapter-scoped lists rather than an interleaved rendering shape — where a heading sits relative to a verse, or how a note marker anchors into verse text, is presentation, and stays the consuming theme's decision. searchVerses()/searchNotes() treat every whitespace-separated word as a separate AND'd term — a row must contain every word, in any order or position — rather than requiring the whole query as one exact contiguous phrase (which would silently return nothing for almost any realistic multi-word search) or exposing raw operator syntax. SQLite FTS5 does this via per-word quoted phrases; MySQL boolean mode via +word1 +word2 — same semantics, different syntax.
BibleReader is not final on purpose — every internal fetch method is protected, and FILENAME is overridable — so a theme can subclass it (different installed filename, different fetch behavior) without a taw-core fork. TAW\Core\Rest\BibleEndpoint resolves which reader it queries — SQLite first, MySQL otherwise — through a filter, so a theme can override the pick or supply an entirely different implementation:
add_filter('taw_corpus_bible_reader', function () {
return new MyThemeBibleReader(); // any BibleReaderInterface implementation
});
The filter's return is narrowed via instanceof BibleReaderInterface, falling back to the computed default (SQLite-or-MySQL) if a filter callback returns something else.
See bible under REST API for the full request/response shape of all three routes.
Catechism Reader Corpus
One level deeper than Bible Reader Corpus's book → chapter → verse: a catechism's part → section → chapter → numbered question/answer. Unlike the single-installed Bible, this is explicitly edition-parameterized from day one — more than one catechism (the Catechism of Saint Pius X today, others later) can be installed side by side. Same storage/install/REST posture and the same two-backend split as the Bible corpus, applied one navigational level deeper.
Opt-in — call TAW\Core\Rest\CatechismEndpoint::enable() in the theme's customizations.php before Theme::boot().
Editions
TAW\Core\Corpus\Catechism\CatechismEditions is the one place valid edition slugs are declared — a plain, developer-curated array, not admin-configurable, same posture as the Bible's fixed filename:
'pius-x' => ['filename' => 'catechism-pius-x.sqlite', 'name' => 'Catecismo Mayor de San Pío X'],
Adding a second edition is one new array entry plus installing its own .sqlite file or export — no other code in this namespace changes. GET /wp-json/taw/v1/catechism/editions lists every registered edition, installed or not.
Installing an edition
catechism:install <edition> <path> [filename] — the same two source formats as corpus:install, scoped to one edition at a time:
# Raw .sqlite — needs pdo_sqlite on THIS host:
php bin/taw catechism:install pius-x /path/to/catechism-pius-x.sqlite
# Portable JSON export — any host, no pdo_sqlite required here at all:
php bin/taw catechism:export /path/to/catechism-pius-x.sqlite /path/to/export.json
php bin/taw catechism:install pius-x /path/to/export.json
Unlike the Bible's five MySQL tables (one corpus, one table-set), MysqlCatechismInstaller writes into one shared table-set across every edition — each row carries both edition and the source file's own source_id. Re-running catechism:install for one edition only ever touches that edition's own rows via DELETE FROM ... WHERE edition = ?, not TRUNCATE TABLE — DML, not DDL, so it carries none of the implicit-commit caveat the Bible installer's per-table TRUNCATE has. The transaction wrapping around a catechism install is therefore a genuine cross-table atomicity guarantee: a failure partway through actually rolls back every DELETE/INSERT the run issued.
Every $wpdb->query() call is checked the same way MysqlBibleInstaller learned to check it in production — a false result throws with $wpdb->last_error, and install() returns row counts read back via COUNT(*) after the import, never the input export's own counts.
Reading an edition
TAW\Core\Corpus\Catechism\CatechismReaderInterface — implemented by CatechismReader (SQLite) and MysqlCatechismReader (MySQL fallback), the same independent-implementation choice as the Bible reader. Every method takes $edition explicitly rather than assuming a single installed catechism:
$reader = new TAW\Core\Corpus\Catechism\CatechismReader(); // or MysqlCatechismReader() — same contract
$reader->parts('pius-x'); // full part → section → chapter tree; each chapter carries its own paragraph_count
$reader->chapter('pius-x', 12); // one chapter's question/answer paragraphs + part/section breadcrumb
$reader->searchParagraphs('pius-x', 'gracia'); // full-text search across both question_text and answer_text
MysqlCatechismReader reuses MysqlBibleReader's innodb_ft_min_token_size short-word filtering from the start, rather than rediscovering the same production gap — see Bible Reader Corpus's note on that setting. TAW\Core\Rest\CatechismEndpoint resolves the reader the same SQLite-first, filterable way BibleEndpoint does, via the taw_corpus_catechism_reader filter.
See catechism under REST API for the full request/response shape of every route.
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);
Dump is for eyeballing a value during development. For anything that should be recorded — a swallowed exception, a fallback that fired, a misconfiguration a site owner needs to see — use Logger (below), not Dump::log().
Logging
TAW\Core\Log\Logger is the framework's structured log facade. It is always on — there is no enable() call — and it replaces the ad-hoc error_log('[TAW …] …') calls the framework used internally. Theme code should use it too.
use TAW\Core\Log\Logger;
Logger::error('mail.emailit_send_failed', 'Emailit send failed — falling back to wp_mail().', [
'exception' => $e::class,
'error' => $e->getMessage(),
]);
Level helpers: debug(), info(), notice(), warning(), error(), critical() (PSR-3 without alert/emergency). Logger::log($level, $code, $message, $context) is the generic form.
Anatomy of an entry
Every entry is both human- and machine-readable:
| Field | Purpose |
|---|---|
message | A readable sentence — what a person reads. |
code | A stable, dot-namespaced key (subsystem.event, e.g. form.email_delivery_failed). This is the contract an automated consumer — an AI agent, or the TAW Hub — filters and alerts on. Treat shipped codes as stable. |
context | An array of the concrete values involved — no string interpolation to parse back out. |
ts, level, request_id | ISO-8601 timestamp, severity, and a per-request id that ties multiple lines from one request together. |
Where entries go
Two sinks are active by default:
| Sink | Destination |
|---|---|
ErrorLogSink | PHP's error_log() — one line: [TAW] [ERROR] mail.emailit_send_failed: … {"context":"json"} |
JsonlFileSink | wp-content/taw-logs/taw.log.jsonl — one JSON object per line, size-rotated (~5 MB, 3 backups) |
The log directory lives in wp-content/ — not the public uploads/ — and is seeded with a deny-all .htaccess and an index.php stub on first write. Keep secrets and PII out of context regardless.
Reading it back
php bin/taw log:tail --level=error --code=form --since=2026-09-01T00:00:00+00:00 --limit=100
php bin/taw log:tail --json | jq # raw entries, for piping
TAW\Core\Log\LogReader is the underlying reader (same level / code-prefix / since filters). On a site that has joined a fleet, the taw-hub-companion plugin exposes the same data over a signed GET /wp-json/taw-hub/v1/logs route so the Hub can report on the site without shell access.
Extending
| Filter | Use |
|---|---|
taw_core_log_sinks | Add or replace sink destinations (array of LogSinkInterface). |
taw_core_log_entry | Enrich every entry before it is written — e.g. tag it with a site identifier — without taw/core knowing what is listening. |
Logger::setSinks(...) overrides the sinks outright; it is mainly for tests.