Phase 1 MVP: drop-in step registry + session + Markdown report

Plugin skeleton, drop-in step registry, option-backed session, single-page
checklist UI, downloadable Markdown report. Steps live as one file each
under includes/steps/ — adding/removing one is a single file change.
Step IDs are stable strings so renaming files preserves session data.

Architecture (hc-5ix.3): WPH_Steps singleton globs includes/steps/*.php,
natsort-orders by filename, requires each file (which returns a WPH_Step
instance), then applies a 'wph_steps' filter so installs can drop steps.

Session (hc-5ix.4): option-backed (per decision — plugin is installed
per-engagement, so DB-resident history would be lost on uninstall).
Single in-progress session per site; finished sessions render a report
that the user downloads/copies.

Recovery bootstrap (hc-5ix.1): detects whether wp-site-recovery is
installed + active, surfaces state on the start panel and in every
active session. Manual install for now; private update channel deferred
to hc-5ix.27.

Smoke-tested on testsite: registry discovery (13 steps in correct order),
start → update_step → progress count → finish → 8KB Markdown report →
discard cycle.
This commit is contained in:
2026-06-11 15:49:29 +01:00
parent d0d4d433d8
commit 8de7cad0de
23 changed files with 1160 additions and 0 deletions

275
includes/admin-page.php Normal file
View File

@@ -0,0 +1,275 @@
<?php
if (!defined('ABSPATH')) exit;
add_action('admin_menu', 'wph_register_menu');
add_action('admin_post_wph_start', 'wph_handle_start');
add_action('admin_post_wph_save_step', 'wph_handle_save_step');
add_action('admin_post_wph_finish', 'wph_handle_finish');
add_action('admin_post_wph_discard', 'wph_handle_discard');
add_action('admin_post_wph_download_report', 'wph_handle_download_report');
add_action('admin_enqueue_scripts', 'wph_enqueue_assets');
function wph_register_menu(): void {
add_management_page(
'Site Healthcheck',
'Site Healthcheck',
'manage_options',
'site-healthcheck',
'wph_render_admin_page'
);
}
function wph_enqueue_assets($hook): void {
if ($hook !== 'tools_page_site-healthcheck') return;
wp_register_style('wph-admin', false);
wp_enqueue_style('wph-admin');
wp_add_inline_style('wph-admin', wph_inline_css());
}
function wph_inline_css(): string {
return '
.wph-card { background:#fff; border:1px solid #c3c4c7; border-radius:6px; padding:1rem 1.25rem; margin-bottom:1rem; }
.wph-card h2 { margin-top:0; }
.wph-step-status { display:inline-block; padding:.1rem .55rem; border-radius:10px; font-size:11px; font-weight:600; text-transform:uppercase; letter-spacing:.04em; }
.wph-status-not_started { background:#f0f0f1; color:#646970; }
.wph-status-done { background:#def7e3; color:#155724; }
.wph-status-skipped { background:#fff3cd; color:#856404; }
.wph-status-blocked { background:#fbeae8; color:#721c24; }
.wph-status-n_a { background:#e2e3e5; color:#41464b; }
.wph-step { padding:1rem 1.25rem; border:1px solid #dcdcde; border-radius:6px; margin-bottom:.75rem; background:#fff; }
.wph-step header { display:flex; justify-content:space-between; align-items:center; gap:1rem; margin-bottom:.5rem; }
.wph-step header h2 { margin:0; font-size:1.1rem; }
.wph-sub-items { margin:.5rem 0 .75rem 1.25rem; padding:0; }
.wph-sub-items li { margin:.15rem 0; }
.wph-watch-outs { background:#fff8e1; border-left:3px solid #f5b800; padding:.4rem .8rem; margin:.5rem 0; font-size:.92em; }
.wph-watch-outs strong { display:block; margin-bottom:.2rem; }
.wph-escalation { background:#fbeae8; border-left:3px solid #c0392b; padding:.4rem .8rem; margin:.5rem 0; font-weight:500; }
.wph-step textarea { width:100%; min-height:60px; }
.wph-progress { font-weight:600; }
.wph-ok { color:#155724; }
.wph-warn { color:#856404; }
.wph-bad { color:#721c24; }
.wph-bootstrap-panel { padding:.6rem 1rem; background:#f6f7f7; border:1px solid #dcdcde; border-radius:6px; margin-bottom:.75rem; }
.wph-bootstrap-panel h3 { margin:0 0 .35rem; font-size:1rem; }
.wph-actions { display:flex; gap:.5rem; align-items:center; margin-top:.4rem; }
';
}
function wph_render_admin_page(): void {
if (!current_user_can('manage_options')) wp_die('Forbidden');
$session = WPH_Session::current();
echo '<div class="wrap">';
echo '<h1>Site Healthcheck</h1>';
if (!$session) {
wph_render_start_panel();
echo '</div>';
return;
}
if ($session->is_finished()) {
wph_render_finished_panel($session);
echo '</div>';
return;
}
wph_render_active_session($session);
echo '</div>';
}
function wph_render_start_panel(): void {
?>
<div class="wph-card">
<h2>Start a healthcheck</h2>
<p>This will create a new in-progress session for <code><?php echo esc_html(get_site_url()); ?></code>. One session per site at a time.</p>
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
<?php wp_nonce_field('wph_start'); ?>
<input type="hidden" name="action" value="wph_start">
<button class="button button-primary">Start new healthcheck</button>
</form>
</div>
<?php
WPH_Recovery_Bootstrap::render_status();
}
function wph_render_active_session(WPH_Session $session): void {
$progress = $session->progress();
$tech = get_userdata($session->technician_id());
?>
<div class="wph-card">
<p>
<strong>Session:</strong> <code><?php echo esc_html($session->id()); ?></code> ·
<strong>Started:</strong> <?php echo esc_html(date('Y-m-d H:i', $session->started_at())); ?> ·
<strong>Technician:</strong> <?php echo esc_html($tech ? $tech->display_name : '#' . $session->technician_id()); ?> ·
<span class="wph-progress"><?php echo (int) $progress['done']; ?> / <?php echo (int) $progress['total']; ?> steps</span>
</p>
<p>WP <code><?php echo esc_html($session->wp_version()); ?></code> · PHP <code><?php echo esc_html($session->php_version()); ?></code> · Site <code><?php echo esc_html($session->site_url()); ?></code></p>
<div class="wph-actions">
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" onsubmit="return confirm('Mark this healthcheck as finished?');" style="display:inline">
<?php wp_nonce_field('wph_finish'); ?>
<input type="hidden" name="action" value="wph_finish">
<button class="button button-primary">Finish &amp; generate report</button>
</form>
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" onsubmit="return confirm('Discard this in-progress session? All notes will be lost.');" style="display:inline">
<?php wp_nonce_field('wph_discard'); ?>
<input type="hidden" name="action" value="wph_discard">
<button class="button button-link-delete">Discard</button>
</form>
</div>
</div>
<?php
WPH_Recovery_Bootstrap::render_status();
foreach (WPH_Steps::instance()->all() as $step) {
wph_render_step_card($session, $step);
}
}
function wph_render_step_card(WPH_Session $session, WPH_Step $step): void {
$state = $session->step_state($step->id());
$status = $state['status'];
$notes = $state['notes'];
?>
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" class="wph-step" id="step-<?php echo esc_attr($step->id()); ?>">
<?php wp_nonce_field('wph_save_step_' . $step->id()); ?>
<input type="hidden" name="action" value="wph_save_step">
<input type="hidden" name="step" value="<?php echo esc_attr($step->id()); ?>">
<header>
<h2><?php echo esc_html($step->title()); ?></h2>
<span class="wph-step-status wph-status-<?php echo esc_attr($status); ?>"><?php echo esc_html(str_replace('_', ' ', $status)); ?></span>
</header>
<?php if ($blurb = $step->blurb()): ?>
<p><?php echo esc_html($blurb); ?></p>
<?php endif; ?>
<?php if ($items = $step->sub_items()): ?>
<ul class="wph-sub-items">
<?php foreach ($items as $item): ?>
<li><?php echo esc_html($item); ?></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<?php if ($watch = $step->watch_outs()): ?>
<div class="wph-watch-outs">
<strong>Watch out for:</strong>
<ul style="margin:.2rem 0 0 1rem;">
<?php foreach ($watch as $w): ?>
<li><?php echo esc_html($w); ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<?php if ($status === WPH_Session::STATUS_BLOCKED && ($esc = $step->escalation())): ?>
<div class="wph-escalation"><?php echo esc_html($esc); ?></div>
<?php endif; ?>
<p>
<label>
<strong>Status:</strong>
<select name="status">
<?php foreach (WPH_Session::VALID_STATUSES as $s): ?>
<option value="<?php echo esc_attr($s); ?>" <?php selected($status, $s); ?>><?php echo esc_html(str_replace('_', ' ', $s)); ?></option>
<?php endforeach; ?>
</select>
</label>
</p>
<p>
<label><strong>Notes:</strong></label>
<textarea name="notes" placeholder="What did you check, find, fix, or flag?"><?php echo esc_textarea($notes); ?></textarea>
</p>
<p>
<button class="button">Save step</button>
<?php if ($state['updated_at']): ?>
<span class="description">Last saved <?php echo esc_html(human_time_diff($state['updated_at'], time())); ?> ago</span>
<?php endif; ?>
</p>
</form>
<?php
}
function wph_render_finished_panel(WPH_Session $session): void {
$report = wph_build_markdown_report($session);
?>
<div class="wph-card">
<h2>Healthcheck finished</h2>
<p>Started <?php echo esc_html(date('Y-m-d H:i', $session->started_at())); ?> · Finished <?php echo esc_html(date('Y-m-d H:i', (int) $session->finished_at())); ?></p>
<div class="wph-actions">
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
<?php wp_nonce_field('wph_download_report'); ?>
<input type="hidden" name="action" value="wph_download_report">
<button class="button button-primary">Download Markdown report</button>
</form>
<button class="button" onclick="navigator.clipboard.writeText(document.getElementById('wph-report-md').textContent);this.textContent='Copied!';setTimeout(()=>this.textContent='Copy to clipboard',2000)">Copy to clipboard</button>
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" onsubmit="return confirm('Discard this finished session?');" style="display:inline">
<?php wp_nonce_field('wph_discard'); ?>
<input type="hidden" name="action" value="wph_discard">
<button class="button button-link-delete">Discard &amp; start a new one</button>
</form>
</div>
</div>
<div class="wph-card">
<h2>Report preview</h2>
<pre id="wph-report-md" style="white-space:pre-wrap;background:#f6f7f7;padding:1rem;border-radius:6px;max-height:30em;overflow:auto"><?php echo esc_html($report); ?></pre>
</div>
<?php
}
// --- Handlers ----------------------------------------------------------------
function wph_handle_start(): void {
if (!current_user_can('manage_options')) wp_die('Forbidden');
check_admin_referer('wph_start');
WPH_Session::start(get_current_user_id());
wp_safe_redirect(admin_url('tools.php?page=site-healthcheck'));
exit;
}
function wph_handle_save_step(): void {
if (!current_user_can('manage_options')) wp_die('Forbidden');
$step_id = isset($_POST['step']) ? sanitize_key((string) $_POST['step']) : '';
check_admin_referer('wph_save_step_' . $step_id);
$session = WPH_Session::current();
if (!$session || $session->is_finished()) wp_die('No active session.');
if (!WPH_Steps::instance()->get($step_id)) wp_die('Unknown step.');
$status = isset($_POST['status']) ? sanitize_key((string) $_POST['status']) : WPH_Session::STATUS_NOT_STARTED;
$notes = isset($_POST['notes']) ? wp_unslash((string) $_POST['notes']) : '';
$session->update_step($step_id, $status, $notes);
wp_safe_redirect(admin_url('tools.php?page=site-healthcheck#step-' . rawurlencode($step_id)));
exit;
}
function wph_handle_finish(): void {
if (!current_user_can('manage_options')) wp_die('Forbidden');
check_admin_referer('wph_finish');
$session = WPH_Session::current();
if (!$session) wp_die('No active session.');
$session->finish();
wp_safe_redirect(admin_url('tools.php?page=site-healthcheck'));
exit;
}
function wph_handle_discard(): void {
if (!current_user_can('manage_options')) wp_die('Forbidden');
check_admin_referer('wph_discard');
WPH_Session::discard();
wp_safe_redirect(admin_url('tools.php?page=site-healthcheck'));
exit;
}
function wph_handle_download_report(): void {
if (!current_user_can('manage_options')) wp_die('Forbidden');
check_admin_referer('wph_download_report');
$session = WPH_Session::current();
if (!$session) wp_die('No session.');
$report = wph_build_markdown_report($session);
$host = parse_url(get_site_url(), PHP_URL_HOST) ?: 'site';
$host = preg_replace('/[^a-z0-9.-]/i', '', (string) $host);
$stamp = date('Ymd', $session->started_at() ?: time());
$filename = 'wph-report-' . $host . '-' . $stamp . '.md';
nocache_headers();
header('Content-Type: text/markdown; charset=UTF-8');
header('Content-Disposition: attachment; filename="' . $filename . '"');
echo $report;
exit;
}

View File

@@ -0,0 +1,105 @@
<?php
if (!defined('ABSPATH')) exit;
/**
* Healthcheck session. Option-backed (single in-progress session per site).
*
* Per beads decision hc-5ix.4: option, not CPT. The plugin is installed per
* engagement, so per-site history living in the DB would die on uninstall.
* Reports are exported as Markdown instead — see report.php.
*/
final class WPH_Session {
public const STATUS_NOT_STARTED = 'not_started';
public const STATUS_DONE = 'done';
public const STATUS_SKIPPED = 'skipped';
public const STATUS_BLOCKED = 'blocked';
public const STATUS_NA = 'n_a';
public const VALID_STATUSES = [
self::STATUS_NOT_STARTED,
self::STATUS_DONE,
self::STATUS_SKIPPED,
self::STATUS_BLOCKED,
self::STATUS_NA,
];
private array $data;
private function __construct(array $data) {
$this->data = $data;
}
public static function current(): ?self {
$raw = get_option(WPH_OPT_SESSION);
if (!is_array($raw) || empty($raw['id'])) return null;
return new self($raw);
}
public static function start(int $technician_id): self {
$data = [
'id' => uniqid('wph_', true),
'started_at' => time(),
'finished_at' => null,
'technician_id'=> $technician_id,
'site_url' => get_site_url(),
'wp_version' => get_bloginfo('version'),
'php_version' => PHP_VERSION,
'steps' => [], // keyed by step id → ['status' => ..., 'notes' => ..., 'updated_at' => ...]
];
update_option(WPH_OPT_SESSION, $data, false);
return new self($data);
}
public static function discard(): void {
delete_option(WPH_OPT_SESSION);
}
public function id(): string { return (string) $this->data['id']; }
public function started_at(): int { return (int) $this->data['started_at']; }
public function finished_at(): ?int { return isset($this->data['finished_at']) ? (int) $this->data['finished_at'] : null; }
public function is_finished(): bool { return $this->finished_at() !== null; }
public function technician_id(): int { return (int) $this->data['technician_id']; }
public function site_url(): string { return (string) ($this->data['site_url'] ?? get_site_url()); }
public function wp_version(): string { return (string) ($this->data['wp_version'] ?? ''); }
public function php_version(): string { return (string) ($this->data['php_version'] ?? ''); }
public function data(): array { return $this->data; }
public function step_state(string $step_id): array {
return $this->data['steps'][$step_id] ?? [
'status' => self::STATUS_NOT_STARTED,
'notes' => '',
'updated_at' => null,
];
}
public function update_step(string $step_id, string $status, string $notes): void {
if (!in_array($status, self::VALID_STATUSES, true)) {
$status = self::STATUS_NOT_STARTED;
}
$this->data['steps'][$step_id] = [
'status' => $status,
'notes' => $notes,
'updated_at' => time(),
];
update_option(WPH_OPT_SESSION, $this->data, false);
}
public function finish(): void {
$this->data['finished_at'] = time();
update_option(WPH_OPT_SESSION, $this->data, false);
}
public function progress(): array {
$steps = WPH_Steps::instance()->all();
$total = count($steps);
$done = 0;
foreach ($steps as $step) {
$st = $this->step_state($step->id());
if (in_array($st['status'], [self::STATUS_DONE, self::STATUS_SKIPPED, self::STATUS_NA], true)) {
$done++;
}
}
return ['done' => $done, 'total' => $total];
}
}

View File

@@ -0,0 +1,45 @@
<?php
if (!defined('ABSPATH')) exit;
/**
* Base class for a healthcheck step.
*
* Each step lives in its own file under includes/steps/ with a numeric prefix
* (e.g. 10-backup.php). The file returns an instance of a subclass of WPH_Step.
* Drop a new file in that directory and it shows up; delete a file and it
* disappears. The numeric prefix controls order so re-ordering is a rename.
*
* Stable string IDs (returned by id()) are stored in the session, so renaming
* the file does NOT lose data as long as id() stays the same.
*/
abstract class WPH_Step {
/** Stable identifier (lowercase slug). NEVER change once shipped. */
abstract public function id(): string;
/** Human-readable title shown in the UI and report. */
abstract public function title(): string;
/** Intro paragraph shown under the title (plain text or simple inline HTML). */
public function blurb(): string { return ''; }
/** Sub-items the technician should tick through (plain strings). */
public function sub_items(): array { return []; }
/** "Watch out for" callouts (plain strings). */
public function watch_outs(): array { return []; }
/**
* If this step has a stop/escalate condition (e.g. backup failed → stop),
* return the guidance string. Rendered as a banner when status=blocked.
*/
public function escalation(): ?string { return null; }
/**
* Phase 3 hook — return structured findings (php version, plugin update
* intel, etc.) for the technician to verify. Phase 1 returns nothing.
*
* @return array<string,mixed>
*/
public function autocheck(array $session_state): array { return []; }
}

View File

@@ -0,0 +1,55 @@
<?php
if (!defined('ABSPATH')) exit;
/**
* Step registry. Singleton.
*
* discover() globs a directory for *.php files in sort order and requires each.
* Each file MUST return a WPH_Step instance. The numeric filename prefix
* (00, 10, 20...) controls the visual order.
*
* After discovery, the 'wph_steps' filter lets installation-specific code
* remove/replace steps (return the array keyed by step id).
*/
final class WPH_Steps {
private static ?WPH_Steps $instance = null;
/** @var array<string,WPH_Step> */
private array $steps = [];
private bool $discovered = false;
public static function instance(): WPH_Steps {
return self::$instance ??= new self();
}
public function discover(string $dir): void {
if ($this->discovered) return;
$files = glob(rtrim($dir, '/') . '/*.php') ?: [];
// Natural sort so 100-* comes after 20-* (not after 10-*).
natsort($files);
$files = array_values($files);
foreach ($files as $file) {
$obj = require $file;
if ($obj instanceof WPH_Step) {
$this->steps[$obj->id()] = $obj;
}
}
/**
* Filter the loaded steps. Return an array keyed by step id.
* To drop a step on a particular install: unset($steps['backup']).
*/
$this->steps = apply_filters('wph_steps', $this->steps);
$this->discovered = true;
}
/** @return array<string,WPH_Step> */
public function all(): array {
return $this->steps;
}
public function get(string $id): ?WPH_Step {
return $this->steps[$id] ?? null;
}
}

View File

@@ -0,0 +1,58 @@
<?php
if (!defined('ABSPATH')) exit;
/**
* Step-0 bootstrap: ensure wp-site-recovery is installed + active before the
* stepper runs (bead hc-5ix.1, references decision hc-5ix.26).
*
* Detection is by plugin folder slug, not by main-file path, because the slug
* is stable across releases. If we ever publish a private update channel for
* the recovery plugin, this is also where we'd offer "fetch latest".
*/
final class WPH_Recovery_Bootstrap {
public const RECOVERY_SLUG = 'site-recovery';
public const RECOVERY_MAIN = 'site-recovery/site-recovery.php';
public static function is_installed(): bool {
if (!function_exists('get_plugins')) {
require_once ABSPATH . 'wp-admin/includes/plugin.php';
}
return array_key_exists(self::RECOVERY_MAIN, get_plugins());
}
public static function is_active(): bool {
if (!function_exists('is_plugin_active')) {
require_once ABSPATH . 'wp-admin/includes/plugin.php';
}
return is_plugin_active(self::RECOVERY_MAIN);
}
public static function recovery_admin_url(): string {
return admin_url('tools.php?page=site-recovery');
}
/**
* Render a status panel that the admin page calls from the "Before you start"
* section. Phase-1: report state + link to manage. Phase-3 may add a
* one-click installer from a private URL.
*/
public static function render_status(): void {
$installed = self::is_installed();
$active = $installed && self::is_active();
?>
<div class="wph-bootstrap-panel">
<h3>Recovery plugin status</h3>
<?php if ($active): ?>
<p class="wph-ok">✓ <strong>Site Recovery</strong> is installed and active.
<a href="<?php echo esc_url(self::recovery_admin_url()); ?>">Open recovery settings</a> to copy the URL + password into the client record.</p>
<?php elseif ($installed): ?>
<p class="wph-warn">⚠ <strong>Site Recovery</strong> is installed but inactive. <a href="<?php echo esc_url(admin_url('plugins.php')); ?>">Activate it</a> before starting work.</p>
<?php else: ?>
<p class="wph-bad">✗ <strong>Site Recovery</strong> is not installed. Install it before starting the healthcheck — it's the safety net while we work on the site.</p>
<p class="description">Phase-1: install manually via <em>Plugins → Add New → Upload Plugin</em>. A private update channel for one-click install lands in hc-5ix.27.</p>
<?php endif; ?>
</div>
<?php
}
}

91
includes/report.php Normal file
View File

@@ -0,0 +1,91 @@
<?php
if (!defined('ABSPATH')) exit;
/**
* Build a Markdown report for a healthcheck session.
* Per bead hc-5ix.7: pure Markdown, downloadable. No DB-side report storage
* (plugin may be uninstalled at the end of the engagement).
*/
function wph_build_markdown_report(WPH_Session $session): string {
$tech = get_userdata($session->technician_id());
$tech_name = $tech ? $tech->display_name : '#' . $session->technician_id();
$lines = [];
$lines[] = '# Site Healthcheck — ' . $session->site_url();
$lines[] = '';
$lines[] = '- **Started:** ' . date('Y-m-d H:i', $session->started_at());
if ($session->is_finished()) {
$lines[] = '- **Finished:** ' . date('Y-m-d H:i', (int) $session->finished_at());
$minutes = max(1, (int) round(((int) $session->finished_at() - $session->started_at()) / 60));
$lines[] = '- **Duration:** ~' . $minutes . ' minute(s)';
}
$lines[] = '- **Technician:** ' . $tech_name;
$lines[] = '- **Site:** ' . $session->site_url();
$lines[] = '- **WordPress:** ' . $session->wp_version();
$lines[] = '- **PHP:** ' . $session->php_version();
$lines[] = '- **Session ID:** ' . $session->id();
$lines[] = '';
// Summary table
$lines[] = '## Summary';
$lines[] = '';
$lines[] = '| Step | Status |';
$lines[] = '|---|---|';
foreach (WPH_Steps::instance()->all() as $step) {
$state = $session->step_state($step->id());
$lines[] = '| ' . $step->title() . ' | ' . wph_status_label($state['status']) . ' |';
}
$lines[] = '';
// Per-step detail
$lines[] = '## Detail';
$lines[] = '';
foreach (WPH_Steps::instance()->all() as $step) {
$state = $session->step_state($step->id());
$lines[] = '### ' . $step->title();
$lines[] = '';
$lines[] = '_Status: ' . wph_status_label($state['status']) . '_';
if ($state['updated_at']) {
$lines[] = '_Saved: ' . date('Y-m-d H:i', $state['updated_at']) . '_';
}
if ($blurb = $step->blurb()) {
$lines[] = '';
$lines[] = $blurb;
}
if ($items = $step->sub_items()) {
$lines[] = '';
foreach ($items as $item) {
$lines[] = '- [ ] ' . $item;
}
}
if (!empty($state['notes'])) {
$lines[] = '';
$lines[] = '**Notes:**';
$lines[] = '';
foreach (preg_split('/\R/', (string) $state['notes']) as $nl) {
$lines[] = '> ' . $nl;
}
}
if ($state['status'] === WPH_Session::STATUS_BLOCKED && ($esc = $step->escalation())) {
$lines[] = '';
$lines[] = '> ⚠ **Escalation:** ' . $esc;
}
$lines[] = '';
}
$lines[] = '---';
$lines[] = '_Generated by Site Healthcheck plugin v' . WPH_VERSION . '_';
return implode("\n", $lines) . "\n";
}
function wph_status_label(string $status): string {
switch ($status) {
case WPH_Session::STATUS_DONE: return '✅ Done';
case WPH_Session::STATUS_SKIPPED: return '⏭ Skipped';
case WPH_Session::STATUS_BLOCKED: return '🛑 Blocked';
case WPH_Session::STATUS_NA: return '— N/A';
case WPH_Session::STATUS_NOT_STARTED:
default: return '◻ Not started';
}
}

View File

@@ -0,0 +1,19 @@
<?php
if (!defined('ABSPATH')) exit;
return new class extends WPH_Step {
public function id(): string { return 'before'; }
public function title(): string { return 'Before You Start'; }
public function blurb(): string {
return 'Confirm prerequisites before touching anything.';
}
public function sub_items(): array {
return [
'Confirm you have admin access to the WordPress dashboard and hosting control panel',
'Check the client record for any known issues, recent changes, or flags from the previous healthcheck',
'Note the current WordPress version, PHP version, and active theme before touching anything',
'Install our recovery plugin (Site Recovery) — done via the button below',
'Make a note of the recovery path in case there is a problem',
];
}
};

View File

@@ -0,0 +1,20 @@
<?php
if (!defined('ABSPATH')) exit;
return new class extends WPH_Step {
public function id(): string { return 'backup'; }
public function title(): string { return 'Step 1 — Take a Full Backup'; }
public function blurb(): string {
return 'Before any work takes place, take a complete backup manually. Do not rely on the most recent automated backup.';
}
public function sub_items(): array {
return [
'Back up both the database and all files (wp-content, wp-config.php, .htaccess)',
'Confirm the backup has completed and is accessible/downloadable',
'Note the backup location and timestamp in the client record',
];
}
public function escalation(): ?string {
return 'If the backup fails or cannot be confirmed, stop. Do not proceed until you have a verified backup.';
}
};

View File

@@ -0,0 +1,13 @@
<?php
if (!defined('ABSPATH')) exit;
return new class extends WPH_Step {
public function id(): string { return 'uptime'; }
public function title(): string { return 'Step 10 — Uptime and Availability'; }
public function sub_items(): array {
return [
'Check uptime monitoring logs if available — note any downtime incidents since the last healthcheck and flag to client if significant',
'Confirm the site is resolving correctly on both www and non-www',
];
}
};

View File

@@ -0,0 +1,19 @@
<?php
if (!defined('ABSPATH')) exit;
return new class extends WPH_Step {
public function id(): string { return 'small_fixes'; }
public function title(): string { return 'Step 11 — Small Fixes'; }
public function blurb(): string {
return 'Address any small issues found during the check that fall within the ~15 minute threshold. Anything longer goes on the quote list.';
}
public function sub_items(): array {
return [
'Broken internal links on key pages',
'Missing alt text on homepage images',
'Obvious content errors noticed in passing (broken shortcodes, missing widgets)',
'Reactivating a deactivated-but-needed plugin',
'Clearing accumulated spam comments',
];
}
};

View File

@@ -0,0 +1,16 @@
<?php
if (!defined('ABSPATH')) exit;
return new class extends WPH_Step {
public function id(): string { return 'wrap_up'; }
public function title(): string { return 'Step 12 — Wrap Up and Document'; }
public function sub_items(): array {
return [
'Log everything done in the client record: versions before and after, any issues found, any fixes applied, anything flagged for follow-up',
'Note the date, time taken, and technician',
'If using ManageWP or WP Umbrella, generate the client report and review it before sending — make sure it accurately reflects what was done',
'Send the client report or file it according to your process',
'If anything was flagged that needs a separate quote or client decision, send that communication now rather than leaving it',
];
}
};

View File

@@ -0,0 +1,19 @@
<?php
if (!defined('ABSPATH')) exit;
return new class extends WPH_Step {
public function id(): string { return 'environment'; }
public function title(): string { return 'Step 2 — Environment Check'; }
public function blurb(): string {
return 'Before touching updates, review the hosting environment.';
}
public function sub_items(): array {
return [
'PHP version — check against WordPress requirements and plugin compatibility. Flag if below 8.1. Note if EOL.',
'Disk usage — flag if over 80% used',
'Error logs — check the server error log and WordPress debug log if enabled. Note any recurring errors, 500s, or deprecated function warnings',
'wp-config.php — confirm WP_DEBUG is off on production',
'File permissions — spot check wp-config.php (should be 640 or 600), wp-content (755), uploads (755)',
];
}
};

View File

@@ -0,0 +1,21 @@
<?php
if (!defined('ABSPATH')) exit;
return new class extends WPH_Step {
public function id(): string { return 'core'; }
public function title(): string { return 'Step 3 — WordPress Core Update'; }
public function sub_items(): array {
return [
'Check current version against latest stable release',
'If an update is available, apply it',
'After update, load the site front-end and wp-admin and confirm both are functioning',
'Check the database upgrade prompt — if WordPress prompts to upgrade the database, run it',
'Note the version you updated from and to',
];
}
public function watch_outs(): array {
return [
'White screen of death post-update, admin redirect loops, missing admin menu items — these usually indicate a theme or plugin conflict with the new core version.',
];
}
};

View File

@@ -0,0 +1,25 @@
<?php
if (!defined('ABSPATH')) exit;
return new class extends WPH_Step {
public function id(): string { return 'plugins'; }
public function title(): string { return 'Step 4 — Plugin Updates'; }
public function sub_items(): array {
return [
'Go to Dashboard → Updates and review all pending plugin updates',
'Before updating, note which plugins have updates and what versions they are moving to',
'Update plugins one at a time if the site is complex or has many interdependencies; batch update is acceptable for straightforward sites',
'After each update (or after a batch), check the front-end and any key functional areas (forms, checkout, membership, etc.)',
'Check for any plugins that have been deactivated but not deleted — flag these to the client',
];
}
public function watch_outs(): array {
return [
'WooCommerce updates — always treat these as high-risk, test checkout flow afterwards',
'Page builder updates (Elementor, Divi, Beaver Builder) — can affect layout rendering',
'Security plugin updates — confirm they reactivate and are still reporting clean',
'Plugins that haven\'t been updated by their developer in over 12 months — flag as a risk',
'Plugins showing "Update unavailable" or removed from the WordPress repository — flag immediately, these can indicate abandoned or compromised plugins',
];
}
};

View File

@@ -0,0 +1,20 @@
<?php
if (!defined('ABSPATH')) exit;
return new class extends WPH_Step {
public function id(): string { return 'theme'; }
public function title(): string { return 'Step 5 — Theme Updates'; }
public function sub_items(): array {
return [
'Update the active theme if an update is available',
'If a child theme is in use (correct practice), the parent theme can be updated safely — confirm child theme is active',
'If no child theme is in use and the parent theme has been customised directly, do not update without flagging to the client first — the update will overwrite customisations',
'Update inactive themes only if they are legitimate fallback themes (e.g. a default Twenty* theme). Unused themes that serve no purpose should be flagged for removal',
];
}
public function watch_outs(): array {
return [
'Layout changes post-theme update, broken header/footer, missing custom fonts or colours — indicates customisation was done directly in the parent theme.',
];
}
};

View File

@@ -0,0 +1,23 @@
<?php
if (!defined('ABSPATH')) exit;
return new class extends WPH_Step {
public function id(): string { return 'visual'; }
public function title(): string { return 'Step 6 — Visual and Functional Check'; }
public function blurb(): string {
return 'Do a manual walkthrough of the site.';
}
public function sub_items(): array {
return [
'Homepage — load and visually inspect. Check for broken images, layout issues, console errors (open browser dev tools)',
'Navigation — click through the main menu. Confirm all links resolve correctly, no 404s on primary pages',
'Key pages — About, Contact, Services or equivalent. Check content renders correctly',
'Contact form — submit a test entry and confirm it delivers (check spam folder if no delivery). Note which form plugin is in use',
'If WooCommerce — check shop page loads, a product page loads, add to basket works. Do not need to complete a full test purchase every time unless flagged',
'If membership/login — confirm login page loads and (if test credentials available) login works',
'Mobile view — check the homepage and one internal page on a mobile viewport in browser dev tools',
'HTTPS — confirm the padlock is showing and there are no mixed content warnings',
'Redirects — confirm www/non-www and HTTP/HTTPS are redirecting correctly to the canonical URL',
];
}
};

View File

@@ -0,0 +1,16 @@
<?php
if (!defined('ABSPATH')) exit;
return new class extends WPH_Step {
public function id(): string { return 'performance'; }
public function title(): string { return 'Step 7 — Performance Check'; }
public function sub_items(): array {
return [
'Run a quick PageSpeed Insights check on the homepage',
'Note the scores (mobile and desktop) in the client record',
'Flag if mobile score has dropped significantly since last check (more than 10 points)',
'Check that caching is active — if using a caching plugin, confirm it is enabled and not throwing errors',
'Check image sizes on the homepage — flag if uncompressed images over 500KB are being served',
];
}
};

View File

@@ -0,0 +1,17 @@
<?php
if (!defined('ABSPATH')) exit;
return new class extends WPH_Step {
public function id(): string { return 'security'; }
public function title(): string { return 'Step 8 — Security Check'; }
public function sub_items(): array {
return [
'Confirm the SSL certificate is valid and not expiring within 30 days — flag if so',
'Check the WordPress user list — flag any unfamiliar admin accounts',
'Check for any recently modified core files if you have file change monitoring in place',
'Confirm the login URL is not the default /wp-admin if security hardening was previously applied',
'If a security plugin is active, review its dashboard for any flagged issues',
'Check that xmlrpc.php is disabled or restricted if not in use',
];
}
};

View File

@@ -0,0 +1,14 @@
<?php
if (!defined('ABSPATH')) exit;
return new class extends WPH_Step {
public function id(): string { return 'database'; }
public function title(): string { return 'Step 9 — Database'; }
public function sub_items(): array {
return [
'Run a database optimisation (via WP-CLI: wp db optimize, or via a plugin such as WP-Optimize)',
'Check for and remove any spam comments if comment moderation hasn\'t been keeping up',
'Check post revisions — if excessive (thousands), note for client discussion on whether a revision limit should be set',
];
}
};