post_content;
$plain = trim(wp_strip_all_tags(strip_shortcodes($content)));
$tags = get_the_terms($post->ID, 'post_tag');
$cats = get_the_terms($post->ID, 'category');
return [
'id' => (int) $post->ID,
'title' => (string) $post->post_title,
'type' => (string) $post->post_type,
'status' => (string) $post->post_status,
'date' => (string) $post->post_date,
'excerpt_set' => trim((string) $post->post_excerpt) !== '',
'tags_count' => is_array($tags) ? count($tags) : 0,
'categories_count' => self::real_categories($cats),
'word_count' => $plain === '' ? 0 : count(preg_split('/\s+/', $plain)),
'has_thumbnail' => has_post_thumbnail($post->ID),
'images_missing_alt' => self::images_missing_alt($content),
'duplicate_title' => isset($duplicate_title_ids[self::title_key((string) $post->post_title)]),
'supports' => [
'tags' => is_object_in_taxonomy($post->post_type, 'post_tag'),
'categories' => is_object_in_taxonomy($post->post_type, 'category'),
'excerpt' => post_type_supports($post->post_type, 'excerpt'),
'thumbnail' => post_type_supports($post->post_type, 'thumbnail'),
],
];
}
/** The default category ("Uncategorized") is where WordPress puts a post nobody filed. */
private static function real_categories($cats) {
if (!is_array($cats)) {
return 0;
}
$default = (int) get_option('default_category', 0);
return count(array_filter($cats, static function ($c) use ($default) { return (int) $c->term_id !== $default; }));
}
/** Bounded, tolerant
alt scan (see docs/TESTLOG.md: PREG_UNMATCHED_AS_NULL). */
public static function images_missing_alt($html, $limit = 200) {
if ($html === '' || !preg_match_all('/
]*>/i', $html, $m)) {
return 0;
}
$missing = 0;
foreach (array_slice($m[0], 0, $limit) as $tag) {
if (preg_match('/\balt\s*=\s*("([^"]*)"|\'([^\']*)\'|([^\s>]+))/i', $tag, $a, PREG_UNMATCHED_AS_NULL)) {
$value = $a[2] ?? $a[3] ?? $a[4] ?? '';
if (trim($value) === '') {
$missing++;
}
} else {
$missing++;
}
}
return $missing;
}
/** One key for "the same title": case-insensitive, and any run of spaces, tabs or no-break spaces counts as one. */
public static function title_key(string $title): string {
return mb_strtolower(trim((string) preg_replace('/[\s\x{00A0}]+/u', ' ', $title)));
}
/** Title-id map for the duplicate-title check (single pass, O(n)). */
public static function duplicate_title_map(array $posts) {
$seen = [];
foreach ($posts as $p) {
$key = self::title_key((string) $p->post_title);
if ($key === '') {
continue;
}
$seen[$key] = ($seen[$key] ?? 0) + 1;
}
$dups = [];
foreach ($seen as $key => $n) {
if ($n > 1) {
$dups[$key] = true;
}
}
return $dups;
}
/**
* Scan one batch (offset-based). Returns the number of posts scanned in this batch;
* 0 means done. The report option is replaced on the first batch and appended after.
*/
public static function scan_batch(array $settings, $offset, $store = null) {
$store = $store ?: new Postlint_Store();
$types = $settings['post_types'];
$batch = max(1, (int) $settings['batch_size']);
$posts = $store->published_posts($types, $batch, $offset);
if (empty($posts)) {
if ($offset > 0) {
$report = self::get_report($store);
$report['complete'] = true;
$store->write_report($report);
}
return 0;
}
// Duplicates are judged against every published title in scope, not just this batch,
// or two posts a batch apart would never meet.
$dup_map = method_exists($store, 'published_titles')
? self::duplicate_title_map(array_map(static function ($t) { return (object) ['post_title' => (string) $t]; }, $store->published_titles($types)))
: self::duplicate_title_map($posts);
$report = $offset === 0 ? self::empty_report() : self::get_report($store);
foreach ($posts as $post) {
$snapshot = self::snapshot($post, $dup_map);
$issues = Postlint_Checks::evaluate($snapshot, $settings);
if ($issues) {
$report['issues'][] = ['id' => $snapshot['id'], 'title' => $snapshot['title'], 'type' => $snapshot['type'], 'checks' => $issues];
}
foreach ($issues as $code) {
$report['totals'][$code] = ($report['totals'][$code] ?? 0) + 1;
}
$store->mark_scanned($post->ID, $store->timestamp());
}
$report['scanned'] = ($report['scanned'] ?? 0) + count($posts);
$report['complete'] = count($posts) < $batch;
$report['scanned_at'] = $store->now();
$store->write_report($report);
return count($posts);
}
public static function get_report($store = null) {
$store = $store ?: new Postlint_Store();
return array_merge(self::empty_report(), $store->read_report());
}
public static function empty_report() {
$totals = array_fill_keys(array_keys(Postlint_Checks::all_checks()), 0);
return ['scanned_at' => '', 'scanned' => 0, 'complete' => false, 'totals' => $totals, 'issues' => []];
}
}