TWT AEO Ultimate — Full Changelog
=================================

= 2.22.0 =

* **New: AI Visibility records WHERE each answer named you.** `TWTAEO_Visibility_Verdict::mention_snippets()` captures the passage (~110 chars each side, whole-word matched, up to 4 deduped snippets) around every mention of the site's names and tracked brand phrases — on cited answers too, since a link and a prose mention are different exposures. Loose brand phrases whose words matched scattered fall back to their strongest word (4+ chars) so the mention still surfaces with some context. Stored per check in a new `mentions` column (visibility DB_VERSION 3; dbDelta adds the column in place). The check drawer renders "Where the answer named you" with the name `<mark>`-highlighted; a cited/named check with no prose mention says so explicitly — linked but never spoken of by name is its own signal.
* **Fixed: the store's judge_verdict() wrapper stripped every judged field but verdict/our_urls/domains**, so `owned_domains`, `citation_surface` and `brand_hits` — the 2.18 brand-tracker outputs — were silently emptied on every stored check. Brand rollups and per-brand trends computed from stored runs undercounted owned-profile citations. The wrapper now passes all judged fields through; re-run a visibility run to repopulate.

= 2.21.0 =

* New: Bot View module. Fetches any page exactly the way no-JavaScript AI crawlers (GPTBot, ClaudeBot, PerplexityBot) do and reports what they can actually see: static JSON-LD and Product-node completeness, schema that only exists after JavaScript runs (Google Tag Manager tags, theme scripts), review widgets whose stars never reach the raw HTML (with the compliant first-party fix spelled out), social embeds with and without crawlable fallback text (X/Twitter, Instagram, Facebook, TikTok), video embeds missing VideoObject schema, JS-lazy-loaded images with no real src, empty client-rendered app shells, AJAX-loaded content, external iframes, a noindex check on the bot copy, and missing meta description/og:image fallbacks. Pure PHP, no headless browser; on by default under TWT AEO → Bot View.
* New: every Bot View audit fetches the page twice — once as GPTBot, once as a normal browser — and compares the two, exposing firewalls that refuse AI crawlers outright, bot-challenge pages served with HTTP 200 that silently replace your content, and setups serving bots a drastically smaller page.
* New: Bot View lists machine-readable data hiding inside scripts — dataLayer pushes, Next.js/Nuxt hydration state, WooCommerce variation JSON, product state objects — that ships in the HTML but is ignored by crawlers, flagging safe candidates to lift into server-rendered schema.
* New: weekly site-wide Bot View scan over the homepage and recently updated posts, pages and products (WooCommerce/EDD transactional pages excluded), with a regression flag when a page gets worse since the previous scan and a "Run site scan now" button.
* Fixed: AI Crawler Watch no longer counts Bot View self-audits as real crawler visits.

= 2.20.1 =

* **Fixed: heading-based FAQ detection published non-questions as FAQ schema.** Any h3/h4 followed by a paragraph counted as a Q&A pair, so a feature-card grid became a FAQPage full of fake questions — "Suppresses duplicates", "🕸️ Connected Product Graph", section labels — misinforming every answer engine that read it. A new `looks_like_question()` gate now requires a heading to actually read as a question (ends with "?", question-length) before any heading strategy counts or extracts it; detection and extraction use the same gate so a page never "detects" FAQ content that generation would refuse. Structured sources — Rank Math/Yoast FAQ blocks, `Q1:`/`A1:` patterns — are explicit declarations and unchanged. Already-generated polluted FAQPage schema is stored per post: delete it and regenerate. A heading like "How It Works" that merely starts with a question word is a title, not a question, and now stays out — the honest cost of the fix.
* **Fixed: SASWP per-post custom schema now suppresses this plugin's Article/NewsArticle on that post.** SASWP's custom markup field prints straight into the page and is invisible to the homepage-only conflict scan, so a post carrying a SASWP Article got a second Article from this plugin — two entities for one URL. `TWTAEO_SEO_Compatibility::saswp_post_has_type()` reads the SASWP field per post (cached per request) and both writers defer when it already declares the type (Article, BlogPosting, or NewsArticle).
* **Fixed: the Schema Detector header's Rescan button was nearly invisible.** It used WordPress's stock grey `.button` class on the plugin's dark (#1a1a2e) header bar. It now uses the plugin's own `.twt-aeo-btn--primary` accent style, matching the E-E-A-T page's header Rescan button.
* **Fixed: saved custom schema of a non-recommended type was invisible and undeletable in the Dashboard.** The Schema Present column only rendered detected-present types and the Missing column only intent-recommended ones, so a stored schema outside both sets (e.g. a FAQPage on a Product-intent page) kept publishing with no UI that could reach it — the exact trap behind the FAQ-pollution cleanup. The Present column now also lists every saved schema type with its ✏ edit button (which carries the Delete action), marked with the saved style and a title explaining it publishes but isn't among the page's recommended types.
* **New: duplicate meta description detection on the Not Indexed screen, with an inline AI fix.** `TWTAEO_Index_Heuristics` builds a once-per-request map of every published post/page/product's effective meta description (one query over the plugin's own `_twtaeo_meta_description` plus Yoast/Rank Math/AIOSEO/SEOPress keys, resolved in the same priority order the per-post check uses; compared whitespace-collapsed and case-insensitive). A page whose description is shared verbatim with others gets a "Duplicate Meta Description" flag naming up to three of them, with `fix => 'meta_desc'`: the Not Indexed page's fix modal gained a third editor that shows the shared description and one button — reusing the existing `twtaeo_ai_desc_generate` endpoint, which writes and saves a description from that page's own content. No new menu item; identical descriptions read as templated duplicate content and correlate with "Discovered — currently not indexed".
* **Changed: dynamic robots.txt is now shared-cacheable for 5 minutes (was force-no-cache).** The no-cache design made every robots.txt fetch depend on origin health so the AI-crawler logger saw each hit — but Google treats a failed robots.txt fetch as "Robots.txt unreachable" and aborts the whole crawl, rejecting URL-inspection indexing requests site-wide (observed live on tampawebtech.com 2026-08-26). Being unindexed is strictly worse than a logging gap, and search engines cache robots.txt up to 24h anyway. `TWTAEO_AI_Ready::nocache_robots()` now sends `Cache-Control: public, max-age/s-maxage` (default 300s, `twtaeo_robots_cache_ttl` filter; 0 restores nocache_headers()). llms.txt and the other discovery endpoints are unchanged — their per-hit logging is real signal and they gate nothing.
* **Fixed: Index Status flagged EDD's transactional pages as not-indexed problems.** `TWTAEO_Index_Status::is_excluded_page()` only knew WooCommerce's cart/checkout/myaccount page IDs, so an Easy Digital Downloads checkout or receipt page (e.g. /checkout-2/receipt/) burned GSC inspection quota and appeared under "Discovered — not indexed" even though transactional pages are noindex by design. The exclusion now also covers EDD's purchase, success/receipt, failure, and purchase-history pages, plus any child of an excluded page (a receipt nested under checkout is part of the same flow).
* **Fixed: E-E-A-T "Article datePublished / dateModified" signal ignored this plugin's own Article schema.** `check_article_dates()` predated the automatic Article writer and only returned true when Rank Math or Yoast was active — so a site running this plugin alone lost 10 points and was told to install a competitor, even though `TWTAEO_Article_Schema_Writer` has emitted both dates on every post since 2.4. The check now passes when the plugin's own writer is on (`TWTAEO_Output_Control::should_write( 'schema_article' )` — which also covers the defer-to-SEO-plugin case) and recognizes AIOSEO/SEOPress alongside RM/Yoast; it still requires at least one published post.
* **Fixed: stored JSON corrupted non-ASCII characters (em-dashes, accents, emoji stored as literal "u2014"-style text).** `update_post_meta()`/`update_user_meta()` run `wp_unslash()` on their value, which eats the backslashes in the `\uXXXX` escapes `wp_json_encode()` produces — so every save mangled every non-ASCII character in custom schemas, product FAQs, author social links, and certifications. All six write sites now go through `TWTAEO_Custom_Schema_Writer::encode_for_meta()`, which slashes the payload and encodes with `JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES` so escapes barely occur at all. `ajax_save_custom_schema` also stops passing the JSON payload through `sanitize_textarea_field()` (which stripped HTML-looking content out of answer text); validation is `json_decode()` rejection plus re-encoding in `save()` — the raw string is never stored or output. Verified by WP-CLI round-trip test: em-dash, café, emoji, embedded HTML and quotes all survive save → read → delete-resave.

= 2.20.0 =

**Headlines: trust signals machines can read — declare how each post was made (digitalSourceType), author archives become real ProfilePages, authors get an AI-drafted authority statement that spells out what their credentials mean, and translated sites stop claiming the wrong language in their schema.**

* **New: content provenance — digitalSourceType on Article/NewsArticle.** A "Content Provenance" box in the editor sidebar lets an author declare how a post was produced: human-created (the default), human with AI assistance, AI-generated with human review, or automated. Non-default declarations publish as `digitalSourceType` using the schema.org/IPTC vocabulary (`CompositeWithTrainedAlgorithmicMediaDigitalSource`, `TrainedAlgorithmicMediaDigitalSource`, `AlgorithmicMediaDigitalSource`) — the vocabulary Google documented for forum content in March 2026, valid on any CreativeWork per schema.org. Human-created deliberately publishes NOTHING: absence is the documented human default, so an explicit value would add bytes without information — and it means every existing post is already correctly labelled. No other WordPress plugin emits this. The honest-labelling logic cuts both ways: declaring the AI-assisted pages is what makes the unlabelled human pages a credible signal. (`includes/class-content-provenance.php`)
* **New: ProfilePage on author archives.** The author archive previously published a bare Person; it now publishes the profile-page shape Google documents — `ProfilePage` with `mainEntity` → the Person (carrying the grounded credentials from 2.19.0), `dateCreated` from the account registration date and `dateModified` from the author's latest content change. In Knowledge Graph mode the ProfilePage joins the unified @graph as the page node (the graph builds no WebPage on author archives, so nothing collides); standalone mode wraps the Person directly. Brings parity with the big SEO suites' author archives — except this Person node grounds its credentials, which theirs do not.
* **New: multilingual schema awareness (WPML + Polylang).** Until now every schema node on a translated site claimed the default site language — `inLanguage` was read twice, both from `get_bloginfo()`, so the French page told every consumer it was the English site: the exact brand-flattening failure the international E-E-A-T work targets. A new `TWTAEO_Multilingual` helper (function_exists-guarded, no hard dependency) resolves each post's real locale from Polylang (`pll_get_post_language`) or WPML (`wpml_post_language_details`), and the WebPage node plus Article/NewsArticle now carry it. Articles with published translations additionally publish `workTranslation` stubs — `@id` `<translated-url>#article`, matching the id convention the translation's own page uses, so the two pages reference the same entity from both directions. Single-language sites resolve to the site locale and emit no workTranslation: behaviour unchanged. (`includes/class-multilingual.php`)
* **New: contextual authority statement — the prose half of machine-recognizable credentials.** 2.19.0 grounded each credential's issuing institution in the schema (`credentialCategory`, `recognizedBy.url`, `recognizedBy.sameAs`); this adds the same relationship in plain text, because models read page prose as well as JSON-LD and the relationship should exist in both. A new field on the author profile holds a 2–3 sentence statement ("Jane Doe is a First-Class Registered Architect certified by the Ministry of Land, Infrastructure, Transport and Tourism, the government body that licenses architects in Japan"), drafted with one click from the saved certifications via the shared AI client (`TWTAEO_AI_Client`, the configured enrichment provider, one call) — the draft lands in the textarea for review and is never stored until the profile is saved. When present it is appended to the public author bio wherever the theme renders it (`get_the_author_description` filter, per-author toggle to opt out) and to the Person schema `description`. Generation refuses politely when there are no certifications to write from, and the prompt forbids inventing credentials, employers, or superlatives — an unrecognized institution is described only by the facts given.

= 2.19.0 =

**Headlines: the board stops only measuring and starts telling you what to do — the questions you are losing and who is winning them, your citations checked against real traffic, a line per brand over time, and one number that no longer contradicts itself. Plus the mandatory move to Google's Merchant API, and author credentials AI systems can actually recognize.**

* **New: author certifications are now machine-recognizable credentials.** An LLM trained mostly on English content has no basis for reading "一級建築士" or "Architekt BDA" as evidence of expertise — the credential is real, but the model never learned what the institution behind it represents. Each certification on the author profile can now say what kind of credential it is (professional license, degree, certification, membership, accreditation — output as `credentialCategory`, so a government licence no longer ships mislabelled as a "Professional Certification"), and ground its issuing organization with its official website and Wikipedia/Wikidata links — output as `recognizedBy.url` and `recognizedBy.sameAs`, connecting the issuer to an entity the model already knows. The profile screen also shows one open certification row by default, so the fields are discoverable without clicking "Add Certification" first; a row left without a name is simply not saved.

* **Fixed: a remotely-driven site scan skipped the newest 200 pages.** `TWTAEO_Background_Scan::start()` deliberately skips the head of the list, because the Dashboard scans those in the browser while the page is open and queueing them twice would scan them twice. That assumption only holds when there IS a browser on the site — an Agency Connector driving a scan remotely has none, so the newest 200 pages, usually the ones that matter most, were never scanned while the site reported the scan complete. `start()` now takes an offset; the Dashboard's behaviour is unchanged and remote callers pass 0.
* **Critical: Google Merchant Center now speaks the Merchant API.** Google shut the Content API for Shopping down on **18 August 2026**, and every Merchant Center call this plugin made went to it — so on any connected store the product feed comparison, the integrity score, rejection reporting and the sync queue have been failing since that date. The reader now calls Merchant API and translates its shapes back to the flat product the rest of the plugin was written against, so nothing downstream changed. Three of those shape changes are silent failures rather than errors and were each handled explicitly: **GTINs became a list** (`gtin` → `gtins`), and reading the old key would have reported an entire catalogue as missing its barcodes; **prices arrive in millionths** (`amountMicros`), so a $15.00 product compared against `15000000` would have marked every product a price mismatch; and **`servability: "disapproved"` became `severity: "DISAPPROVED"`**, so rejection codes would have vanished from the screen while Google carried on refusing the products. Your existing connection still works — the OAuth scope is unchanged, so no store has to re-authorise. **One new step is required by Google, not by us:** your Google Cloud project must be registered against your Merchant Center account (Settings → register the Cloud project the OAuth credentials came from; one project per account). Google answers a bare 403 when it is missing, so the plugin now names that as the cause instead of showing the raw error.
* **Fixed: pushing a promotion to Merchant Center was a fatal error.** `TWTAEO_Promotions_Feed` called `TWTAEO_Google_Merchant_Center::create_promotion()`, which did not exist — so submitting selected coupons crashed the request rather than sending anything. The method now exists and speaks Merchant API, which additionally requires a promotions data source: the plugin **finds** yours and refuses with an explanation if there is none, rather than creating one, because that would mean changing your Google account without being asked.
* **Improved: a Merchant Center sync no longer reads the whole catalogue twice.** Merchant API reports each product's status on the product itself, so the products pass and the statuses pass are one listing; the full-catalogue read is now shared between them instead of paged a second time.
* **Fixed: the same engine reported two different “cited %” on one screen.** The engine card divided by ANSWERED checks; the share-of-voice donut divided by ALL of them, counting a refused or errored call as a miss. On a live run Grok read 57% on its card and 36% on its donut — same engine, same run, same page. Both were defensible and the disagreement was not, so the donut now divides by answered checks like the card always did, which is the correct reading: an errored call is not evidence that nobody cited you. Where an engine had unavailable checks the caption now says so outright (“7 of 11 answered · 4 unavailable, not counted against you”) instead of leaving you to wonder which denominator you are looking at.
* **New: “Questions you are not answering” — every absent verdict becomes a brief.** Until now the board told you that you were absent and left it there. Each absent question now lists which engines skipped you, how many answered at all, and — the part that matters — **which domains were cited instead, linked**. Questions where a rival was cited sort to the top, because those are proven opportunities: the answer exists, it is simply not yours. Questions where nothing at all was cited are labelled as exactly that and kept separate, since an assistant answering from memory is a much weaker signal and dressing the two up the same way would be dishonest. Costs nothing extra — the question, the engines and the rival URLs were already recorded on every check.
* **New: “Cited here, visited from there” — the probe layer checked against reality.** The citation engine and the analytics panel have never spoken to each other, which left the module's central claim untested. Each engine now shows its cited % beside the sessions your analytics attributed to that assistant over 28 days, with a plain-language reading: cited and people arrive; cited but nobody clicks yet; or **people arrive anyway, for questions your set never asks** — which is a content gap you could not previously see. Presented as directional, never as cause and effect: the two columns measure different things over different windows, and the page says so.
* **New: a line per brand over time.** Now that brands are tracked in their own right, each one gets its own sparkline of cited % across runs, with the change since it was first tracked. A brand only appears from the run it entered — earlier runs genuinely hold no data for it, and back-filling a zero would invent a decline that never happened. One run shows a reading, not a trend, and says so.
* **New: the AEO Score is reported to the Agency Hub daily, when one is connected.** Sites paired with a TWT Agency dashboard now send their site score, how it was earned by category, and their lowest-scoring pages with the line items each one failed — so an agency can see which client slipped this month and exactly what to fix, without opening each site. The score travels as the number this plugin computed, with the rubric version alongside it, rather than as raw signals for the hub to re-score: the rubric lives here and changes here, so a second implementation would drift. Nothing changes on a site with no hub connected. Requires TWT Agency 1.32.0+.
* **New: a finished run is pushed to the Agency Hub, when one is connected.** Sites paired with a TWT Agency dashboard now send each completed run to it — the summary, every check with its verdict and the domains that answer cited, and what this site counts as itself (its hosts, its owned profiles, its tracked brands) so the hub reads a citation the same way the board does. It is the run's own evidence, not a re-probe: the hub sees every engine you hold a key for and the full cited/named/absent/unavailable vocabulary, instead of re-asking three engines on the agency's card and recording a yes or no. Nothing changes on a site with no hub connected, sample data is never transmitted, and the send happens after the run finishes rather than during it, so the board never waits on the network. Requires TWT Agency 1.29.0+.
* **Fixed: brand questions had no “fix” link.** Every other question type routed to the screen that resolves it; brand questions showed a dash, because the routing table was never taught the new level. Content-shaped brand questions (reviews, alternatives, pricing) now point at FAQ content, and the rest at “What counts as you”, where registering what the brand owns is what changes the answer.

= 2.18.0 =

**Headlines: brand citations — track any brand by name, and count the profiles you own as citations instead of as competitors. Plus brand questions, an honest “not in this run” on every empty scope, cleaner Search Console queries, and press release features that work without your own API key on WordPress 7.0+.**

* **Fixed: the /okf/ product pages and /llms.txt could fatal on hosts without PHP’s mbstring extension.** `TWTAEO_OKF::build_product()` called `mb_strtoupper()` and the llms.txt excerpt builder called `mb_strrpos()`, neither guarded. The trap is that WordPress polyfills `mb_substr()` and `mb_strlen()` in `wp-includes/compat.php` but not those two — so on the same line, the `mb_substr()` calls were safe and the `mb_strtoupper()` was a fatal error, taking out the entire `/okf/products/<slug>.md` endpoint for any product with attributes. Both now sit behind `function_exists()` with UTF-8-correct fallbacks (the llms.txt one converts a byte offset to a character offset, since a space can never be a UTF-8 continuation byte but the caller counts characters). Verified by running the render suite with mbstring deliberately unloaded.
* **New: the press release features work on WordPress 7.0+ without an API key of your own.** PR detection and the AP-style transformer called Anthropic directly and stopped at “Claude API key is not configured”, so on a site without a key they simply did nothing. Both now go through the plugin’s shared AI client, which prefers WordPress 7.0’s built-in AI Client (`wp_ai_client_prompt()`) when no key is present — the same pattern AI meta descriptions already used. Below WordPress 7.0 that function does not exist, so the plugin’s own keyed path remains and is unchanged. **If you have a key configured, nothing about the call changes**: the model pins (`claude-sonnet-4-6` for the transformer, `claude-haiku-4-5` for detection), the token budgets and the timeouts are all passed through exactly as before. The shared client gained a per-call timeout for this, so detection keeps its deliberately short 15-second budget — it is a five-token yes/no running inside a page request and must not hang it — rather than inheriting the 60-second default.
* **Fixed: picking a scope the run never asked about gave a blank board with no explanation.** The scope dropdown lists every category, brand and post category you have, but a run only covers as many as its allocation reached — on a site with 22 categories and `Per category` at 5, sixteen of the options silently produced no charts, which reads as broken rather than as “never asked”. Those options are now labelled “— not in this run”, and choosing one says so in words: nothing was asked in that scope, which is not the same as not being cited there, with the allocation setting to raise named. Nothing is hidden — every scope you own is still listed.
* **Improved: Search Console top queries no longer fill up with rank-tracker noise.** Search Console reports SERP-scraper traffic as ordinary queries — long operator chains like `"aioseo" -site:reddit.com -site:twitter.com …`, almost always one impression and no clicks — and ten of those filled the panel and pushed every real query out of sight. Queries carrying search operators (`site:`, `inurl:`, `intitle:`, boolean soup, multiple negated terms) are machine queries, not questions anybody asked, so they are dropped from the LIST; their impressions still count in the totals, because they did happen and understating the account would be its own distortion. The panel now reads a hundred rows to find ten worth showing, and sorts question-shaped queries first — who/what/how/best/vs/alternatives/price — because those are the ones an assistant answers and the only ones this module can act on.
* **New: brand questions — each tracked brand is asked about by name.** A brand item that only ever gets evaluated against questions about something else is passive: if no answer happens to mention it, the board reads “absent” when the truth is that nothing ever asked. Brands are now a question level of their own, alongside Company and Post, with their own allocation slider (“Per brand”) and their own families — what it is, who makes it, is it any good, what are the alternatives, what do reviews say, what does it cost, where do I get it, is it legitimate, who is it for, how does it compare. Question ids are derived from the brand name and stay stable, so trends remain comparable run to run, and each brand is its own scope in the results filter. Registering no brands costs nothing: the level contributes zero questions and the allocation arithmetic shows it.
* **Brand items accept a whole website, not only profiles.** A brand routinely owns its own domain (aeoultimate.com) as well as pages on other platforms, so a bare domain is now taken at face value and every page under it counts. The guard that matters is kept exactly where it belongs: on shared platforms — LinkedIn, X, YouTube, GitHub, wp.org and the rest — a bare host is still refused, because `linkedin.com` on its own would book every LinkedIn citation on the internet as yours. The board also distinguishes the two: a citation of a site you own reads as your own site, a citation of your page on somebody else's platform reads as a profile, and an answer that does both says so.
* **New: the brand citation tracker — company items and brand items (AI Visibility → “What counts as you”).** The verdict asked one question: is the cited URL on our domain? So an answer that linked your own LinkedIn company page scored as *named* at best — and share of voice, which files every non-“ours” domain under competitors, put your own profile in your competitor donut. Not cited and cited-somewhere-else-you-own are not the same thing. You can now register both: **company items** (your hosts, plus the social profiles already on your Company profile — which imports Yoast and Rank Math, so most sites arrive pre-filled, and the X handle you may already have typed) and **brand items** (a product or sub-brand tracked in its own right — a phrase to watch for and, if it has any, the profiles that brand owns). A citation landing on any of them now counts as a citation, and the board reports the surface alongside it — own site, owned profile, or both — with a “where your citations landed” split under the results and a per-brand cited/named table. The split is the point: a business cited only ever through LinkedIn has a different job in front of it from one whose own pages are being quoted, and widening the rule must not paper that over. Phrases follow the convention you already expect — in quotes is that exact phrase, without quotes is every word of three letters or more in any order (so “the Ultimate AEO plugin” still counts), whole words either way, so “Ace” never matches “Aceto”. A channel a brand does not have is left blank and simply not tracked — never reported as a zero. Matching is path-scoped and deliberately strict in the other direction too: `linkedin.com/company/your-brand` is you, `linkedin.com/company/anyone-else` is not, deeper pages of your own profile still count, and the assistants’ own tracking parameters are ignored. A bare domain is refused with the reason shown rather than saved — claiming all of `linkedin.com` would inflate your score exactly as dishonestly as the old rule deflated it; a whole host you really do own belongs in Alternate hosts. Anything that cannot be stored is listed back to you instead of disappearing, because a URL that silently fails to save leaves you believing a property is tracked while its citations keep counting for the competition.

= 2.17.0 =

**Headlines: AI Visibility citation engine (probe ChatGPT, Gemini, Claude, Perplexity, Grok and Le Chat on your own keys; verdicts cited/named/absent; accuracy against your real prices, stock and policies; competitor share of voice; sample data; runs finish in the background) + Open Knowledge Format bundle at /okf/, on by default, linked from llms.txt.**

* **New: AI Visibility — the citation engine (TWT AEO → AI Visibility).** Asks the assistants shoppers actually use — ChatGPT, Gemini, Claude, Perplexity, Grok and Le Chat (Mistral), each on its consumer app's default model with web search, on your own API keys — the questions shoppers ask about your company, your categories, your product types, your products and your posts. Every check records a verdict per engine: cited (the answer linked to your domain), named (mentioned without a link), or absent — and an errored/refused call is recorded as unavailable, never counted against you. Where the plugin already knows the answer (your policies, where you ship, your variants, prices and stock) an accuracy judge marks what the engine said as correct, wrong or not stated — wrong answers sort to the top of the question table with a link to the exact screen that fixes them. Share-of-voice donuts show who was cited instead, per engine, for any scope you pick. You control the spend: an allocation with live arithmetic ("X questions × Y engines = Z checks") and presets, an editable question list where nothing is asked that you have not seen, a daily cap the run pauses at rather than failing, and one check per request — close the tab and a background worker finishes the run from where it stopped. An observed layer up top shows GA4 sessions referred by AI assistants, Search Console top queries, and Bing Webmaster stats when those are connected. "Load sample data" fabricates a labelled run from your real catalogue so you can see the whole board before a single API call is billed — it never enters a trend and is removed with one click.
* **New: Open Knowledge Format (OKF) bundle at /okf/.** Your site as a machine-readable knowledge directory per Google's OKF v0.2: index.md manifest, organization.md, and a markdown concept file with YAML frontmatter for every product, product category, post and policy page — prices, availability, attributes, FAQs and policy text included, cached and invalidated on content changes. On by default, linked from llms.txt, nothing to configure.
* **New: Grok (xAI) and Le Chat (Mistral) join the citation engine, with key fields in Settings.** Both engines were built in the port but unreachable — the key resolver had no `xai`/`mistral` slug, so they could only ever render as "coming soon". Settings now carries a Grok (xAI) and a Mistral key row (same three-tier resolution as every other key: PHP constant, then environment variable, then encrypted database option — a key defined in wp-config.php is never written to the database), and both engines are selectable on the board. An engine you hold no key for still shows honestly as "No key" rather than disappearing. Note the model pins mirror each consumer app's default: Grok probes `grok-4.6`, verified live against a real xAI key.
* **New: runs no longer need the tab open.** Starting a run also queues a WordPress cron worker that advances the queue in ~20-second batches and re-queues itself until the run finishes, so closing the page no longer stalls a 500-check run at whatever the browser managed. While the board is open it still drives the run itself (a page steps far faster than cron), and a transient lock plus a page heartbeat guarantee the two never execute the same check twice — a check is recorded once or not at all. The worker obeys the same daily cap, and pauses the run with the reason if an engine's key disappears mid-run instead of retrying forever. Because it rides WP-Cron, background progress depends on the site receiving traffic; the page says so rather than implying a guaranteed clock.
* **New: "Polish awkward questions with AI" on the question list.** Post questions are built by dropping a post title into a template, which reads badly when the title is already a sentence ("What is What to Put on Your Website to Get Found?"). This sends those questions — with each post's real title — to your configured enrichment provider (the cheap tier behind meta descriptions, not a web-search call) and keeps the natural rewrite. Question ids never change, so trends stay comparable across runs; rewrites are marked `source: ai` so you can see which ones a model touched; ticks are preserved; and a rewrite that comes back empty, over-long or malformed is discarded so the template text survives. "Rebuild from templates" now carries polished and hand-edited text forward by id, the way it already carried your unticks.
* **Fixed: a new default-on module stayed invisible on every upgraded site.** Module defaults were seeded only when the active-module option did not exist — i.e. on first activation ever — so any module added in a later release was registered but never enabled on an existing install. AI Visibility shipped default-on and was therefore missing from the menu on every site upgrading from an earlier version, discoverable only by someone who thought to check the Modules screen. The loader now keeps a ledger of every module slug an install has seen: a registered module missing from the ledger is genuinely new and gets its default, while a module you deliberately switched off is already in the ledger and stays off. Runs once per new module, writes nothing thereafter.
* **Fixed: the engine list said "add it in Settings" twice** ("No Perplexity key — add it in Settings. — add it in Settings"), because the reason string repeated the call to action the board appends as a link.

= 2.16.0 =

**Headlines: a new AEO Score grading system with a full line-by-line breakdown of every page's score — every point named, every gap linked to its fix — plus one-run bulk schema autofill and AI-written summaries in the schema editor.**

* **New: the AEO Score — a citability score that shows the receipts.** Every plugin invents a score; almost none explain it. Ours is the sum of visible line items, period. Each scanned page gets 0–100 computed at scan time from every surface the plugin already measures — core schema for the page's classified intent (40), recommended answer surfaces like FAQ/Review/Breadcrumb (15), trust & authorship (company profile always, author profile on authored content — 15), Open Graph title/description/image (10 — yes, OG counts: engines assembling answers read it), discoverability (noindex/canonical — 10), and content signals (meta description, headings, depth, keyword density — 10). Scoring is intent-aware: a blog post is never docked for missing Product schema; each intent has its own maximum and scores normalize to 100 so pages stay comparable. The Dashboard table gains a Score column (colored ring); clicking it opens the line-by-line breakdown — every item shows its points, one sentence on why an answer engine cares, and a link to the exact screen that fixes it. Above the table, a site-wide panel shows the overall score (declared openly as 70% average page score + 30% site setup), a per-category "where points are lost" bar chart, the site setup checklist (company profile, all authors' profiles — one filled-in author never covers the rest — IndexNow, sitemap, schema modules, Merchant Center sync on WooCommerce stores), and a score-over-time line with one snapshot stored per day.
* **New: one-click "Auto-Fill Missing Schema" on the Dashboard.** Walks every scanned page and creates every missing schema that can be built from data the site already has: WebPage/WebSite/BreadcrumbList from titles and URLs, Organization from the company profile, Person and Article from author profiles and post dates, FAQPage from detected Q&A content (same extractor Autopilot uses), Service via the service writer's prefill, LocalBusiness/PostalAddress from Local Pack business info, and full Product schema for WooCommerce products via the product generator. Runs in batches with progress; changed pages are rescanned so their missing lists and AEO Scores update immediately. **Types needing facts only the owner knows are never guessed** — event dates, review verdicts, prices outside WooCommerce, an address that is not on file — they are counted and reported by name ("Left for you: …") and keep their + buttons. Existing saved schemas are never overwritten. Guessed data is worse than a named gap.
* **Improved: the Dashboard schema modal closes on save** instead of staying open behind a success message — the updated row tag (✓) is the confirmation.
* **New: "Generate with AI" on the schema modal's description fields.** Every description textarea in the create/edit modal gets a cost-labelled link ("1 API call") that writes a summary from the page's own content using the configured AI provider — the same engine as AI Meta Descriptions. Shown only when a provider and key are configured; always user-initiated, never bulk.
* **Fixed: the AEO Score breakdown's fix links for schema types pointed back at the dashboard itself** — a dead self-link ("use the + button on this page" that navigated to the page you were on). Schema-type fixes are now a "Create it now" action that closes the breakdown and opens that page's create modal directly; FAQPage links to the Schema Detector's FAQ tab (which generates from detected Q&A content) and Service to its Service tab. Breakdowns stored before this change show plain text instead of the dead link until their page is next scanned.
* **Fixed: the Scan All progress bar presented the foreground block as the whole site, and background progress was invisible.** The bar counted "X / 200" on any size site (200 being the foreground cap, not the total), and the background-scan progress display — polling, resume-on-page-load, the live "Background scan… X / Y remaining" label — had been written into `admin/assets/js/page-dashboard.js`, a file that is never enqueued, so after the foreground pass the rest of the site scanned with no feedback at all. The bar now speaks in site totals from the first tick ("Scanning… 40 / 949 — newest 200 first, then 749 in the background"), the handoff flows straight into live background polling instead of a blind reload, and revisiting the dashboard mid-background-scan resumes the progress display. Schema detection checked Rank Math, Yoast, SASWP, then fell back to HTTP/wp_head sniffing — but never the plugin's own stored schemas, and the sniffing fallbacks don't work inside the AJAX and CLI contexts scans actually run in. Result: a page whose schema THIS plugin created (via the modal, autofill, or the product generator) could stay listed as "missing" that same schema forever, its AEO Score never improving. Detection now counts the plugin's own stores first (custom schemas + the Service writer). Saved types therefore move to the Present column and keep an ✏ edit button there — except WooCommerce products' generator-owned Product/ProductGroup nodes, which are richer than the modal form and would be clobbered by hand-editing, so their edit path stays on the WooCommerce tab.
* **Fixed: non-WooCommerce product pages could never get Product schema from the Dashboard.** A page whose content reads as a product page (pricing, buy, in stock — the intent classifier is right: it IS a product page) got a "Product" missing tag, but clicking + always routed to the WooCommerce one-click generator, which correctly refuses anything that is not a `product` post — so on a site without WooCommerce, or for a sales page outside the catalogue, Product schema was unreachable. The one-click generator now runs only for real WooCommerce products (unchanged there); every other post type falls through to the manual Product form (name, brand, price, currency, availability) that was already in the modal. A product page with no Product node is a straight citation loss.
* **New: Review and HowTo forms in the schema modal.** Both types appear in the intent map's recommended lists (and in the AEO Score breakdown), but neither had a form — surfacing them anywhere was a dead end. Review captures what's reviewed, a rating out of 5, the reviewer, verdict and date; HowTo takes a name, description, and steps typed one per line, emitted as positioned HowToStep nodes.
* **Fixed: the homepage's WebSite tag and location pages' PostalAddress tag were dead ends.** Both types are in the intent map's expected lists, so their missing tags rendered a + button — but neither had a form, and clicking alerted "No form defined for schema type: WebSite/PostalAddress". Both forms now exist (WebSite prefilled from the site name/tagline/URL; PostalAddress street/city/region/postal/country), with matching JSON templates in the schema writer. The homepage case hit every site whose front page lacks WebSite schema.
* **Fixed: Search Console domain properties could not be connected.** The Command Center's Site URL field was `type="url"`, so the browser rejected `sc-domain:example.com` — the only property name Google accepts for a DNS-verified domain property. Entering the https:// URL instead returns Google's "User does not have sufficient permission for site" even when the account owns the domain property. The field now accepts both forms and the help text shows each format with the site's own values filled in.

= 2.15.0 =

* **Fixed: every scanning screen silently stopped at the 200 most recent items.** The detectors cap each scan at 200 posts because scanning a whole large catalogue in one page load times out — but the screens presented that first block as the whole story, so on a 1,000-product store 800 products were simply invisible. Every affected screen now pages through the entire catalogue:
  * **WooCommerce Products tab** — pages 200 products at a time (`scan_all()` takes a page number), with a windowed pager, a "Products X–Y of Z (newest first)" header, and a Scan Again button with a rescan-complete notice.
  * **Social Graph** — the existing 25-row pager now spans the whole catalogue; deeper pages load deeper 200-item scan blocks (`TWTAEO_OG_Detector::SCAN_BLOCK`) instead of re-slicing the first one.
  * **Schema Detector FAQ / Service / Contact tabs** — these detectors *filter* what they scan (a block of 200 posts may yield three FAQ pages), so a new shared `TWTAEO_Scan_Pager` pages over scan blocks and labels each page with "Scanned items X–Y of Z", never presenting a filtered block as the site total.
  * All paged queries order by date then ID, both descending, so pagination is deterministic.
* **New: the commerce tab bar never hides a feature.** All five tabs (Products, Google Merchant Center, Bing Merchant Center, Smart Collections, Promotions) always render on the WooCommerce screen; a tab whose module is off shows dimmed with an "Off" chip and routes to the Modules page. Previously an off module's tab did not exist at all — and a merchant who never learns Promotions exists never publishes the data. Less data = fewer citations.
* **Fixed: the AEO header banner disappeared on the Smart Collections and Promotions tabs.** Both are separate admin pages that never rendered the shared header; they now carry the same "AEO WooCommerce" banner and page style as the rest of the tabbed screen.
* **New: Image SEO's "Add an image" opens the native WordPress media modal.** For a page with no images, the button now opens the media library in place and sets the chosen image as the featured image (the image og:image and schema fall back to), then swaps the row to the normal Generate/Edit actions. New `twtaeo_img_set_featured` AJAX endpoint; the button previously just linked to the editor.
* **Improved: bolder pill tabs and livelier cards.** The active tab is a filled blue gradient pill with white text (the Conflicts tab gets the matching red), hover states lift, icons are larger, and summary cards elevate on hover.
* **Changed: suppressing WooCommerce core's Product schema now requires the merchant's consent.** `dedupe_wc_product_schema()` used to blank WooCommerce's built-in Product block unconditionally whenever the detector module was on. Both dedupe filters are now gated on a new `twtaeo_wc_schema_takeover` option (`'aeo'` suppresses, anything else passes through untouched), and the Products tab carries a card that explains the conflict — classic themes publish WooCommerce's block on every product page, some block themes don't — with one-click "Make AEO the only Product schema (recommended)" and a "Restore WooCommerce's built-in block" path back. Existing installs updating to 2.15.0 revert to publish-both until the merchant chooses.
* **New: Social Graph summary row reworked.** A fifth card counts pages missing an og:title and/or og:description (the text half of the gap the image card never covered), the grid gains a compact variant so five cards fit one row, and Rescan All moved above the cards where the stale-number question actually arises. The Twitter Cards card stopped under-counting: the writer emits a card on every singular page once the site handle path is active, so the card now shows live coverage (with the customised count alongside) and, when Rank Math or Yoast manages Twitter Cards, says so instead of showing a dishonest zero.
* **New: catalogue-wide search on the big tables.** The WooCommerce Products tab and the Social Graph coverage table each carry a search box that queries the WHOLE catalogue server-side — title, content, and SKU (the `posts_search` SKU filter is ported from AEO Ultimate for WooCommerce, along with its reasoning: filtering the rows already on screen would look instant and find nothing, because the product being hunted is almost never on the page you are looking at). `scan_all()` on both detectors takes an optional search term; matches paginate through the same block machinery, `last_found()` supplies the pager total, and a page number that falls off the end of a narrowed result set lands on the last real page instead of an empty one.
* **Improved: pagers render above AND below every paginated table** (Products tab, Social Graph, and the Schema Detector's FAQ/Service/Contact tabs), so changing pages never requires scrolling past 200 rows.
* **New: the Social Graph cards read from a site-wide census.** A single 200-item block's counts on a 955-page site produced "200 scanned, 200 missing image, 200 Twitter cards" — the block size three times, read (correctly) as nonsense. Rescan All now drives a batched walk over every scan block (`twtaeo_og_census_block` AJAX, one block per request so no store size can time it out), counts accumulate server-side into the `twtaeo_og_census` option, and the cards render those site-wide totals with a caption naming when the census ran. Until the first census, the cards carry an explicit "newest block only — run Rescan All" caption instead of passing a block off as the site.
* **Improved: one image-fallback chain everywhere.** New `TWTAEO_OG_Writer::effective_image()` (saved og:image → featured image → first WooCommerce gallery image) now backs the OG output, the Twitter Card summary/large-image downgrade decision, and the Social Graph table — three surfaces that could previously disagree about whether a page "has an image". The Twitter Card column shows the effective default ("large image · default") rather than "auto", since the writer applies a card to every page once active; per-page overrides still win, and when Rank Math or Yoast manages cards the cell says so.
* **Fixed: products with only gallery images read as "missing og:image".** Both `TWTAEO_OG_Writer::output_meta()` and `TWTAEO_OG_Detector::scan()` fell back to the featured image alone; a product whose images all live in the WooCommerce gallery published no og:image and was counted missing. Both now fall back to the first gallery image, in the same order, so the scan keeps reporting exactly what the writer publishes. The duplicate Rescan All button above the coverage table was removed (the one above the summary cards stays). Note the scan runs live on every page load — Rescan is a labelled refresh, and unchanged numbers mean unchanged content, scoped to the 200-item block on screen.
* **New: the FAQ detector scans products.** `TWTAEO_FAQ_Detector::post_types()` includes `product` when WooCommerce is active, so the Schema Detector's FAQ tab covers product pages — sizing questions and care instructions are FAQ content an answer engine can quote. A product whose pairs are already published through `TWTAEO_Product_FAQ` (our assembler at `#product-faq`, or AEO Ultimate for WooCommerce when it claims the surface) reports schema present; pairs that exist while no commerce writer is active do NOT count as present, because nothing reaches the page.
* **Fixed: the Social Graph bulk description tools never touched products.** `get_posts_missing_og()` / `get_posts_missing_description()` queried only `post` and `page`, while the Social Graph screen scans products too — so on a store the AI bulk run queued a handful of pages, skipped them as thin, and reported "0 created. Reload to see them." to a merchant looking at hundreds of product rows missing og:description. Both queries now include `product` when WooCommerce is active (matching `TWTAEO_OG_Detector::post_types()`), the generator folds `post_excerpt` into the content it summarises (for products that is the short description — often the entire pitch), and every finish surface — the in-page status line, the admin notice — reports skipped counts by name instead of hiding them inside a bare created figure. Starting a run with nothing to do now says so instead of dead-ending at "Finding posts…".
* **Improved: page-scoped numbers say so.** On a paginated catalogue the Products tab summary cards are labelled "(this page)" with a caption naming exactly what was scanned — "0 Missing Schema" beside "949 Total Products" must never read as a site-wide verdict the scan never made. "Generate Schema for All" also stays visible whenever the catalogue is only partially scanned, since it walks the whole catalogue regardless of the page shown.

= 2.14.0 =

* **Compatibility: verified against WordPress 7.1 (RC2) and WooCommerce 11.0.0.** Every admin screen (Dashboard, Modules, Settings, Command Center, Social Graph, Schema Detector, Image SEO, E-E-A-T, WooCommerce + its Smart Collections and Promotions tabs, Knowledge Graph, Not Indexed, AI Ready) and the front-end surfaces (product pages with the full connected @graph, shop, FAQ pages, llms.txt) render with zero deprecations, warnings or fatals. WordPress 7.1's AI Client SDK still exposes usingModelPreference / usingMaxTokens / generateText, so the WordPress 7 AI generation path keeps working unchanged. Tested up to bumped to 7.1.

* **Feature: commerce schema suite ported back from AEO Ultimate for WooCommerce.** That plugin was forked from this one and its `@graph` and product schema kept evolving; this release brings the evolved versions home:
  * **Advanced Knowledge Graph (all pages, not just products):** the Organization is typed `OnlineStore` when WooCommerce is active; placeholder emails (dev-email@wpengine.local and friends) are never published as a contact point; WebPage titles are entity-decoded; `mainEntity` resolution understands `#product-group` and `#collection` (collection deliberately last so a shortcode collection never outranks the product on its own page); `isPartOf` back-references are restricted to CreativeWork types as the vocabulary requires; mapped product-category archives are typed `CollectionPage`; and the emitted JSON is compact rather than pretty-printed (~half the bytes on a large product graph).
  * **Product schema engine:** the WooCommerce detector is replaced with the evolved version — connected Product/ProductGroup `@graph` with per-variant offers, image nodes with captions, strikethrough (sale) price specifications, dimensions, GTIN/MPN identifiers, product relationships, review detection, shipping details derived from live shipping zones, return policy detection, speakable specs, breadcrumbs folded into the graph, and suppression of WooCommerce core's duplicate Product block. New `twtaeo_product_schema` filter for extensions.
  * **Extension-aware pricing (`TWTAEO_WC_Extensions`):** Bookings, Subscriptions, Bundles and the rest render "From: $X", not a definite price — offers now declare a range or a floor instead of publishing a starting figure as the price.
  * **Product FAQs:** every FAQ source on a product page (merchant-written, generated, filtered in via `twtaeo_product_faq_pairs`) is assembled into exactly one FAQPage node at `#product-faq` — deliberately not `#faqpage`, so it never outranks the Product as the page's mainEntity. PHP and AI generators included.
  * **Smart Collections (new module, off by default):** turns intent-shaped categories ("Starter Kits", "Under $500") into published Collections — CollectionPage + ItemList on the category archive, a written explanation, llms.txt entries, and a Search Console-driven suggester. Creates no pages or categories on its own; drafting returns a proposal and writes nothing until explicitly confirmed.
  * **Promotions & Price Rules (new module, off by default):** quantity breaks and member pricing published as `UnitPriceSpecification` entries carrying their own conditions (`eligibleQuantity`, member tier); coupon codes sent to Google Merchant Center (the channel built for them) and a downloadable Bing promotions feed; reads WooCommerce coupons directly so Advanced Coupons / Smart Coupons rules are covered; names any plugin that can change a price without a code. Never writes a discount into the offer price. Weekly re-derivation keeps detected dynamic pricing from outliving the rule that produced it.
* **Note:** both new modules require WooCommerce and are off until switched on. **When AEO Ultimate for WooCommerce is active, all ported commerce writers stay unregistered** — that plugin ships the same engines, and running both would publish every node twice. Detection and admin scanning still run; only front-end emission stands down.
* **Feature: the setup wizard's Autopilot now does real work.** Previously it enabled two modules and copied company info; now everything that can be done **without AI** also runs, **in the background** (one cron event, with the completion screen polling — and driving the run itself where WP-Cron is dead): FAQ + Service schema built for every detected page still missing it, Organization/ContactPoint schema for contact pages with gaps, and og:description filled from existing meta descriptions. **Every task checks whether another plugin already provides the surface first** — pages carrying schema from any source are never candidates, and the social-description step skips itself entirely when an SEO plugin owns Open Graph output. The completion screen reports exactly what was done and what was skipped and why. AI actions are never run by Autopilot; they stay behind their explicit, cost-labelled buttons.
* **Bug fix (important, WooCommerce):** "Generate Schema for All" only processed the 200 most recent products — it looped the display scan (capped at 200) instead of walking the catalogue. It now processes the whole catalogue in cursor-driven batches of 25 with live progress ("Generating… 175 of 1,400"), and a failed request reports how far it got and resumes on the next press instead of claiming nothing happened.
* **Bug fix (important, WooCommerce):** products could sit flagged "missing schema" that Generate for All refused to touch, with no explanation — schema detection checked stored scans, Rank Math, Yoast, SASWP, WooCommerce core and inline JSON-LD, but never this plugin's own saved schema, while the generator skipped exactly those products because the schema already existed. The writer's own schema now counts as schema present, and products the generator cannot build are listed by name with a pointer to what is missing.
* **Bug fix (Image SEO):** pages with no images at all were silently omitted, so a site whose pages lack images saw an empty screen and concluded the scanner was broken. They are now listed and flagged — no image means nothing for rich results, social shares, or AI answers to show, which is a worse gap than missing alt text — with an "Add an image" action (alt text starts with an image). The header counts them, and a note points WooCommerce stores to the product-image audit on the WooCommerce tab. The shared coverage badge also now shows an explicit "No image" warning instead of a muted dash (this fixes the same silent gap on the WooCommerce products table).
* **Bug fix (Social Graph):** on stores with more than 200 pages/products the header and summary presented the 200-post scan cap as the whole site ("200 missing image" on a 1,400-product store). Counts are now labelled honestly — "200 of 1,400 pages scanned (most recent first)" — with the true published total counted from the database.
* **Bug fix (important, Dashboard):** "Scan All Pages" scanned exactly the 200 most recent posts and silently stopped — the server-side background scanner that was designed to pick up the remainder was never triggered from the button. On completion the foreground pass now hands off to the background scanner, with progress shown; large catalogs get fully scanned.
* **Bug fix (Dashboard):** products listed with missing Product schema were a dead end — clicking + alerted "No form defined for schema type: Product". Products now generate their complete schema in one click using the same generator as the WooCommerce tab (a Product node cannot be typed into a form; offers, identifiers, shipping and returns come from the product itself).
* **Bug fix (WordPress 7):** the Setup Wizard was registered as an orphan (null-parent) admin page, which on WordPress 7 resolves no admin title and triggers a strip_tags() deprecation from admin-header.php on every wizard load. It is now a real (CSS-hidden) submenu entry with a proper title.
* **Improvement:** Smart Collections and Promotions live as tabs on the WooCommerce screen (Products | Google Merchant Center | Bing Merchant Center | Smart Collections | Promotions) instead of two more sidebar entries. Their pages keep their own URLs and sub-tabs; the sidebar items are hidden and the highlight stays on WooCommerce. ⚠️ WordPress 7 note recorded in class-admin-menu.php: every classic "hidden admin page" trick now fails — remove_submenu_page() revokes access, and a null/options.php parent gets its parent_file clobbered because get_admin_page_parent() recomputes AFTER the filter — so the pages stay genuinely registered and are hidden with CSS only.
* **Bug fix (PHP 8.4):** two ported methods declared implicitly nullable typed parameters (`array $children = null`, `WC_Product $product = null`), which PHP 8.4 deprecates loudly enough to break admin headers on debug installs. Both now use explicit `?` nullable types; all ported files swept for further cases (none).
* **Bug fix (found during the port):** with the detector's breadcrumb now folded into the unified graph, AI Ready's standalone semantic BreadcrumbList would have shipped a second copy on every product page. It now stands down whenever the graph actually publishes a BreadcrumbList — checked against `published_types()` (newly ported alongside `published_ids()`), not `is_folding()`, because a folding page whose graph carries no breadcrumb still needs the standalone one.

= 2.13.0 =

* **Feature: bulk actions everywhere a per-row button existed.** Every screen that previously required clicking pages one at a time now has a one-click bulk control at the top, so a whole site can be brought to full coverage in one pass:
  * **Schema Detector → FAQ tab:** new "Generate All (N missing)" button builds FAQPage schema for every listed page from its already-detected Q&A content. Free — no AI calls. (The button previously existed only on a legacy standalone page that was no longer linked in the menu; the merged Schema Detector tab that users actually see only had per-row buttons.)
  * **Schema Detector → Service tab:** new "Generate All (N missing)" button auto-fills Service schema for every page missing it, using the same prefill the one-click metabox action uses (page title, trimmed content, detected provider/business info). Free — no AI calls. Each page's schema can still be refined afterwards via "Edit Schema".
  * **Schema Detector → Contact tab:** new "Create for All (N need attention)" button in the Business Info panel applies the shared business profile to every contact page that still needs schema, instead of clicking "Create from this info" row by row. Free — no AI calls.
  * **Image SEO:** new "Generate all missing alt text (AI)" card fills alt text across every listed page instead of per-row clicks. States the exact image count up front, asks for confirmation before anything runs (one billed AI call per image), shows live progress, and has a Stop button — pages already processed keep their new alt text.
  * **WooCommerce → Products:** new "Fill All Missing Alt Text (AI)" button next to "Generate Schema for All" runs AI vision over every product with images missing alt text. Same safeguards: image-count confirmation with billing notice, live progress, Stop button.
* **Note:** all bulk actions that use AI say so explicitly and confirm the billed call count before running; all bulk actions that are free say "no AI calls, nothing is billed" in the UI. Nothing runs without a click, and nothing existing is overwritten — bulk actions only fill what is missing.
* **Bug fix (important, AI-Ready):** every plain-text/markdown endpoint served to AI crawlers — `llms.txt`, `llms-full.txt`, `ai.txt`, Markdown content negotiation (`Accept: text/markdown`), and the Agent Skills documents — was passed through `wp_kses_post()` before output. That function is an HTML sanitizer: on non-HTML bodies it entity-encodes the text, so the llms.txt spec's required `> description` blockquote reached crawlers as `&gt; description`, every `&` as `&amp;`, and code fences in skill documents could be mangled. These responses are `text/plain`/`text/markdown` and cannot execute HTML, so escaping bought no safety and corrupted exactly the output the plugin exists to provide. All five endpoints now emit the raw document (UTF-8 validated). Found while testing 2.13.0 on a live site: its `llms.txt` literally began `&gt; AEO Dashboard`.
* **Efficiency:** the WooCommerce products table computed each product's image-alt summary twice per row; it is now computed once and reused.
* **Bug fix (WordPress 7, follow-up to the AI Client fix):** bulk AI social descriptions aborted with "Method using_max_output_tokens does not exist on WordPress\AiClient\Builders\PromptBuilder" after generating nothing. Two compounding traps: the WP wrapper serves every fluent method through `__call`, so `is_callable()` is true for **any** name — and calling an unsupported one does not throw; the wrapper **poisons the builder** and the eventual `generate_text()` returns `prompt_builder_error`, so try/catch cannot save it either. The plugin was calling `using_max_output_tokens`, which has never existed in the SDK (the real method is `usingMaxTokens`). Optional tweaks (model preference, token cap) are now gated on `method_exists()` against the underlying SDK class `WordPress\AiClient\Builders\PromptBuilder` — the only probe that reflects what the installed AI Client actually supports — and the cap uses the correct `using_max_tokens` name. Applied to both AI Client call paths (`TWTAEO_AI_Description` and `TWTAEO_AI_Client`).

* **Bug fix (important, WordPress 7 only):** the WordPress 7.0 AI Client path was dead. `WP_AI_Client_Prompt_Builder` declares only `__construct`, `using_abilities` and `__call` — every fluent method, `generate_text()` included, is served by `__call`, so `method_exists()` is **false** for all of them while `is_callable()` is true. Both guards used `method_exists()`, so `generate_text()` was **never called**: `TWTAEO_AI_Client::call_via_ai_client()` short-circuited to `''` and returned "returned no content" without making a request, and `TWTAEO_AI_Description::call_via_ai_client()` could only ever return "This WordPress AI Client version cannot generate text". Leaving the key fields blank — which the settings screen recommends on WordPress 7 — therefore disabled AI generation entirely. Now uses `is_callable()`, actually invokes the builder, and passes WordPress's own `WP_Error` message through instead of flattening it to a generic one. `TWTAEO_Rate_Limiter` had the same defect on `get_ability_name()`, logging `unknown` always; that one was cosmetic.
* **Feature:** `TWTAEO_Output_Control` — a switch for every output that could collide with another plugin, on the Schema Conflict Detector page. Nine keys: `schema_identity`, `schema_article`, `schema_service`, `schema_contact`, `schema_breadcrumb`, `llms_txt`, `sitemap`, `meta_description`, `crawler_log`. **Seven of these had no gate anywhere** — the Knowledge Graph spine, Article, Service and Contact writers, the AI meta description, AI-Ready's `/sitemap.xml` endpoint and the crawler logger all registered outside every module check, so a site running Yoast or Rank Math had duplicates with no lever short of deactivation. Writers are gated at **registration**, not at output time.
* **Note:** every key defaults to **on**, so updating changes the output of no existing install. Conflicts are detected and reported; nothing is ever switched off automatically, because silently removing schema from a working site during an auto-update is worse than a duplicate.
* **Note:** `llms_txt` and `schema_breadcrumb` **delegate** to the settings that already own them in `twtaeo_ai_ready` — read, never copied. `set()` refuses them and the admin shows those rows disabled with a link to the AI Ready screen. A second flag that can disagree with the first is the bug this shape exists to prevent.
* **Note:** ⚠️ switching an output off strips every reference into the removed nodes; a hand-off does not. They are not the same operation — a hand-off is guaranteed a same-`@id` replacement, a switch is not (Yoast addresses its WebPage by the bare URL, so references into `{url}#webpage` would orphan).
* **Improvement:** Surface hand-off to AEO Ultimate for WooCommerce. That plugin is a commerce-focused fork of this one, so the two published the *same* `@id`s — `home_url( '/#organization' )`, `home_url( '/#website' )` and `{url}#webpage` — both on `wp_head` priority 1, plus two `/llms.txt` writers, two `/sitemap.xml` writers, two meta description writers and two BreadcrumbLists. Most of that came from writers registered outside every module check, so there was no setting a merchant could reach. `TWTAEO_Commerce_Handoff` now lets the merchant pick an owner per surface on that plugin's ownership screen, and this plugin stands down from whatever was handed over.
* **Note:** entirely inert without that plugin — the handshake is a `function_exists()` check on functions only it defines, so an install running this plugin alone cannot reach any of this code. There is no new setting, option or screen here.
* **Note:** schema stands down **per node**, never per graph. A node is dropped only when the other plugin republishes one at the same `@id` (or, for the Local Pack block which carries no `@id`, a node of the same type) on that same request. Article, News, Service, Author and PR nodes — which that plugin does not publish — are untouched, and references stay valid because the replacement carries the identical identifier.

= 2.12.1 =

* Bug fix (important): on hosting without the PHP libsodium extension, saving or reading any stored key or token could trigger a critical error. Encryption now detects what the server actually supports, so these sites work normally again. If you have seen "There has been a critical error on this website" on a settings screen, this release is likely the fix.
* Bug fix: the Not Indexed page could stop loading with a PHP fatal error when it encountered scan results saved by an older version of the plugin. Stored results are now read defensively.
* Bug fix: module pages that attach themselves to another WordPress menu (for example Appearance → Author Schema) were registered without a capability, so they never appeared and produced a PHP warning. They now use the same permission as the rest of the plugin.
* Improvement: WooCommerce cart, checkout, and my account pages are no longer reported as "not indexed". WooCommerce marks them noindex by design, so they were noise — and they no longer consume Search Console inspection quota.
* Improvement: issues on the Not Indexed page that can be fixed automatically now show a "Fix" button, which generates FAQ schema or fills in image alt text without leaving the page.

= 2.12.0 =

* Bug fix: the Author Entity (E-E-A-T) admin page could stop loading with a PHP fatal error on some sites. The author data lookup now returns a consistent structure, so the author profiles, certification counts, and summary totals render reliably.

= 2.11.0 =

* New unified Knowledge Graph: when a page would output two or more schema blocks (for example Organization + FAQPage, or a post with an author), they are now woven into a single connected `@graph` — one identity spine (WebSite → Organization → WebPage) with every entity cross-referenced by `@id` — instead of separate, disconnected JSON-LD blocks. Pages with only one schema type are left exactly as before.
* Consolidates duplicate Organization output, links each page's primary entity (Article, FAQPage, Service, Product) to the WebPage, and emits the author (Person) and publisher (Organization) once, referenced by `@id`.
* New Knowledge Graph admin page to define entities, relationships, and per-URL about/mentions mappings, with Wikipedia/Wikidata `sameAs` grounding.

= 2.10.0 =

* **Feature:** New **Smart 404 Rescue** module (default off). Turns 404s into fixes instead of a bloated log table — nothing is written per-404. On a genuine visitor 404 it: (1) ignores hacker/bot probe traffic outright via a bundled, admin-extendable pattern list (wp-login, .env, /.git, shells, traversal, and more) so attack noise never generates work or notifications; (2) matches the dead URL to the closest published page by typo-distance; (3) auto-corrects broken **internal** links at their source with a precise, reversible undo log; and (4) 302s the visitor to the right page.
* **Feature:** Smart 404 Lost Page Recovery. Using the plugin's existing Google Search Console connection, a weekly (and on-demand) scan pulls the URLs Google still ranks you for, keeps the ones that now 404 — renamed or moved content — and matches each to the closest live page by keyword overlap. Confident matches become **permanent 301s automatically** (these apply to crawlers too, so search equity transfers); ambiguous ones are listed for one-click approval. A "Not found (404)" GSC export can also be pasted in for the long tail. Reliable and free — no third-party backlink guessing.
* **Feature:** Smart 404 admin page — run the lost-page scan, approve suggested redirects, review and remove 301s, undo healed links, and extend the hostile-probe baseline. All state is held in bounded options (no custom tables). The scan cron is torn down cleanly when the module or plugin is deactivated.
* **Feature:** New **Access & Roles** page (administrator-only). Choose which WordPress roles — an Editor, or a custom "SEO Manager" role — can use the AEO tools without being full administrators. Ideal for agencies giving a client's marketing person controlled access. It grants a dedicated `manage_twtaeo` capability that unlocks the plugin's operational pages, AJAX actions, and the per-post schema controls, but only within the plugin itself — allowed roles gain no site-wide admin power, and Settings, Modules, Diagnostics and this page stay administrator-only so credentials and configuration are never exposed. Access is computed live from a single option (no roles are mutated), so it's fully reversible.
* **Minor:** A one-time, dismissible review request appears on the plugin's own admin screens after seven days of use ("leave a review" and "already did" hide it permanently; "maybe later" snoozes it a week). Shown only to administrators, never site-wide.

= 2.9.1 =

* **Feature:** WooCommerce Product schema now emits Google's newly documented merchant-listing fields. Offers carry `priceValidUntil` (so the price never reads as stale in Search), and scheduled sales add `validFrom`/`validThrough` from the product's sale dates — on both simple products and each variation of a variable product. Products also emit the recommended `category` property, mirrored from their WooCommerce product categories so on-page markup matches the Merchant Center feed.
* **Feature:** AI Google Product Category resolver. One click in the product schema modal maps a product to its exact entry in Google's official Product Taxonomy via web-grounded AI and adds it to the schema as a `CategoryCode` object (numeric ID + full path) alongside the plain-text categories — using the new array format Google now accepts. This makes the product page a single source of truth that Google Shopping and AI shopping agents read identically, without hand-mapping a 5,000-line taxonomy. The result is cached per product and flows automatically into generated schema.
* **Feature:** Category alignment audit in the Google and Bing Merchant Center sync. Each product's on-page resolved Google Product Category is now compared against the value in the feed; disagreements surface as a new "Category Mismatch" count, factor into the E-Commerce Integrity Score, and flag the product row as a Data Gap — so agencies can catch page↔feed category drift the same way they already catch price and availability drift.

= 2.9.0 =

* **Removed:** The built-in Content Generator module and its remote-generation REST endpoints. AI-assisted drafting is available in the standalone TWT Content Generator plugin instead. AI API keys and all other AI features are unchanged.
* **Removed:** Dormant bulk OG-image generation code. Per-post, on-demand OG image generation in the editor modal remains.
* **Removed:** The unused Agency Reporting module registration; the optional TWT Agency connection itself is unchanged.
* **Compliance:** The last inline admin script block (the metabox one-click schema button) now loads via `wp_enqueue_script()` with localized strings.
* **Compliance:** Restructured the readme within directory rendering limits. The complete external-services inventory now ships in EXTERNAL-SERVICES.md and the full version history in this file.
* **Compliance:** Verified every disclosed terms/privacy link resolves and corrected outdated ones; expanded disclosures to name each API endpoint and dashboard link.
* **i18n:** Regenerated the translation template and fixed translator-comment mismatches.

= 2.8.0 =

* **Compliance:** Output-escaping pass across the plugin. Inline SVG icons are now escaped through `wp_kses()` with an explicit SVG allow-list; JSON-LD schema output is hardened with the full `JSON_HEX_*` flag set so it is inert in HTML; and the WebMCP tool definitions are handed to the browser via `wp_add_inline_script()` instead of being echoed. Every remaining intentional escape exception is documented inline.
* **Compliance:** All physical-file operations for the `/.well-known/` discovery files (API Catalog, Agent Skills Index, MCP Server Card, OAuth/OIDC) and `robots.txt` now go through the WordPress `WP_Filesystem` API instead of direct PHP filesystem functions.
* **Compliance:** Unique-prefix pass — every element that used the short `twt_` prefix (the Reviews post type and its meta keys, the merchant-sync snapshot meta, nonces, AJAX/redirect parameters, and a transient) now uses the plugin's `twtaeo_` prefix. A one-time, automatic database migration renames existing data so nothing is lost on upgrade.
* **Compliance:** base64 transport-encoding calls (cryptography, AI image payloads, OAuth/JWT, HTTP Basic, the admin-menu data-URI icon) are documented as legitimate, non-obfuscation uses.
* **Security:** The AI Ready settings save now performs independent capability and nonce checks within the handler, and Markdown content negotiation reads its `aeo_format` request flag via a registered query var rather than the raw query string.
* **Feature:** When the server filesystem is not writable, the AI Ready page now shows step-by-step instructions plus the exact, copy-and-paste contents for creating the `/.well-known/` discovery files (and the `robots.txt` Content-Signal/Sitemap directives) by hand.
* **Compliance:** Expanded the External Services disclosures with the optional industry-directory links the plugin can surface, and corrected/removed several directory URLs.

= 2.7.0 =

* **Feature:** New **Image SEO** page — scans posts and pages for images missing alt text and fills it with AI vision per item, or via a manual edit modal when no AI provider is connected. Covers the featured image and images embedded in the content, and writes both the Media Library attachment alt and the empty inline `alt=""` in the post HTML (existing alt text is never overwritten). WooCommerce product images remain on the WooCommerce page.
* **Feature:** Rich WooCommerce Product schema — the per-product schema modal now writes nested Brand, color/material/size, GTIN/MPN, `additionalProperty` custom specs, `itemCondition`, variable products as `ProductGroup` with `variesBy`, cross-sell/related/similar/accessory relationships, `aggregateRating` + individual reviews, and site-wide `OfferShippingDetails` + `hasMerchantReturnPolicy` defaults.
* **Feature:** AI Product Enrichment engine (per-product, on-demand, non-destructive) — extract attributes from descriptions (with unit conversion to your store units), tag visual attributes from the product image (color/pattern/texture/shape), generate image alt text, enhance the description (fact-grounded, with optional write-back to the product), standardize custom attributes into schema.org fields, resolve brand `sameAs` authority links (Wikipedia/Wikidata/official), and flag competitor-expected fields you're missing.
* **Feature:** Shared multi-provider AI client (Claude, OpenAI, Google Gemini, Perplexity) with JSON, web-search grounding, and image (vision) input. New "AI Enrichment" settings let you choose an extraction provider and a live-retrieval provider independently.
* **Feature:** Products table now shows a per-product schema **Optimization score** (9 weighted signals, with the missing ones on hover) and an **image-alt coverage** column — both update live after you save or fill.
* **Improvement:** `/llms.txt` now includes a Products section for WooCommerce stores — links the shop and product categories and declares that the store publishes Schema.org product data for AI shopping agents.
* **Improvement:** Updated AI model IDs to current releases (OpenAI `gpt-5.4-mini`, with correct GPT-5 reasoning-model request parameters; image generation on `gpt-image-2`) and verified Claude/Gemini/Perplexity IDs.
* **Compliance:** Updated the External Services disclosures to cover the new AI uses — WooCommerce product enrichment and Image SEO — and to state that vision features send your own post/product image URLs/contents to the configured AI provider. Added a dedicated Google Gemini API entry. All AI requests remain opt-in, key-gated, and triggered only by an explicit click.

= 2.6.1 =

* **Feature:** AI traffic & citations across the Command Center — a GA4 "AI Assistant Traffic" section (ChatGPT, Gemini, DeepSeek, Copilot, Grok) with per-source sessions, users, and conversions, plus "AI Search" panels on the Search Console and Bing tabs that link out to Google's and Bing's AI performance reports (those stay UI-only until the providers expose APIs).
* **Feature:** Index Status now inspects every published page (not just the 50 most recently edited) and shows the pages that actually need work — sorted worst-first, with a "Needs work / All scanned" toggle and pagination. Google has no bulk index-status export, so each page is inspected individually via the URL Inspection API.
* **Feature:** Inline fixes on the Index Status tab — an "Issues & Fixes" column with a modal to fill Open Graph (title/description/image, with AI generation) and generate FAQ schema without leaving the page.
* **Feature:** Analytics "Top Pages" now includes Bounce Rate, Avg Time, Events, and Conversions plus a plain-language Signal (Converting / Needs work / Likely bots / Healthy) so non-analysts can tell what to act on.
* **Improvement:** Bing tab "Query Statistics" rebuilt as a proper Top Queries table (query, impressions, clicks, CTR, average position) — it previously discarded the query term and showed a bogus -0 position from click-position data.
* **Improvement:** Index Status content check now flags missing heading hierarchy (no H2/H3) instead of a missing in-content H1, since WordPress themes render the post title as the page H1.
* **Improvement:** Modules page descriptions reviewed for accuracy — Social Graph now notes its AI og:description/og:image generation and bulk fill.
* **Fix:** Setup wizard text corrected — sitemap URL now shows the real /aeo-sitemap.xml path, the completion screen links to the Command Center, and Basics step labels match what they configure.
* **Improvement:** Plugin Check pass — annotated the legitimate-but-flagged spots (read-only display filters, core-hook calls, custom merchant-queue table queries, guarded set_time_limit) with justified phpcs:ignore comments. No behavior change.

= 2.6.0 =

* **Feature:** AI Discovery File Hits — the Command Center now tracks how many AI crawlers fetch your machine-readable discovery files (robots.txt, llms.txt, llms-full.txt) over a rolling 30-day window, so you can see whether bots are actually using your structured signals. Per-site hits sync to the agency dashboard.
* **Feature:** "Remove physical robots.txt" button on the AI Ready page — deletes a stranded physical robots.txt (e.g. one left behind by Yoast) so WordPress serves it dynamically again, clearing stale content. No FTP required.
* **Improvement:** The robots.txt panel is now state-aware and recommends the right action per site — confirms when robots.txt is already dynamic and tracked, or guides you to remove/inject when a physical file is in the way. SEO-plugin detection now covers Yoast, Rank Math, All in One SEO, and SEOPress.
* **Improvement:** llms.txt, llms-full.txt, and the dynamic robots.txt now send no-cache headers so a full-page cache (host cache or CDN) doesn't serve them without running WordPress — which would hide AI-crawler hits. The dashboard also flags a physical robots.txt as "not tracked" instead of showing a misleading zero.
* **Fix:** The IndexNow key file now returns HTTP 200 (it was returning 200's body with a 404 status, which made search engines reject it).
* **Fix:** Saving IndexNow settings no longer fails with "The link you followed has expired" — a nested form was causing a nonce conflict.

= 2.5.0 =

* **Feature:** AI Open Graph images — generate a clean, on-brand social share banner (with the post title rendered on it) for a post on demand from the Social Graph editor modal: choose a style and what to base it on, and the image is added to the Media Library and set as the post's og:image. You can optionally reuse it as the post's featured image.
* **Feature:** Stop button for the bulk AI-description job, plus per-provider notes showing each model and its relative speed and limitations.
* **Improvement:** Bulk AI generation is more resilient — a single slow API call that times out is now skipped instead of stopping the whole run (it only gives up after repeated network failures), and the per-request timeout was raised.
* **Improvement:** Pages with too little body text are skipped during AI description generation, since thin pages only produce generic descriptions.
* **Improvement:** Added a "Rescan" button to the Social Graph and Schema Detector pages, with a confirmation when the scan refreshes.
* **Fix:** Google Gemini descriptions are no longer thin or empty — the gemini-2.5 reasoning model was spending its token budget on internal "thinking"; that is now disabled for these short summaries.
* **Fix:** The Social Graph coverage table now reflects the og:title and og:description saved through the AEO editor (previously only the saved image was detected), so saved values no longer show as "missing" after a rescan.
* **Fix:** The Schema Detector FAQ tab now recognises FAQPage schema created by its own "Add FAQ Schema" action, so generated schema no longer keeps showing as "Missing".

= 2.4.1 =

* **Feature:** Bulk AI meta-description generation now runs as a background job — it processes server-side in small batches, shows live progress, and continues if you navigate away (on hosts with working WP-Cron). It also advances directly from the dashboard while the page is open, so it works even where WP-Cron is disabled. The run stops cleanly and shows the provider's message on errors such as no remaining API credits.
* **Feature:** Social Graph — fill missing Open Graph (og:description) values in two ways: reuse the meta descriptions you already have (free and instant) or have AI write share-optimised social copy in the background. The per-post editor modal also gains "Use meta description" and "Generate with AI" buttons.
* **Fix:** Updated the default Google Gemini model to gemini-2.5-flash (gemini-2.0-flash was retired).

= 2.4.0 =

* **Feature:** AI Meta Descriptions — generate concise meta descriptions with Claude, OpenAI, or Google Gemini. Use the per-post "Generate with AI" button in the editor, the bulk "Generate all missing" action on the dashboard, or turn on automatic generation when a post is first saved (existing descriptions are never overwritten). The description is written to the active SEO plugin's field when one is present, and output as a `<meta name="description">` tag when none is.
* **Feature:** Added Google Gemini as an AI provider in Settings (key resolves via the `TWTAEO_GEMINI_KEY` constant/env var or the database, like the other providers).
* **Feature:** Article schema is now output automatically on blog posts that have no SEO plugin supplying it, so posts are no longer left without article-type structured data.
* **Feature:** New Diagnostics page (System Info + recent error log) with one-click copy for support. A centralized logger captures plugin errors — including a plugin-scoped fatal-error catcher — and redacts credentials from log output.
* **Improvement:** NewsArticle schema is now opt-in per post via the "Mark as Google News article" toggle instead of being applied to every post; the news sitemap follows the same flag. This stops evergreen content from being mislabelled as news.
* **Improvement:** E-E-A-T scan now reads your Company Profile and all supported SEO plugins (Yoast, Rank Math, AIOSEO, SEOPress, The SEO Framework) when checking for sameAs links and an Organization name, and the cached scan refreshes automatically when plugins are activated or deactivated.
* **Improvement:** Index-status results now flag `noindex` and mismatched canonical URLs (the real reasons a page stays unindexed), add an "Edit page" shortcut, and explain what actually drives indexing.
* **Fix:** The "Add Certification" button on the user profile now works (its script handle was not enqueued on the profile screen).
* **Fix:** sameAs links saved via the E-E-A-T "Add Links" modal now persist and are detected on rescan.

= 2.3.3 =

* **Compliance:** Plugin Check pass — rewrote database queries cleanly, fixed global-variable usage, and corrected hook-name flags.

= 2.3.2 =

* **Compliance:** Plugin Check compliance pass; declared WordPress 6.2 as the minimum supported version and made the WordPress 7.0 AI Client call conditional.

= 2.3.1 =

* **Security/Guidelines:** The Content Generator now sends Claude and OpenAI requests through the WordPress 7.0 AI Client (`wp_ai_client_prompt()`) when no plugin-owned key is set, so the plugin no longer reads the core Connectors API keys (`connectors_ai_*_api_key`) directly.
* **Security:** The MCP endpoint (`aeo/v1/mcp`) now runs the same permission checks as the Agent Search endpoint — honouring bot-block rules, rate limits, IP allow-list, user-agent verification, and OAuth bearer auth instead of bypassing them. It remains public by default when no restrictions are configured.
* **Performance/Guidelines:** Moved all remaining inline `<style>` and `<script>` blocks (admin pages, the Hub metabox, front-end hub shortcodes, the Last Updated badge) into properly enqueued CSS/JS via `wp_enqueue_*`. JSON-LD schema output remains inline as required.
* **Compliance:** Removed promotional upsell prompts from the AI Crawler Watch and Not Indexed screens; the Not Indexed report now lists every not-indexed page locally instead of capping the free view.
* **Fix:** Corrected the EasyPRWire service domain and its Terms/Privacy links, and updated the EIN Presswire legal links. All External Services disclosures reviewed and verified.

= 2.3.0 =

* **Feature:** Added Google service-account authentication as a no-OAuth alternative for Search Console and Analytics access on agency-managed sites.

= 2.2.2 =

* **Feature:** Social Graph now auto-fills the Open Graph title and image and adds an `og:image:alt` label.
* **Feature:** The Dashboard now scans pages beyond the initial 200-page cap in the background, with progress and a completion notice.

= 2.2.1 =

* **Fix:** Resolved a fatal error when author social/credential meta was stored as an array and passed to `json_decode()`.
* **Branding:** Updated the Modules promo card from "TWT AEO Pro" to "TWT Agency" with an accurate feature list.

= 2.2.0 =

* **Feature:** Content Generator — industry/subtopic-based AI content from Claude, OpenAI, and Perplexity.
* **Feature:** AEO Command Center — Google Analytics, Search Console, and Bing Webmaster integrations.
* **Feature:** Background WooCommerce merchant sync to Google Merchant Center and Bing Merchant Center.
* **Feature:** Connector REST API for the TWT Agency hub, automatic index-status scanning, and remediation endpoints.

= 2.1.1 =

* Renamed the connected dashboard product to **TWT Agency** across the admin UI, settings, and documentation. Sync behavior is unchanged, and existing connection settings are preserved.
* Updated the External Services disclosures and added Privacy Policy / Terms of Use links covering the optional TWT Agency sync and Token Telemetry features.
* Readme polish and WordPress.org formatting and compliance fixes.

= 2.1.0 =

* **Compatibility:** Validated against WordPress 7.0; now requires MySQL 8.0.
* **Feature:** Rebuilt the AI Crawler Watch interface on the WordPress DataViews framework (React) with live client-side filtering, sorting, and status — falling back to a classic table on older environments (< WP 6.5).
* **Integration:** Registered core operations with the WordPress 7.0 Command Palette and Client-Side Abilities API. Schema Conflict Scan and IndexNow Submit All are now available via `Ctrl+K` and core AI assistants.
* **Block Editor:** Added iframe-aware state tracking. The AEO Status metabox now reads block state via the `wp.data` store, avoiding direct DOM access in the iframed editor.
* **Developer:** Added the `TWT_AEO_Key_Resolver` for API credentials, resolved in priority order (PHP constant → environment variable → Connectors → database). Constants are never written to the database.
* **Server Safeguards:** Added environment checks on admin pages that flag low memory conditions (≤ 128MB) which can conflict with WP 7.0 AI features.

= 1.0.17 =

* Added: Company Profile module — Organization schema, `article:publisher` / `article:author` OG split, `fb:app_id` (Meta Business Sync), and Yoast / Rank Math one-click sync.
* Added: Per-bot controls — custom rate limits and Block toggles for 20 known AI crawlers.
* Added: `/llms-full.txt` generation mirrored against your sitemap configuration.

= 1.0.16 =

* Added: Repeatable `EducationalOccupationalCredential` fields on the author profile.
* Added: Configurable frontend author hover cards.

= 1.0.15 =

* Added: XML sitemap generator at `/aeo-sitemap.xml`.
* Added: Conflict detection against external SEO-suite sitemaps.
* Added: IndexNow publishing hooks.

= 1.0.14 =

* Added: AI Ready toolkit — `llms.txt`, agent endpoints, and discovery/authentication routes.
* Added: MCP server configuration.

= 1.0.13 =

* Added: Command Center overview with crawler analytics and a default 30-day log retention.

= 1.0.12 =

* Added: Open Graph and social metadata output with automatic conflict avoidance.

= 1.0.11 =

* Added: Author profile schema and a configurable frontend author box.

= 1.0.10 =

* Added: Schema Detector for FAQ and Service content.
* Added: Local Pack module and LocalBusiness schema.

= 1.0.0 =

* Initial release — module loader, dashboard, settings, and data transmitter.
