LocalEyes Plugin
LocalEyes WordPress Plugin, with all versions.
by LocalEyes · github.com/luccas-localeyes/localeyes-wp-plugin
Install
No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:
wp plugin install https://github.com/luccas-localeyes/localeyes-wp-plugin/archive/refs/heads/main.zipLocalEyes Plugin (WordPress)
LocalEyes is a WordPress control panel for site speed and structure. It scans PageSpeed Insights / Core Web Vitals, gives a dedicated TTFB analysis, builds native replacements for other plugins (copying their data and proving parity against the live page, then recommending you remove them yourself), provides a full SEO suite, surfaces GSC index hygiene, and keeps snapshot backups of everything it changes. It also keeps the original NAP find-and-replace engine as the Search & Replace tool.
It never deactivates or deletes any plugin. It copies functionality and recommends; you remove.
- Plugin name: LocalEyes Plugin
- Main file:
localeyes-nap.php(loader) +includes/modules - Version: 2.5.0-dev
- Author: LocalEyes
- Requires: WordPress 5.6+, PHP 7.0+
- License: GPL-2.0-or-later
- REST namespace:
localeyes/v1(v2 routes needmanage_options; the NAP routes neededit_pages) - Full build plan:
C:\Users\Capitao\.claude\plans\eager-yawning-tiger.md
Build status (v2.5.0-dev)
This is an in-progress rebuild delivered in phases. What is implemented and installable now:
| Area | Status |
|---|---|
Modular loader + custom DB tables (wp_localeyes_*) |
Done |
| Real-time job engine (pause / continue / cancel, checkpointed) | Done |
| PSI / Core Web Vitals client (mobile + desktop, full audits, no truncation) | Done |
| Configure tab: PSI + GSC keys + admin App Password (show/hide, Save, Test, Refresh), Refresh Info, toggles | Done |
| Overview: field + lab scores with the "LocalEyes can do this / partial / manual / theme" badges | Done |
| TTFB: dedicated field-vs-lab analysis + prioritized actions | Done |
| Speed tab: PSI scan (homepage / whole site) with live progress | Done |
| Speed auto-fixes (hero, fonts, viewport, preconnect) with preview + Deploy + kill switch | Built (untested) |
| Per-URL optimization exclusions (path prefix, per module) | Built (untested) |
| Plugin replacement engine + duplication detector (never deactivates/deletes) | Built (untested) |
| SEO suite (meta, canonical, robots, redirects, sitemap, robots.txt, llms.txt, schema) + Rank Math import | Built (untested) |
| GSC index hygiene + builder page inventory (manual import or API token) | Built (untested) |
| Thrive redirect helpers | REST only (POST /thrive/redirects); no screen yet |
| LocalEyes-changes snapshot backups (REST only, no screen) | Built (untested) |
| Site Scan: read-only crawler (sitemap + DB diff, redirect chains, noindex, canonicals, schema, builder/theme) with a live feed | Built (untested) |
"Built (untested)" means the module and its dashboard exist and are wired to the REST API, but have not yet been run on a live WordPress. All of it must be verified on staging before production.
As of v2.5.0-dev the static gates and the logic harness both run here: `.\tools\audit.ps1 -Php
` is clean (php -l, PHPStan level 5, duplicate declarations, route/DOM wiring, option read/write) and `.\tools\harness\run.ps1 -Php ` passes fourteen suites. That proves structure and the logic those suites cover; it still does not prove any feature behaves correctly against a real WordPress.Linting is now a gate. Every PHP file must pass php -l before a change is considered done, and
the pure helpers (font MIME/audit, @font-face patching, preconnect head rewriting, config parsing)
have executable tests that run against real Lighthouse data. Structural checks alone are not enough:
they cannot tell a compiling file from a correct one.
Phase 7 additions (built, untested)
| Feature | Where |
|---|---|
| Throttled background queue (Action Scheduler; WP-Cron fallback) | includes/queue.php |
| API rate-limit status (waits, backs off, reports responding/not) | includes/queue.php. The backoff applies to every queued API call; /api-status reports it and the Speed tab now prints it under the scan buttons |
| GSC OAuth 2.0 (Connect + auto token refresh) | includes/seo/oauth.php, GSC tab |
| OAuth setup preflight (asks Google whether the redirect URI is registered, before you connect) | includes/seo/oauth.php, /gsc/oauth/preflight, "Check setup" on the GSC tab |
| Snapshot backups (list/create/restore/download/delete) | includes/backups.php, /snapshots/* REST routes |
| Real plugin reproductions: header/footer code (+analytics), SVG, Classic Editor, smooth scroll, post-types order, author box | includes/replace/features/reproductions.php |
| Post-removal verification (baseline → recheck → restore + reinstall advice) | includes/replace/engine.php, Plugins tab |
| Duplication vs theme/Rank Math/any plugin (exact tag) | includes/duplication.php. The Plugins tab uses /duplication; the per-tag breakdown at /duplication/tags has no screen yet |
| Canonicals: redirected-pages finder + audit + bulk deploy | includes/seo/canonical.php, GSC tab |
| Breadcrumbs (visible trail; BreadcrumbList JSON-LD is offered as a reviewable suggestion, never auto-output) | includes/seo/breadcrumbs.php |
| Table of contents: heading levels, four insert positions, per-post overrides, working anchor jumps | includes/seo/toc.php, includes/seo/toc-meta.php |
Sitemap index + per-type; robots/llms/sitemap conflict detection, with one-click fixes for the safe ones (move a shadowing physical file aside, switch the WP core sitemap off through core's own wp_sitemaps_enabled filter). Both are reversible with Undo. Another plugin's sitemap is only ever reported, never switched off |
includes/seo/sitemap.php |
| Schema builders: FAQ + Article (plus Organization/LocalBusiness). Builders only propose a block for you to review; they never store or emit one | includes/seo/schema.php |
| Pre-uninstall + on-deactivation snapshot; opt-in full purge on uninstall | localeyes-nap.php |
Phase 8 additions (built, untested)
| Feature | Where |
|---|---|
| Per-page SEO + Schema editor in any editor (Gutenberg sidebar, Classic meta box, front-end + Elementor/Thrive/WPBakery drawer) | includes/seo/panel.php, assets/seo-panel.js, assets/seo-panel.css |
| Per-page inspector: identifies current SEO + schema from LocalEyes, Rank Math, Yoast, AIOSEO, SEOPress, plus an optional live-page fetch | includes/seo/inspect.php (/seo/page-inspect) |
| Shared read-only per-post getters behind both the importers and the inspector | le_read_rankmath_meta (meta.php), le_read_yoast/aioseo/seopress_meta + le_all_detected_meta (importers.php), le_read_rankmath_schema (schema.php) |
| Full per-page field parity: twitter description/image, additional keywords, breadcrumb title, max-snippet | meta store + analysis.php + breadcrumbs.php |
The panel writes to the existing LocalEyes SEO store and only renders on the live page once the SEO replacement is Deployed (Plugins tab), so it never double-emits with Rank Math. In page builders (Elementor/Thrive/WPBakery) it appears as a floating "LocalEyes SEO" launcher and drawer, since those editors do not show a WP meta box or Gutenberg sidebar.
Core Web Vitals
The plugin cannot promise your site passes. Hosting, theme, content and third-party scripts all move these numbers and none of them are LocalEyes's to control. What it does is measure honestly, fix specific defects, and make each fix prove itself.
The verdict (includes/cwv.php, Overview and Speed tabs) judges LCP, CLS and INP against the
thresholds in le_cwv_thresholds() and states pass or fail per metric and overall. It is field
first: real CrUX visitor data for the URL, falling back to origin-level field data, and only then
to a lab run. The source is shown next to every number, because a lab pass is not a Core Web Vitals
pass, and INP cannot be measured in a lab at all.
Fixes have to prove themselves. Every scan compares itself against the previous run for the same
URL and strategy and reports the before/after delta for the three metrics and the performance score.
If a fix changed nothing, it says so. POST /speed/verify returns that same comparison for a single
fix and remains available, but no screen calls it: the Speed tab reads the delta that POST /psi/run
already computes, so it costs no extra PageSpeed call.
One scan per refresh. The Speed tab has a single Refresh everything button. It runs PageSpeed once for mobile and desktop and repaints scores, Core Web Vitals, fonts and every issue row from that one scan, with the Live progress card showing each step and offering pause / continue / cancel. There are deliberately no per-row re-scan buttons: each one used to run a full scan of the whole page, so a list of 22 issues offered 22 identical 60-second scans that each spent a PageSpeed quota unit to re-measure the same page.
PageSpeed failures. The timeout is LE_PSI_TIMEOUT (120s, overridable by defining it earlier).
Transient failures - a run that outlasts the timeout, Lighthouse's own "Something went wrong", a
Google 5xx - get exactly one retry. A rate limit and a 403 refusal get none: the first spends quota
that is already exhausted, and the second cannot succeed twice. Errors that survive the retry are
rewritten to name the page and what to check, rather than quoting a cURL socket error at the user.
Fonts and the critical path
Fonts are the usual reason a WordPress page has a long critical request chain: without
font-display, the browser blocks text rendering until the font arrives.
| What | Where |
|---|---|
| font-display on every @font-face (not just Google's) - patches local stylesheets that declare fonts without one. This is what takes fonts off the critical path | includes/perf/font-display.php |
Font audit - every font file with origin, size and format; flags families split across two origins and legacy .ttf/.otf with the woff2 saving. Report only |
includes/perf/font-audit.php |
| Capped font preload - preloads the first fonts the page requests, bounded (default 2). Preloading every font competes with the LCP image and makes the chain worse | includes/autofix.php (fonts_preload) |
Preconnect with the right crossorigin - a preconnect to a font origin without crossorigin opens the wrong kind of connection and the browser discards it. Also fixes tags hard-coded in the theme header, which wp_resource_hints cannot reach |
includes/autofix.php (preconnect) |
LocalEyes cannot convert a .ttf to woff2 - there is no pure-PHP woff2 encoder. It flags the
files and the saving; self-hosting from Google returns woff2, or your theme author can supply it.
Reducing the number of font families is a design decision, so the audit reports and does not act.
Site Scan
A read-only crawler on its own tab. It reports; it never changes anything. Fixes stay on the Redirects, Canonicals, and per-page SEO screens, where each change gets reviewed individually.
Discovery reads the sitemap (auto-detecting LocalEyes', WP core's, or a plugin's, and following
a sitemap index into its children) and enumerates published posts, then diffs the two. The diff is
the point: URLs the sitemap advertises that no longer exist (orphan in sitemap), and published
pages the sitemap never mentions (missing from sitemap). Known redirect sources are folded in too,
so every redirect can be proven to still land somewhere sensible.
Per URL it records the full redirect chain with every hop and status code (with loop detection),
the final HTTP status, the emitted canonical, effective noindex from all three sources (robots meta
tag, X-Robots-Tag response header, and stored intent), every JSON-LD block actually on the
page, the SEO fields, and which builder and theme produced it.
Live feed. Four workers crawl in parallel and each row appears the moment its own fetch returns, not batched at the end. Pause, continue, and cancel work mid-run, and a browser reload offers to resume from where it stopped rather than starting over.
Findings live in wp_localeyes_scan, grouped by scan_id so runs are comparable; the newest three
are kept. Filter chips narrow to any single finding, and the whole run exports as CSV.
The crawler identifies itself with a LocalEyes-Scan user agent, and the 404 log and redirect hit
counters ignore that traffic, so scanning never shows up in your reports as visits the site did not
actually receive.
How schema (structured data) works
Nothing is applied automatically. LocalEyes never generates, adds, or publishes structured data on its own. There is no per-post-type default, no rule that attaches schema to pages you have not opened, and no background write. A page with nothing confirmed on it outputs no JSON-LD at all.
The flow is always the same:
- It pulls what is there. Open a page and the panel lists every schema block it can find: what is already confirmed on the page, what Rank Math holds for it, what the live HTML actually contains, and what LocalEyes could build for you. All of it read-only.
- You review and edit. Each block is separate, with its own JSON editor. Edit any of them, delete any of them, turn any of them off, or add new ones. Inserting from any source only opens the block in an editor; it stores nothing.
- You press Confirm. The block is validated and kept in the editor. It is still not saved.
- You press WordPress's Update button. That is what saves the page.
Per-page schema is stored in the _localeyes_schema post meta key, which is why WordPress's own
Update button owns it (includes/seo/schema-meta.php). Site-wide and per-term blocks live in
wp_localeyes_schema and are edited from the SEO to Schema tab; those screens have no Update button,
so Confirm saves immediately there and the UI says so. The same applies inside Elementor, Thrive, and
WPBakery, which do not save through WordPress's post form.
Staging Rank Math's schema in bulk (SEO to Import) copies each post's blocks onto that post turned off, so nothing is output until you open the page and confirm each one. Re-running it skips blocks that are already present.
Phase 13 additions (AEO personalization, built, untested)
Phase 12 shipped the AEO features with most of their behavior hardcoded. This makes all of it the
site owner's to control, through one settings store (localeyes_aeo, deep-merged over
le_aeo_defaults() in includes/aeo/settings.php, with ordered lists replaced rather than merged so
deleting the third of three sections actually deletes it).
| What is now editable | Where |
|---|---|
llms.txt build: manual or template mode; %pages% %posts% %cpts% %cpt:slug% %contact% %hours% %entity% %faq% %custom% %sitemap% on top of the existing le_seo_replace_vars() engine; per-section post type, heading, count, ordering and descriptions; description source order; custom Markdown sections; URL exclusions; per-page exclude and pin |
includes/aeo/llms.php |
llms-full.txt: the same pages with their text inline, registered through the new le_files_registry filter so it is served and inspected like every other root file |
includes/aeo/llms.php, includes/seo/files.php |
| Accessibility rules: per-rule on/off, per-rule "may a fixer repair this", severity; URL and selector exclusions applied to BOTH the audit and the fixers; the denied-roles and embed-provider maps as editable line lists | includes/aeo/a11y.php, includes/aeo/settings.php |
Per-element overrides: pin the exact aria-label, title or alt for an element by selector, site-wide or per URL, applied before anything the fixers derive and reported back so the two never disagree |
le_aeo_overrides_for(), le_aeo_fix_override() |
| Fixer config: all six modules now expose real fields (icon-class naming, external-link prefix, placeholder vs name, fallback frame title, per-module selector exclusions) instead of four of them having none | includes/aeo/fixes.php |
| WebMCP tools: rename and re-describe the six built-ins and pick which fields they return; the front-end script is descriptor-driven, so adding a tool never touches JavaScript | includes/aeo/tools.php, assets/webmcp.js |
Custom WebMCP tools: read-only tools backed by a post type, a page, a post meta or option, static text, or a shortcode, with a "Try it" preview and a public POST /aeo/tool resolver that only answers for configured tool ids |
includes/aeo/tools.php |
| Contact and hours override: field by field over Local SEO, so a site without Local SEO can still answer | le_aeo_contact(), le_aeo_hours() |
AI crawler rules: per-bot allow/deny for 15 crawlers with allow_all / answer_engines / block_all presets, written through the existing robots_txt filter |
includes/aeo/crawlers.php |
Entity facts (the About block of llms.txt) and FAQ (written once, reused in llms.txt and the get_faq tool) |
includes/aeo/settings.php, includes/aeo/llms.php |
| Audit: max URLs, post types, URL exclusions, daily/weekly schedule via WP-Cron and the throttled queue, regression email, trend across runs, CSV export | includes/aeo/audit.php |
Per-page AEO in the existing Performance panel: agent summary (emitted as a meta tag and returned by get_page_content), exclude from llms.txt, pin, skip the audit, skip the fixers, and per-page element overrides |
includes/perf/overrides.php, assets/perf-panel.js |
New AEO sub-tabs: AI crawlers, Live files (every root endpoint with its HTTP status and who
answered, including missing for a 404, plus a custom-path checker), and Settings.
New routes: /aeo/settings (GET/POST, with reset), /aeo/maps, /aeo/llms/tokens,
/aeo/tools/config, /aeo/tools/preview, /aeo/tool (public), /aeo/crawlers, /aeo/trend,
/aeo/export. /aeo/llms now takes ?file=full.
Six new post meta keys (_localeyes_aeo_exclude_llms, _localeyes_aeo_llms_pin,
_localeyes_aeo_skip_fixes, _localeyes_aeo_skip_audit, _localeyes_aeo_summary,
_localeyes_aeo_overrides), all purged on opt-in uninstall.
Selector matcher gotcha, fixed and tested: attribute selectors are consumed before id and class,
because a[href*="youtu.be"] otherwise reads .be as a class and never matches. An unparseable
selector matches nothing rather than guessing.
Phase 12 additions (AEO tab, agentic browsing, built, untested)
Lighthouse 13.3 added an Agentic Browsing category and PageSpeed Insights inherited it. It reports a
pass ratio, not a score, and covers four things: llms.txt, WebMCP, an agent-usable accessibility tree,
and CLS. The new AEO tab addresses all four, with one honest limit: LocalEyes does not build an
accessibility tree. A real tree needs a browser engine to compute roles and accessible names, and
there is none here. What it does is apply the subset of the rules that tree is judged by to the HTML
the server returns — so anything rendered by JavaScript is outside its reach. llms.txt moved here out
of SEO > Tools.
| Feature | Where |
|---|---|
Agent-accessibility rule engine: link-name, button-name, image-alt, input-image-alt, label, le-aria-role, aria-valid-attr-value, frame-title, document-title, html-has-lang. Regex over the served HTML, not a DOM. le-aria-role is named that way deliberately: it is a hand-written deny-list for nine tags, not axe's full aria-allowed-role matrix, and borrowing that id would claim a completeness it does not have. Each failure carries the failing element and a derived, concrete suggested fix |
includes/aeo/a11y.php |
| Site-wide audit: crawls every URL and applies those rules plus WebMCP form coverage, with the four-worker live feed, pause/continue/cancel and resume | includes/aeo/audit.php, table wp_localeyes_agent |
Lighthouse verdict: probes for category=AGENTIC_BROWSING once and caches the answer; falls back to the agent-relevant accessibility audits plus CLS that PSI already returns |
le_aeo_psi_verdict() |
llms.txt: generator that includes descriptions, CPTs, posts and NAP; live validation of the three Lighthouse checks (H1, Markdown links, length) plus two of our own for the ways a file loses its H1 (a BOM, a --- above the first H2); a report naming the entries whose description fell back to raw page text; detects another plugin answering /llms.txt. Note Google Search does not read this file and has said so — it is published for the answer engines that do, and for the Agentic Browsing score |
includes/aeo/llms.php |
WebMCP declarative forms: detects every form, stores per-form config keyed by a builder-stable signature, and injects toolname / tooldescription / toolparamdescription on output |
includes/aeo/webmcp.php, includes/aeo/fixes.php |
WebMCP site tools: search_site, get_contact_info, get_business_hours, list_pages, get_page_content registered through document.modelContext ?? navigator.modelContext. That API is an origin trial, so on a browser that does not expose it the script no-ops and nothing is registered — which the server cannot detect, so AEO > What is working reports this row as unverifiable rather than as a pass. There is no MCP server, no JSON-RPC transport and no /.well-known discovery here: an agent that does not execute the page's JavaScript sees only the annotated form attributes |
assets/webmcp.js, route /aeo/search |
Opt-in fixers: agent_link_names, agent_aria_roles, agent_form_labels, agent_frame_titles, webmcp_forms, webmcp_tools, all registered through localeyes_perf_registry so they inherit snapshots, kill switches and per-page overrides |
includes/aeo/fixes.php |
All six fixers are off by default and share ONE output buffer. agent_aria_roles removes only the
invalid role, leaving aria-expanded and aria-controls intact so the accordion keeps working.
New REST routes: /aeo/llms (GET/POST), /aeo/llms/generate, /aeo/llms/validate, /aeo/forms
(GET/POST), /aeo/tools, /aeo/search (public), /aeo/discover, /aeo/url, /aeo/latest,
/aeo/summary, /aeo/results, /aeo/check, /aeo/psi. The old /seo/llms* routes are gone.
le_is_own_crawler() now matches the whole LocalEyes- user-agent prefix rather than only
LocalEyes-Scan, so the AEO audit and the live-page inspector stop counting as real traffic in the 404
log and redirect hit counters.
Phase 11 additions (per-post performance panel, built, untested)
The WP Rocket per-post options box, in the right-hand column of every editor, plus four things Rocket's
box does not have. One renderer (assets/perf-panel.js) mounted three ways by includes/perf/panel.php:
a block-editor PluginDocumentSettingPanel, a Classic side meta box, and a floating drawer inside
Elementor / Thrive / WPBakery. Everything saves immediately over REST, independent of the post save.
| Feature | Where |
|---|---|
Never cache this page (also raises DONOTCACHEPAGE for third-party caches) |
perf/overrides.php, perf/page-cache.php |
| Per-module checkboxes matching Rocket option for option; a module that is off site-wide renders greyed and disabled | perf/overrides.php (le_perf_panel_labels, le_perf_not_page_scoped) |
| Cache status + purge/warm this page (extra) | perf/page-cache.php (le_page_cache_url_status), routes perf/page/purge, perf/page/warm |
| Page-specific Critical Path CSS generated from this page's own permalink, with revert (extra) | perf/critical-css.php (le_critical_build, le_critical_generate_page) |
| Page LCP hero image + skip lazy-load for the first N images (extra) | includes/autofix.php (le_speed_hero_url, le_speed_lazy_skip_take) |
| Test this page with PageSpeed inline (extra) | assets/perf-panel.js on the existing psi/run route |
New REST routes (all localeyes/v1, all le_rest_perm + a per-post edit_post check):
GET|POST /perf/overrides (extended payload), POST /perf/page/purge, POST /perf/page/warm,
POST /perf/critical/page, POST /perf/critical/page/revert.
New post meta, all unregistered and underscore-prefixed, all purged on opt-in uninstall:
_localeyes_perf_never_cache, _localeyes_perf_hero, _localeyes_perf_lazy_skip,
_localeyes_perf_critical_css (alongside the existing _localeyes_perf_exclude).
The duplicate per-page performance checkbox list was removed from the SEO panel, so per-page performance now lives in exactly one place.
Phase 10 additions (WP Rocket parity, built, untested)
Completes the performance suite (Performance tab, includes/perf/) toward WP Rocket parity.
| Feature | Where |
|---|---|
| Add missing image dimensions (width/height, same-origin, cached) | includes/perf/image-dimensions.php |
| Preload links (instant.page hover/touch prefetch) | includes/perf/preload-links.php |
| Disable WordPress emojis | includes/perf/disable-emojis.php |
| Minify: Excluded CSS/JS files | includes/perf/minify-assets.php (exclude fields) |
| LazyLoad CSS background images + YouTube/bg toggles | includes/perf/lazy-media.php |
| Delay JS Safe Mode (never delay internal scripts) + exclude | includes/perf/delay-js.php |
| Defer JS user exclusion list | autofix.php + registry.php (field bridged to the perf UI) |
| Cache lifespan (hourly expiry cron) + auto-preload after purge + preload exclude | includes/perf/page-cache.php |
| Never-cache cookies / user agents / URLs + query-string whitelist (honored pre-WP via a drop-in sidecar config) | includes/perf/page-cache.php |
| DB: All transients + Optimize tables | includes/perf/dbclean.php |
| Clear used CSS action | includes/perf/unused-css.php |
All modules render generically on the Performance screen (config fields + Deploy/Save/Revert + action buttons), gated on their toggle, snapshotted on deploy, and paused while real WP Rocket is active.
Phase 9 additions (Rank Math parity gaps + live compare, built, untested)
Closes the audited gaps to Rank Math (free + pro) and adds a live-vs-LocalEyes compare with adopt.
| Feature | Where |
|---|---|
| Live compare + adopt per-page (per-field Adopt + Adopt-all + schema block Insert). Every one of these only fills the editor; you still Confirm and Save | includes/seo/inspect.php, assets/seo-panel.js |
| Live compare (site-wide) live tags vs settings, adopt into settings | includes/seo/compare.php, SEO to Live compare tab |
| Per-term (category/tag) SEO editor (term edit screen + front-end admin bar) | includes/seo/panel.php |
| Bulk edit titles/descriptions/robots across posts (merge-patch, never wipes) | includes/seo/bulk.php, SEO to Bulk edit tab |
| SERP + Facebook/Twitter snippet previews (live) | assets/seo-panel.js |
Primary category + pillar content; wired into %category% + breadcrumbs + link suggestions |
meta.php/settings.php/breadcrumbs.php/links.php |
| External-link options (nofollow / new tab / nofollow image links), global + per-post | includes/seo/linkopts.php |
| Schema presets (offered as suggestions on matching pages, never auto-attached) + import schema from a URL | includes/seo/templates.php |
| Export / import all SEO settings (secrets excluded) | includes/seo/exporter.php |
| Role Manager (which capability may use LocalEyes) | includes/seo/roles.php, helpers.php |
| Link Counter + Quick Edit columns in the posts list | includes/seo/listtable.php |
| RSS feed injection (before/after each item) | includes/seo/rss.php |
| .htaccess redirect sync (non-regex 301/302/307) | includes/seo/redirects.php |
| Automatic internal linking (keyword map) | includes/seo/links.php |
| Social-image watermark (GD) | includes/seo/watermark.php |
Google Trends link-out for the focus keyword; max-video-preview, [localeyes_map], Local hours UI |
panel + local.php |
Fixed defects: regex redirect normalization; Google Instant Indexing now uses the GSC OAuth token
(scope added); breadcrumb home-label/separator/prefix UI; noindex.empty_archive honored.
Migration flow (why the compare exists): all new front-end output stays behind the deploy gate,
so before Deploy the live-compare shows Rank Math's tags and you Adopt them into LocalEyes; then
Deploy and remove Rank Math. New DB table: wp_localeyes_schema_templates (created on version bump).
Action Scheduler note: the queue uses Action Scheduler when some other plugin on the site already provides it; otherwise it falls back to per-tick WP-Cron events (works, but fires on traffic). Bundling Action Scheduler with the plugin is recommended for reliable low-traffic processing.
Still to verify on staging: OAuth redirect round-trip, loopback fetch (needed by canonical/parity/post-removal), and that no screen fatals. The two-level sub-tab reorg is partial: new panels were added to existing screens (SEO, GSC, Plugins) rather than a full hash-routed sub-tab bar.
Phase 11 additions (retiring Rank Math and WP Rocket, built, untested)
Aimed at one question: can the other two plugins be deleted without losing anything.
| Feature | Where |
|---|---|
| Rank Math redirections importer. Its redirects live in its own table, so they were the one data set that died with the plugin. Handles all five match modes, expands a row's multiple sources into one rule each, and reports every rule it could not reproduce instead of dropping it | includes/seo/importers.php (le_seo_import_rankmath_redirects), POST /seo/import-rankmath-redirects |
| Migration reconciliation. Counts what Rank Math holds against what LocalEyes holds for per-post meta, schema blocks and redirects. Database only, so it still works on a host that blocks loopback | includes/seo/rankmath-ready.php (le_rm_audit), GET /seo/rankmath-audit |
Rank Math readiness checklist, the SEO-side twin of the WP Rocket one. Per area: covered and verified, or the reason it is not. ready only when every core area passes |
includes/seo/rankmath-ready.php (le_rm_readiness), GET /seo/rankmath-readiness |
| Live verification, the only part that fetches: samples real pages and compares emitted title/description/canonical against the LocalEyes store, probes imported redirects for the status they claim, and fetches the sitemap | le_rm_probe_run, POST /seo/rankmath-verify |
WP Rocket reclassified. It was listed as keep / "never replaced" while perf/wp-rocket.php was a full checklist for retiring it, so the Plugins tab and the Performance tab contradicted each other |
includes/inventory.php |
Two properties worth keeping:
Unknown is never counted as covered. Every area reports what was measured. "Not verified yet" is reported as not working, because a checklist that treats unknown as covered is how the Rocket screen once invited someone to delete a paid plugin on nginx.
The reporting path makes no HTTP requests. le_rm_audit, le_rm_verify and le_rm_readiness read
a stored probe result; only le_rm_probe_run fetches, behind its own route. checkHotPathHttp in
tools/contractcheck.js pins all three, and that guard was negative-controlled: moving a
wp_remote_get into le_rm_verify makes it fail at the exact line.
Staged schema is not covered schema. le_schema_import_rankmath() stages every block disabled
by design (the review-first contract in schema.php). A block that is staged emits nothing, so the
checklist treats "imported but all still unconfirmed" as a blocker rather than a pass. Confirming
blocks in bulk is still an open design question, because per-block review is the contract.
WP Rocket is judged by the checklist, never by a page diff. le_feature_hosts('perf') hosts no
segments: a cached page and an uncached one are byte-identical by design, so there is nothing for the
segment inventory or the baseline/recheck diff to see. The baseline route now refuses to store an
empty signature, so a zero-segment feature cannot produce a vacuous "nothing was lost".
Not built, and not losses this covers: Rank Math's Content AI and its Analytics history; WP Rocket's
Cloudflare/Varnish/Sucuri purge integrations, OPcache purge, and separate cache for logged-in users.
On nginx, compression and browser cache headers still cannot verify at all, because they are
.htaccess rules, so le_rocket_readiness() cannot report ready there.
Prerequisites
Enable the PageSpeed Insights API on your GCP project and paste the key in Configure. Add your GSC property + key and an admin Application Password there too. Testing happens on staging.
Files in this folder
| File | What it is |
|---|---|
localeyes-nap.php |
Loader + the original NAP engine (Search & Replace). |
includes/ |
v2 modules: bootstrap, helpers, db, jobs, psi, overview, ttfb, admin-ui, and (as they land) autofix, inventory, duplication, replace/, seo/ (including seo/schema-meta.php, the per-page schema post-meta store), backups. |
assets/ |
Enqueued front-end/editor assets: seo-panel.js + seo-panel.css (the per-page SEO/schema panel). Must be included in the zip. |
localeyes-plugin-<version>.zip |
Upload-ready package (contains localeyes-plugin/ with the loader + includes/). Generated by build.ps1; never hand-assembled. The folder inside is always localeyes-plugin/, whatever the file is called. |
build.ps1 |
Sets the version, packages the zip, and verifies it before shipping. The only supported way to build. |
README.md |
This document. |
If you edit any source file, run
.\build.ps1 -Version <x.y.z>before uploading (see Rebuilding the zip), otherwise WordPress installs the stale copy.
Note on the NAP defaults
The old hardcoded Austin/Chicago/Dallas find/replace pairs were removed in v2.0. Search & Replace now
operates on current live content and loads/saves whatever pairs you enter (the localeyes_pairs
option). "Load defaults" restores your saved set, not any baked-in address.
Why this exists
When a Google Business Profile address changes (for example after a suspension and move to a new Regus/office address), the old NAP is usually scattered across dozens of places on the site: page body, Elementor Theme Builder headers/footers, Thrive Architect content, Rank Math schema, and widget/theme-option text. A plain search-and-replace plugin misses the builder data and the schema, which is exactly where AI answer engines and Google read the address from. This tool covers all of those surfaces in one pass and lets you roll back if anything looks wrong.
This is the practical companion to the suspended-profile reinstatement work (Austin / Chicago / Dallas). No addresses ship with the plugin: you enter the pairs for the move you are doing and save them, and Search & Replace reloads them next time (see Saved find/replace pairs below).
What it scans and edits (coverage)
| Surface | Where it lives | Routing token |
|---|---|---|
| Classic page/post body | post_content |
classic |
| WPBakery body | post_content (detected by [vc_ / _wpb_vc_js_status) |
bakery |
| Elementor (incl. header/footer Theme Builder templates) | _elementor_data post meta |
elementor |
| Thrive Architect | tve_* post meta |
thrive |
| Rank Math schema (per page) | rank_math_* post meta |
schema |
| Widgets + theme options (site-wide) | widget_*, theme_mods_* options |
widgets |
| SEO plugin settings (site-wide) | rank_math*, wpseo* options |
schema_opts |
Builder is auto-detected per page (elementor | thrive | bakery | classic). Nested/serialized
option values are walked recursively so replacements preserve structure. Site-wide option groups
(widgets, schema_opts) are only scanned when you are not scoped to specific pages.
Installing
- In WordPress admin: Plugins to Add New to Upload Plugin.
- Choose
localeyes-plugin-<version>.zipand install, then Activate. - Open it from the LocalEyes Plugin item in the left admin menu (map-pin icon).
Requires a user with the edit_pages capability (editors and admins).
Updating
Do not deactivate, and do not delete. Uploading over a live install is the supported update path:
- Build with a bumped version:
.\build.ps1 -Version 2.6.0(see Rebuilding the zip). - Plugins to Add New to Upload Plugin, choose the new zip, Install Now.
- WordPress recognizes the existing copy and offers Replace current with uploaded, showing the current and new version side by side. Confirm it.
Settings, the 17 custom tables, snapshots and every stored module state survive untouched — the
replace swaps files only, and no deactivation hook runs, so the drop-in and .htaccess blocks stay
live throughout. Schema changes land by themselves: le_bootstrap_maybe_upgrade() runs on init
after the files change, gated on LOCALEYES_DB_VER, so the first request after the update applies
them whether it is a front-end hit or an admin one.
Two things make this reliable, and both are worth knowing about:
- If a leftover folder would block the upload,
includes/install-heal.phpclears it during the install. It only ever acts when the uploaded package's own header says LocalEyes. See "Destination folder already exists" below. Update URI: falsein the plugin header keeps this plugin off the wordpress.org update channel. Without it, WP 5.8+ matches an installed plugin against the .org directory by folder slug, and this installs toplugins/localeyes-plugin/— so whoever registered that slug on .org could have their code installed over this plugin on every site running it, silently wherever auto-updates are on.tools/contractcheck.jsfails the build if that header goes missing.
There is no self-update checker: sites are not told a new version exists, and nothing phones home. Updating is always someone uploading a zip.
If you want the site to report which build it is running
GET /localeyes/v1/ping returns LOCALEYES_NAP_VERSION, and the Plugins screen shows the
header version. Both are only as truthful as the version bump, which is why the build now owns it.
Using the admin UI
Four tabs, left to right:
- Wrong NAP - enter the wrong text to find (address, ZIP, phone, anything). Click Load defaults to preload the Austin/Chicago/Dallas pairs. Then Start scan.
- Pages & schema found - dry-run results with a live progress bar, per-page context snippets, and checkboxes to pick which pages / content types to change.
- Fix to apply - type the replacement for each wrong value and apply. Apply to selected pages uses your ticks from step 2; Apply to all matches hits everything found.
- Updated pages - what changed, with the backup ID for rollback.
Nothing is written until you apply in step 3. A replacement with an empty "replace with" value is rejected (the tool never blanks a value).
REST API
Namespace: localeyes/v1. The original NAP routes below require the edit_pages capability; all v2
control-panel routes (everything under seo/*, replace/*, psi/*, gsc/*, snapshots/*, etc.) require
manage_options via le_rest_perm (configurable through the Role Manager).
| Method | Route | Purpose |
|---|---|---|
| GET | /ping |
Health check; reports version + whether Elementor / Rank Math are present. |
| GET | /targets |
List scannable posts (id, title, type) for progress display. |
| POST | /nap/preview |
Dry run. Returns hit count + hits with snippets. No writes. |
| POST | /nap/apply |
Apply replacements, create a backup, flush caches. |
| POST | /nap/rollback |
Restore a backup by backup_id. |
| GET | /backups |
This README is longer than the copy stored here. Read the rest on GitHub →