Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
429 changes: 429 additions & 0 deletions HANDOFF-spec-audit.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions classes/class-base.php
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ public function init() {
if ( \defined( 'WP_CLI' ) && \WP_CLI ) {
$this->get_wp_cli__get_stats_command();
$this->get_wp_cli__task_command();
$this->get_wp_cli__audit_command();
}

// Init the enqueue class.
Expand Down
56 changes: 56 additions & 0 deletions classes/suggested-tasks/audit/checks/class-charset-check.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php
/**
* Check that the homepage declares a UTF-8 charset.
*
* @package Progress_Planner
*/

namespace Progress_Planner\Suggested_Tasks\Audit\Checks;

/**
* Charset check.
*/
class Charset_Check implements Check {

/**
* {@inheritDoc}
*
* @return string
*/
public function get_rule_id(): string {
return 'meta-charset';
}

/**
* {@inheritDoc}
*
* @param string $url The audited URL.
* @param string $html The fetched homepage HTML.
* @param array $context Shared fetch context.
*
* @return array<string, mixed>
*/
public function run( string $url, string $html, array $context ): array {
if ( '' === \trim( $html ) ) {
return [];
}

// Pass if declared either via <meta charset> or a Content-Type response header.
$pass = (bool) \preg_match( '/<meta\b[^>]*charset\s*=\s*["\']?\s*utf-?8/i', $html );

if ( ! $pass && isset( $context['headers']['content-type'] ) ) {
$pass = false !== \stripos( (string) $context['headers']['content-type'], 'utf-8' );
}

return [
'rule_id' => $this->get_rule_id(),
'category' => 'foundations',
'title' => \__( 'Declare a UTF-8 charset', 'progress-planner' ),
'description' => \__( 'Add <meta charset="utf-8"> near the top of your <head>. Without an explicit charset, special characters and emoji can render as garbled text.', 'progress-planner' ),
'severity' => 'medium',
'status' => $pass ? 'pass' : 'fail',
'doc_url' => 'https://specification.website/spec/foundations/meta-charset/',
'source' => 'php-check',
];
}
}
41 changes: 41 additions & 0 deletions classes/suggested-tasks/audit/checks/class-check.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php
/**
* Interface for a deterministic (non-AI) spec check.
*
* @package Progress_Planner
*/

namespace Progress_Planner\Suggested_Tasks\Audit\Checks;

/**
* A deterministic check verifies a single specification rule in PHP.
*
* Checks are mechanical — no LLM — so they are fast, free, and unit-testable.
* The registry fetches the homepage HTML once and hands it to every check.
*/
interface Check {

/**
* The stable rule ID this check reports on (e.g. 'html-lang-attribute').
*
* @return string
*/
public function get_rule_id(): string;

/**
* Run the check.
*
* @param string $url The audited URL.
* @param string $html The fetched homepage HTML (may be empty if the fetch failed).
* @param array $context {
* Shared context, so checks don't each make their own request.
*
* @type int $response_code The HTTP status code of the homepage fetch.
* @type string[] $headers Lower-cased response headers from the homepage fetch.
* }
*
* @return array<string, mixed> A single finding (see Audit_Runner schema), with at least
* 'rule_id' and 'status'.
*/
public function run( string $url, string $html, array $context ): array;
}
103 changes: 103 additions & 0 deletions classes/suggested-tasks/audit/checks/class-checks-registry.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<?php
/**
* Registry of deterministic spec checks.
*
* @package Progress_Planner
*/

namespace Progress_Planner\Suggested_Tasks\Audit\Checks;

/**
* Holds the deterministic checks and runs them against a single homepage fetch.
*/
class Checks_Registry {

/**
* Get the registered checks.
*
* @return Check[]
*/
public function get_checks(): array {
$checks = [
new Doctype_Check(),
new Lang_Attribute_Check(),
new Charset_Check(),
new Meta_Description_Check(),
new Sitemap_Check(),
];

/**
* Filter the deterministic spec checks.
*
* @param array $checks The registered checks.
*/
$checks = (array) \apply_filters( 'progress_planner_audit_checks', $checks );

return \array_values(
\array_filter( $checks, static fn( $check ) => $check instanceof Check )
);
}

/**
* Run all checks against a URL and return their findings.
*
* The URL is fetched once; the HTML and response metadata are shared with
* every check.
*
* @param string $url The URL to audit.
*
* @return array<int, array<string, mixed>> Findings (one per check that returns one).
*/
public function run( string $url ): array {
$context = $this->fetch( $url );

$findings = [];
foreach ( $this->get_checks() as $check ) {
$finding = $check->run( $url, $context['html'], $context );
if ( ! empty( $finding ) && ! empty( $finding['rule_id'] ) ) {
$findings[] = $finding;
}
}

return $findings;
}

/**
* Fetch the URL once and return shared context.
*
* @param string $url The URL to fetch.
*
* @return array{html: string, response_code: int, headers: array<string, string>}
*/
protected function fetch( string $url ): array {
$response = \wp_remote_get(
$url,
[
'timeout' => 10,
'user-agent' => 'Progress Planner Spec Audit',
]
);

if ( \is_wp_error( $response ) ) {
return [
'html' => '',
'response_code' => 0,
'headers' => [],
];
}

$raw_headers = \wp_remote_retrieve_headers( $response );
$headers = [];
// wp_remote_retrieve_headers() returns a CaseInsensitiveDictionary; getAll() yields a plain array.
$header_array = \is_object( $raw_headers ) ? $raw_headers->getAll() : (array) $raw_headers;
foreach ( $header_array as $name => $value ) {
$headers[ \strtolower( (string) $name ) ] = \is_array( $value ) ? \implode( ', ', $value ) : (string) $value;
}

return [
'html' => (string) \wp_remote_retrieve_body( $response ),
'response_code' => (int) \wp_remote_retrieve_response_code( $response ),
'headers' => $headers,
];
}
}
53 changes: 53 additions & 0 deletions classes/suggested-tasks/audit/checks/class-doctype-check.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php
/**
* Check that the homepage declares an HTML5 doctype.
*
* @package Progress_Planner
*/

namespace Progress_Planner\Suggested_Tasks\Audit\Checks;

/**
* Doctype check.
*/
class Doctype_Check implements Check {

/**
* {@inheritDoc}
*
* @return string
*/
public function get_rule_id(): string {
return 'doctype';
}

/**
* {@inheritDoc}
*
* @param string $url The audited URL.
* @param string $html The fetched homepage HTML.
* @param array $context Shared fetch context.
*
* @return array<string, mixed>
*/
public function run( string $url, string $html, array $context ): array {
// Can't determine from an empty body — don't emit a false failure.
if ( '' === \trim( $html ) ) {
return [];
}

// Allow an optional UTF-8 BOM and leading whitespace before the doctype.
$pass = (bool) \preg_match( '/^(\xEF\xBB\xBF)?\s*<!doctype\s+html/i', \ltrim( $html ) );

return [
'rule_id' => $this->get_rule_id(),
'category' => 'foundations',
'title' => \__( 'Add an HTML5 doctype to your homepage', 'progress-planner' ),
'description' => \__( 'Every page should start with <!doctype html> so browsers render it in standards mode. Without it, browsers fall back to quirks mode, which can break your layout.', 'progress-planner' ),
'severity' => 'high',
'status' => $pass ? 'pass' : 'fail',
'doc_url' => 'https://specification.website/spec/foundations/doctype/',
'source' => 'php-check',
];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php
/**
* Check that the <html> tag declares a lang attribute.
*
* @package Progress_Planner
*/

namespace Progress_Planner\Suggested_Tasks\Audit\Checks;

/**
* Lang attribute check.
*/
class Lang_Attribute_Check implements Check {

/**
* {@inheritDoc}
*
* @return string
*/
public function get_rule_id(): string {
return 'html-lang';
}

/**
* {@inheritDoc}
*
* @param string $url The audited URL.
* @param string $html The fetched homepage HTML.
* @param array $context Shared fetch context.
*
* @return array<string, mixed>
*/
public function run( string $url, string $html, array $context ): array {
if ( '' === \trim( $html ) ) {
return [];
}

$pass = (bool) \preg_match( '/<html\b[^>]*\blang\s*=\s*["\']?\s*[a-z]{2,3}\b/i', $html );

return [
'rule_id' => $this->get_rule_id(),
'category' => 'foundations',
'title' => \__( "Declare your site's language", 'progress-planner' ),
'description' => \__( 'Add a lang attribute to the <html> tag (for example lang="en"). It helps screen readers pronounce content correctly and search engines serve the right language.', 'progress-planner' ),
'severity' => 'medium',
'status' => $pass ? 'pass' : 'fail',
'doc_url' => 'https://specification.website/spec/foundations/html-lang/',
'source' => 'php-check',
];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php
/**
* Check that the homepage declares a non-empty meta description.
*
* @package Progress_Planner
*/

namespace Progress_Planner\Suggested_Tasks\Audit\Checks;

/**
* Meta description check.
*
* Operates on the already-fetched homepage HTML — makes no extra HTTP requests.
*/
class Meta_Description_Check implements Check {

/**
* {@inheritDoc}
*
* @return string
*/
public function get_rule_id(): string {
return 'meta-description';
}

/**
* {@inheritDoc}
*
* @param string $url The audited URL.
* @param string $html The fetched homepage HTML.
* @param array $context Shared fetch context.
*
* @return array<string, mixed>
*/
public function run( string $url, string $html, array $context ): array {
// Can't determine from an empty body — don't emit a false failure.
if ( '' === \trim( $html ) ) {
return [];
}

$pass = false;

// Look for a <meta name="description" content="..."> with a non-empty
// content attribute. Case-insensitive, tolerates attribute reordering.
if ( \preg_match( '/<meta\b[^>]*\bname\s*=\s*["\']?description["\']?[^>]*\bcontent\s*=\s*"([^"]*)"/i', $html, $m )
|| \preg_match( '/<meta\b[^>]*\bname\s*=\s*["\']?description["\']?[^>]*\bcontent\s*=\s*\'([^\']*)\'/i', $html, $m )
|| \preg_match( '/<meta\b[^>]*\bcontent\s*=\s*"([^"]*)"[^>]*\bname\s*=\s*["\']?description["\']?/i', $html, $m )
|| \preg_match( '/<meta\b[^>]*\bcontent\s*=\s*\'([^\']*)\'[^>]*\bname\s*=\s*["\']?description["\']?/i', $html, $m )
) {
$pass = '' !== \trim( $m[1] );
}

return [
'rule_id' => $this->get_rule_id(),
'category' => 'foundations',
'title' => \__( 'Add a meta description to your homepage', 'progress-planner' ),
'description' => \__( 'A meta description is the snippet search engines show under your page title in search results. Without one, search engines auto-generate a snippet which is often less compelling and less accurate. Install an SEO plugin like Yoast SEO or set a description in your theme to control how your site is summarized.', 'progress-planner' ),
'severity' => 'medium',
'status' => $pass ? 'pass' : 'fail',
'doc_url' => 'https://specification.website/spec/foundations/meta-description/',
'source' => 'php-check',
];
}
}
Loading
Loading