Repository navigation
Continued WPistic licensing and protected update support.
Changelog
All notable changes to SEOistic are documented here. Format loosely follows
Keep a Changelog.
[1.5.2] - 2026-08-12
Added
- WPistic-backed licensing integration and protected update support.
- WPistic activation/validation adapter and entitlement compatibility layer.
scripts/build-release.sh— deterministic release packaging.
Release
- Bump version to 1.5.2 and prepare production release.
Changed
- Unified licensing architecture to use WPistic platform by default.
- Removed legacy GitHub Releases-based updater for licensed installations.
Security
- Default license API endpoint now points to
https://api.wpistic.com.
Release Notes
[1.5.1]
Stabilisation release. No new marketed features — this fixes surfaces that
reported inaccurate results, did not scale, or claimed capabilities the code
did not have.
Fixed
- SEO score correctness (
src/Core/Scorer.php). Four of the ten checks
could not fail, so the score overstated itself on every page:- The SEO title and meta description passed on being non-empty; the stated
10–60 and 50–160 character targets were shown in the message but never
affected the result. Both now gate on length (filterable via
seoistic/score_title_min|maxandseoistic/score_description_min|max). - The H1 check returned
trueunconditionally, including when no H1
existed. It now fails on duplicate H1s — the state the scorer can actually
prove frompost_content— passes on exactly one, and treats "none in
content" as the theme supplying it unless a site opts into the stricter
rule viaseoistic/score_require_content_h1. - The structured-data check returned
trueunconditionally, including when
schema output was suppressed by another SEO plugin (Core\Compat) or the
post's schema type was set to "none". It now reflects what the page will
actually emit. - "Focus keyword in title" passed when no focus keyword was set, so a page
with no keyword scored a point for having it in the title.
- The SEO title and meta description passed on being non-empty; the stated
- Word counting for non-Latin languages (
src/Core/WordCount.php, new).
str_word_count()is single-byte only and returned 0 for Bengali, Hindi,
Arabic, Greek, Cyrillic and every other non-Latin script, silently failing
the content-length check for entire languages regardless of how much text a
page had. Replaced with a Unicode-aware counter; unspaced scripts (Han,
Kana, Hangul) are counted per character. Unit tested. - Dashboard double-counting (
src/Core/DashboardMetrics.php). The noindex
query matched two meta keys that SEOISTIC deliberately keeps in sync, so a
single noindexed post was counted twice and "Indexable Pages" came out too
low. All postmeta joins now useCOUNT(DISTINCT p.ID), and the average
score is computed one-row-per-post. The metric is also renamed
indexable_pages(the old key remains as an alias) and the card now says
plainly that it counts what robots allow, not what Google has indexed. - Scheduled audit never reached past the first 1,000 posts
(src/Core/ScheduledAudit.php). Each run re-scored the same oldest 1,000
published posts. It now keeps a persistent keyset cursor, continues where
the previous run stopped, and wraps around at the end of the site. - Orphan scan was capped and unbounded at the same time
(src/Core/LinkGraph.php). It loaded the content of the first 5,000
published posts into one synchronous request — wrong results above that cap,
and an expensive admin request below it. Replaced with an incremental,
resumable crawl backed by a new{prefix}seoistic_link_edgestable: each
request indexes a bounded batch from a persisted cursor, and orphans become
a single indexedLEFT JOIN.url_to_postid()results are memoised. Edges
left behind by posts that are no longer published are pruned against the
posts table when a pass completes, so unpublishing or deleting a page makes
the pages it linked to become orphans again. - Redirects were loaded in full on every front-end request
(src/Addon/RedirectsModule.php). Every enabled rule was read from the
database and walked in PHP on each page view. Literal sources now resolve
through a single indexed lookup, and regex rules — the only ones that need
iterating — are cached. Also: invalid regex is rejected at save time instead
of warning on every request, self-referential targets are detected rather
than redirecting forever, and the redirect code is validated before use. - 404 log grew without bound and buried real broken links. Missing assets
and the usual automated probe paths are no longer logged, and the log is
pruned daily to a filterable 30-day retention.
Changed
-
External links are no longer blanket-nofollowed
(src/Addon/LinkManagerModule.php). Every outbound link was marked
nofollow, which contradicts Google's guidance that ordinary editorial
links need no qualification and that the qualifiers exist for specific cases
(sponsoredfor paid/affiliate,ugcfor user-generated). External links
now getnoopeneronly; sites wanting a qualifier opt in explicitly through
theseoistic_external_link_reloption or theseoistic/external_link_rel
filter. Arelan author set by hand is merged into, not overwritten. -
Google Indexing API submissions are gated to eligible pages
(src/Indexistic/IndexingEligibility.php, new). Google supports that API
only for pages carrying JobPosting or BroadcastEvent markup and states that
submitting other page types can cost a project its access. The UI previously
said it "works for other page types in practice" and submitted them anyway.
Ineligible URLs are now skipped and logged with a reason,JobPostingand
BroadcastEventare selectable schema types, and sites that emit the markup
from a template can vouch for a URL viaseoistic/google_indexing_eligible.
Bulk-action counts now report what Google accepted, not what was selected. -
"Check Google status" renamed to "Check notification status." The
Indexing API's metadata endpoint reports when Google last received a
submission for a URL — not whether the page is indexed. The button, the
method (get_notification_status()) and the surrounding copy now say so and
point at Search Console's URL Inspection for real index status. -
Multisite: network activation only ever set up one site
(src/Install/Activator.php,src/Install/Tables.php,src/Plugin.php).
WordPress runs the activation hook once for a network-wide activation, in the
context of whichever site the network admin was on, soTables::create()
created tables for that site's prefix alone. Every other site on the network
was left with no SEOISTIC tables at all, and every front-end page view on
those sites raisedTable 'wp_2_seoistic_redirects' doesn't exist— twice
per request, indefinitely, since the recovery path was admin-only and a
subsite can serve traffic for months before anyone opens its dashboard.
Network activation now sets up each existing site,wp_initialize_site
covers sites added later, and the version check runs on front-end requests
too so any site missed on a very large network repairs itself on first
request (guarded by a short lock so a traffic burst can't stampede
dbDelta). Pre-existing; not introduced in this release. -
Translations were loaded before
init(src/License/License.php,
src/Core/ScheduledAudit.php,src/Uptime/UptimeMonitor.php).
License::register()calledwp_schedule_event()at plugin-load time,
which fires thecron_schedulesfilter, whose callbacks translated their
display labels — and WordPress 6.7+ flags any translation beforeinitas
_doing_it_wrong. Every debug-enabled site on 6.7 or later logged a notice
on each admin request. Scheduling is deferred toinit, and both schedule
labels fall back to untranslated strings if another plugin triggers the
filter early. Found by actually booting the plugin on WordPress 7.0.1. -
The updater checked a private repository (
seoistic.php).
SEOISTIC_GITHUB_REPOdefaulted to the development repository, which is
private — the updater calls the GitHub releases API unauthenticated, so it
received 404 and every install silently never saw an update. It now defaults
to the public distribution repository,wordpressistic/seoistic. Sites that
need a different source can still override the constant inwp-config.php
or filterseoistic_github_repo.
Security
- Custom AI knowledge file is restricted to the media library
(src/AI/KnowledgeBase.php). The setting accepted an absolute filesystem
path or an HTTP URL and read it withfile_get_contents(), then sent the
contents to a third-party AI provider — an arbitrary-file-read and an SSRF
primitive against the host's internal network. It now accepts only a
media-library attachment ID or an uploads-relative path, resolves symlinks
before checking containment, allowlists text extensions, and caps the read.
Added
- Database version 1.3.0 → 1.4.0 for the
link_edgestable. Additive and
idempotent (dbDeltaviaInstall\Tables); uninstall, cron cleanup and the
new options are covered. - Unit tests for the Unicode word counter and Indexing API eligibility
detection (16 new tests; suite is now 47). Tested up to: 7.0.1(was 6.4), from a real test run rather than an
assumption. WordPress 7.0.1 was booted on PHP 8.4 with the plugin active,
exercising the scorer, dashboard metrics, the link-graph crawl and its new
table, the scheduled-audit cursor, redirect matching, indexing eligibility,
the knowledge-base path guards, and a front-endwp_headrender with
JSON-LD output — with no deprecations, warnings or notices from plugin
code.Requires at leaststays 6.4. Multisite, WooCommerce and
MySQL-backed runs remain untested and are not claimed; the verification run
used the SQLite database drop-in, so the custom-table DDL was not exercised
against MySQL/MariaDB.
[1.5.0]
Added
- Domain & Uptime Monitor (
src/Uptime/,src/Addon/UptimeMonitorModule.php,
src/Admin/UptimeMonitorPage.php,assets/js/uptime-monitor.js): a free,
self-contained monitor for site/domain uptime, response time and SSL-expiry.UptimeMonitorruns checks in WP-Cron on a 5-minute tick, honouring each
monitor's own interval, capped at 50 monitors per tick. A monitor only
flips to "down" afterCONFIRMATIONS(2) consecutive failures and recovers
to "up" on the first success — the up/down state machine (next_state())
and response evaluation (evaluate()) are pure, WordPress-free, and unit
tested. Cron self-schedules only while at least one monitor exists (zero
overhead otherwise), plus a daily prune of check history.UptimeStoreis the prepared-statement data layer over two new tables,
wp_seoistic_uptime_monitorsandwp_seoistic_uptime_checks.UptimeRestControllerexposesseoistic/v1/uptime/monitors(GET/POST),
.../{id}(PUT/DELETE) and.../{id}/check(POST). Every route enforces a
filterable capability (seoistic/uptime_capability, default
manage_options) in itspermission_callback; every write additionally
verifies thewp_restnonce and returns a preciseWP_Erroron failure.UptimeValidatorclamps intervals/timeouts, normalises method/expected
code, and rejects non-HTTP(S) URLs and the cloud metadata endpoint (SSRF
guard) — also unit tested.- State changes fire the
seoistic/uptime_status_changedaction and send an
email on down and on recovery.
- Database version 1.2.0 → 1.3.0. The migration is additive and idempotent
(dbDeltaviaInstall\Tables); no existing rows or options are touched.
Uninstall (opt-in data removal), deactivation cron cleanup, and multisite
are all covered for the new tables/options/cron hooks.
Fixed
- Business Automator connection: the Automator REST API is served under
/api/v1/, but the client and connection test targeted/api/, so
connection tests could not succeed against a real instance. All
AutomatorClientpaths and the module's test-connection call are corrected
to/api/v1/. The client is now documented against the backend's actual,
code-verified route surface (read-only datastore endpoints), and its
script-management methods are clearly marked as a forward contract not yet
served by the shipping backend — not presented to users as working. - Business Automator cron: the hourly
seoistic_run_automationsevent was
scheduled with no callback (a dead cron). It now performs a real, read-only
connection-health refresh and stores the result, so the settings screen shows
a truthful current connected/unreachable status instead of assuming success
from the last manual test.
[1.4.0]
Added
- Setup wizard (
src/Admin/OnboardingPage.php): a versioned, resumable
first-activation flow — Welcome, Site identity, Search appearance, Import
detection (reusesAddon\MigrationModule::detected_sources()), optional
IndexNow/AI integrations, and Finish (with an opt-in "run a real site
audit" hand-off to the existing dashboard button). Redirects exactly once,
only from a genuine single-site activation (Install\Activatorsets a
short-lived transient; never on network-wide/bulk activation, never on an
upgrade), gated onmanage_optionsand never on AJAX/REST/CLI. Skipped
sites get a dismissible resume notice, not another forced redirect. Every
write is nonce- and capability-checked and sanitized per field, and every
option it writes is the same one the normal Settings screen owns — no
second source of truth. - Duplicate-tag protection (
src/Core/Compat.php): detects active Yoast /
Rank Math / AIOSEO and, in the defaultautomode, suppresses SEOistic's
own output per surface (title, meta, robots.txt, sitemap, schema) so two
SEO plugins don't emit duplicate tags. Configurable in Settings →
Compatibility, with per-surface "force on" overrides; never deactivates the
other plugin. - Self-hosted update checker (
src/Core/Updater.php): hooks
pre_set_site_transient_update_pluginsandplugins_apito offer one-click
updates from the curatedseoistic-{version}.zipGitHub release asset —
never thezipball_urlsource archive. Uses the WordPress HTTP API,
validates the tag/version/asset, caches success and failure, and fails safe
(no asset → treated as "no update"). - Release automation (
.github/workflows/release.yml): builds and
verifies the ZIP viabin/build-release.shon a version tag, attaches the
ZIP + SHA-256 to a draft release with notes from this changelog, and
never publishes. CI (.github/workflows/php.yml) now runs a PHP 8.1/8.2/8.3
matrix withphp -l,node --check, and PHPUnit. - First automated tests:
tests/Unit/PlansTest.phpand
tests/Unit/CompatTest.php(13 tests) with a WordPress-free
tests/bootstrap.php. Module\Entitlement::has_unmapped_product()plus License-screen diagnostics
for an unconfiguredSEOISTIC_LICENSE_PRODUCT_IDand for a valid-but-unmapped
license product, and a License-screen privacy disclosure of the exact
activation payload.
Changed
- WooCommerce Product schema (
src/Addon/WooCommerceModule.php) now emits
image,description, stable@id/url, and (via the
seoistic_woocommerce_product_brandfilter)brand; uses a bounded
AggregateOfferprice range for variable products instead of a single wrong
price; handles grouped/external/backorder correctly; and removes
WooCommerce core's own duplicate Product JSON-LD on product pages. - Pricing model: added the
scaleplan rank; removed the lifetime-deal
cards andPlans::lifetime(); the upgrade screen no longer hardcodes annual
prices (owned by the marketing site); GSC, rank tracking, and AI visibility
are now Pro-eligible; replaced internal marketing copy. - A valid license whose product id isn't mapped to a known plan now resolves
to Free (fail-closed) instead of defaulting to Business. - Editor SEO workspace tabs and the Business Automator page tabs now implement
the full WAI-ARIA tabs pattern (role=tab/tabpanel,aria-selected,
aria-controls, rovingtabindex, arrow/Home/End keyboard navigation). - Breadcrumb shortcode registration now honors the
seoistic_breadcrumbs
option; that option and a compatibility panel are now editable in Settings.
Fixed
- Uninstall (
uninstall.php) now inventories and (only on the documented
seoistic_delete_dataopt-in) removes every plugin option — including the
dynamically-suffixed per-provider AI key options — plus all transients, all
three cron events, all custom tables, and all_seoistic_*post meta, across
both single-site and multisite. Default behavior still preserves customer
data. - Deactivation now also clears the
seoistic_run_automationscron event. - Password-protected posts no longer leak their content/excerpt into the public
meta description or JSON-LD (Core\Meta). - Business Automator's
test-connectionREST endpoint now validates the target
URL scheme/host (blocks non-HTTP(S) and the cloud metadata address) to reduce
SSRF surface; competitor-noindex import uses
unserialize(..., ['allowed_classes' => false]). src/autoload.phpreturns instead ofexit-ing when loaded outside
WordPress, so tooling (Composer, PHPUnit) can require plugin classes.
Migration
- No database schema change (
SEOISTIC_DB_VERSIONunchanged at1.2.0). All
existing options, post meta, encrypted secrets, license state, and REST
contracts are preserved. New options (seoistic_onboarding,
seoistic_compat_mode,seoistic_compat_force_on) are additive with safe
defaults. Rolling back to 1.3.0 leaves those options harmlessly unread.
Full Changelog: v1.4.0...v1.5.2