— which * carries no words at all — and a handful of framing sentences, so nothing * the video actually says can ever be quoted or cited. Set above * MIN_WORDS because a video page clears the thin-content bar on framing * alone while still being unreadable in the only way that matters here. */ const VIDEO_MIN_WORDS = 150; /** Flesch Reading Ease bands: at/above OK reads general-audience; below HARD * is university-level prose. (English-only — the formula fits no other * language, and the check says so instead of mis-grading.) */ const READING_EASE_OK = 50; const READING_EASE_HARD = 30; /** Repetition reads as familiarity: a word of FAMILIAR_MIN_SYLLABLES+ * estimated syllables that the page uses FAMILIAR_MIN_OCCURRENCES+ times is * its own subject vocabulary ("security" on a security page), and the * adjusted score prices each use at FAMILIAR_COUNTED_SYLLABLES — an * ordinary word. Flesch alone has no notion of familiarity and double- * charges a page for naming its topic; word-list formulas (Dale–Chall, * Gunning Fog) grade familiar words gently for the same reason. One-off * long words keep full weight — variety, not repetition, is what actually * reads hard, so thesaurus-prose still grades honestly. */ const FAMILIAR_MIN_SYLLABLES = 4; const FAMILIAR_MIN_OCCURRENCES = 3; const FAMILIAR_COUNTED_SYLLABLES = 2; /** * Run the checks for a post. * * @param \WP_Post $post Post being edited. * @return array> Rows: { id, label, status, detail }. */ public static function analyze( \WP_Post $post ) { $has_excerpt = '' !== trim( (string) $post->post_excerpt ); $measured = Content::markdown_source( $post ); $stats = self::stats( $measured, $has_excerpt, self::home_host() ); // Recency is a post fact, not a content fact — fold it in after the pure parse. $stats['age_days'] = self::age_days( $post ); $stats['evergreen'] = self::is_evergreen( $post ); // The site language is a runtime fact too: the reading-ease formula only // fits English, and the check skips honestly elsewhere. $stats['english'] = 0 === stripos( (string) get_locale(), 'en' ); // So is the featured image — and it's only expected where the content // type and theme actually offer one. $stats['featured'] = has_post_thumbnail( $post ); $stats['featured_expected'] = post_type_supports( $post->post_type, 'thumbnail' ) && current_theme_supports( 'post-thumbnails' ); // The owner's own notes about this page's media. $items = MediaContext::items_for( $post ); $described = 0; $note_text = ''; foreach ( $items as $item ) { if ( '' !== $item['context'] ) { ++$described; $note_text .= ' ' . $item['context']; } } $stats['media_described'] = $described; // Deliberately NOT added to the word count. The notes make a page LEGIBLE — // an agent can tell what its media holds — but they do not make it // SUBSTANTIAL, and a bar calibrated for prose must not be cleared by // captions. Blending the two would let five videos and five one-liners turn // a nearly-empty page green, which is the opposite of what this panel is // for. The media row credits the work; the length row keeps telling the // truth about the page's own words. Two rows, two separate facts, neither // covering for the other. // The media detection above ran on the full the_content render; the notes // are keyed to the block render. Where the two disagree about how many // items exist, trust the one the owner actually saw in the editor. if ( ! empty( $items ) ) { $stats['videos'] = count( array_filter( $items, static function ( $m ) { return 'audio' !== $m['kind']; } ) ); $stats['audios'] = count( array_filter( $items, static function ( $m ) { return 'audio' === $m['kind']; } ) ); } $checks = array( self::check_words( $stats ), self::check_summary( $stats ), self::check_evidence( $stats ), self::check_sources( $stats ), self::check_headings( $stats ), self::check_heading_order( $stats ), self::check_passages( $stats ), self::check_reading_ease( $stats ), self::check_link_density( $stats ), self::check_alt_text( $stats ), self::check_media( $stats ), self::check_featured_image( $stats ), self::check_freshness( $stats ), ); /** * Filter the per-page checks (a Pro add-on can append its own). * * @param array $checks Check rows. * @param array $stats The parsed page stats. * @param \WP_Post $post The post. */ $checks = apply_filters( 'agentimus_page_checks', $checks, $stats, $post ); return self::normalize( $checks ); } /** * The labels of every check a post does NOT pass — the readable "what's off" * list several screens collect from {@see analyze()}. One place so the reading * of a row's pass/label shape is written once. * * @param \WP_Post $post Post to check. * @return string[] Non-pass check labels. */ public static function flags( \WP_Post $post ) { $flags = array(); foreach ( (array) self::analyze( $post ) as $check ) { if ( isset( $check['status'] ) && 'pass' !== $check['status'] ) { $flags[] = (string) $check['label']; } } return $flags; } /** * Parse rendered HTML into the cheap counts the checks read. Pure — pass any * HTML string (and whether the post has a manual excerpt) and it holds. * * @param string $html Rendered content HTML. * @param bool $has_excerpt Whether the post has a manual excerpt. * @param string $home_host The site's own host (lowercased), so links elsewhere * count as outbound (cited) sources. '' = don't classify. * @return array */ public static function stats( $html, $has_excerpt = false, $home_host = '' ) { $html = (string) $html; $text = self::text_of( $html ); $words = self::word_count( $text ); // Concrete, quotable specifics: numbers, percentages, dates, amounts. $figures = (int) preg_match_all( '/\d[\d.,]*%?/', $text ); $headings = array(); $paragraphs = array(); $links = 0; $link_words = 0; $outbound = 0; $images = 0; $images_no_alt = 0; $captions = 0; $transcript = false; $media = array(); $dom = self::dom( $html ); if ( $dom ) { // One detection pass, shared with the editor and the emitters, so the // count in the panel can never disagree with the list beside it. $media = self::media_items_from_dom( $dom ); foreach ( $dom->getElementsByTagName( '*' ) as $node ) { $tag = strtolower( $node->nodeName ); if ( preg_match( '/^h([1-6])$/', $tag, $m ) ) { $headings[] = (int) $m[1]; // "Transcript" as a section heading — the plainest way an author // writes one, and it costs nothing to recognise. if ( self::is_transcript_label( $node->textContent ) ) { $transcript = true; } } elseif ( 'track' === $tag ) { // Captions already attached to a self-hosted video: words that // exist and simply aren't being used yet. $kind = strtolower( trim( (string) $node->getAttribute( 'kind' ) ) ); if ( ( '' === $kind || 'captions' === $kind || 'subtitles' === $kind ) && '' !== trim( (string) $node->getAttribute( 'src' ) ) ) { ++$captions; } } elseif ( 'details' === $tag ) { // A collapsed "Transcript" block — visible to a reader who opens // it, and always present in the text an assistant reads. if ( self::is_transcript_label( self::summary_text( $node ) ) ) { $transcript = true; } } elseif ( 'p' === $tag ) { $paragraphs[] = self::word_count( $node->textContent ); } elseif ( 'a' === $tag ) { ++$links; $link_words += self::word_count( $node->textContent ); if ( self::is_outbound( (string) $node->getAttribute( 'href' ), $home_host ) ) { ++$outbound; } } elseif ( 'img' === $tag ) { ++$images; // A missing `alt` attribute is a real gap; alt="" is the standards-correct // marker for a decorative image (WAI) — intentional, so don't flag it. if ( ! $node->hasAttribute( 'alt' ) ) { ++$images_no_alt; } } } } // Sentence and syllable counts feed the reading-ease grade. Rough by // design — Flesch is a heuristic, not a measurement. It grades PROSE: // code samples aren't sentences and an identifier like // App\Http\Middleware\InputValidator is not a twelve-syllable word, so // code containers are stripped before this pass. (`words` above stays // whole-content — code IS substance on a tutorial page.) $prose_html = (string) preg_replace( '@<(pre|code|kbd|samp)\b[^>]*>.*?@si', ' ', $html ); $prose_text = self::text_of( $prose_html ); $prose_words = self::word_count( $prose_text ); $sentences = self::sentence_count( $prose_html ); $syllables = 0; $long = array(); // long-word frequencies: word => [count, syllables] foreach ( explode( ' ', trim( (string) preg_replace( '/\s+/', ' ', $prose_text ) ) ) as $token ) { $n = self::syllables( $token ); $syllables += $n; if ( $n >= self::FAMILIAR_MIN_SYLLABLES ) { // Keep internal hyphens so terms stay readable when a check names // them ("ai-generated", not "aigenerated"); case and punctuation // variants still merge. $word = trim( (string) preg_replace( '/[^a-z-]+/', '', strtolower( $token ) ), '-' ); if ( ! isset( $long[ $word ] ) ) { $long[ $word ] = array( 0, $n ); } ++$long[ $word ][0]; } } // Split the long words into the page's own recurring terms (priced as // familiar in the adjusted syllable count) and one-off heavy words (full // weight, surfaced so the author knows where the weight sits). $familiar_syllables = $syllables; $familiar_terms = array(); $heavy_words = array(); foreach ( $long as $word => $wf ) { if ( $wf[0] >= self::FAMILIAR_MIN_OCCURRENCES ) { $familiar_terms[ $word ] = $wf[0]; $familiar_syllables -= ( $wf[1] - self::FAMILIAR_COUNTED_SYLLABLES ) * $wf[0]; } else { $heavy_words[ $word ] = $wf[0]; } } arsort( $familiar_terms ); arsort( $heavy_words ); return array( 'words' => $words, 'figures' => $figures, 'headings' => $headings, // heading levels in document order 'paragraphs' => $paragraphs, // per-paragraph word counts 'links' => $links, 'link_words' => $link_words, 'outbound_links' => $outbound, 'images' => $images, 'images_no_alt' => $images_no_alt, 'media' => $media, 'videos' => count( array_filter( $media, static function ( $m ) { return 'audio' !== $m['kind']; } ) ), 'audios' => count( array_filter( $media, static function ( $m ) { return 'audio' === $m['kind']; } ) ), 'video_captions' => $captions, 'has_transcript' => (bool) $transcript, 'has_excerpt' => (bool) $has_excerpt, 'sentences' => $sentences, 'prose_words' => $prose_words, 'syllables' => $syllables, 'familiar_syllables' => $familiar_syllables, 'familiar_terms' => array_slice( $familiar_terms, 0, 5, true ), 'heavy_words' => array_slice( $heavy_words, 0, 5, true ), ); } /* ---------------------------------------------------------------------- * * Individual checks — each returns a { id, label, status, detail } row. * ---------------------------------------------------------------------- */ private static function check_words( array $s ) { $words = (int) $s['words']; if ( $words >= self::MIN_WORDS ) { return self::row( 'words', __( 'Enough substance', 'agentimus' ), 'pass', sprintf( /* translators: %d: word count. */ __( '%d words — enough for an AI assistant to extract and cite.', 'agentimus' ), $words ) ); } return self::row( 'words', __( 'Not enough substance yet', 'agentimus' ), 'warn', sprintf( /* translators: 1: word count, 2: the minimum. */ __( 'Only %1$d words. Below ~%2$d an AI assistant has little to work with — expand the page or merge it.', 'agentimus' ), $words, self::MIN_WORDS ) ); } private static function check_summary( array $s ) { // Thin pages are already flagged; don't pile a summary warning on top. if ( (int) $s['words'] < self::MIN_WORDS ) { return self::row( 'summary', __( 'Opening summary', 'agentimus' ), 'pass', __( 'Not required on a short page.', 'agentimus' ) ); } $lead = ! empty( $s['paragraphs'] ) ? (int) $s['paragraphs'][0] : 0; if ( ! empty( $s['has_excerpt'] ) || $lead >= self::SUMMARY_MIN_WORDS ) { return self::row( 'summary', __( 'Opening summary', 'agentimus' ), 'pass', __( 'Has an excerpt or a solid first paragraph an AI assistant can use as the summary.', 'agentimus' ) ); } return self::row( 'summary', __( 'No opening summary', 'agentimus' ), 'warn', __( 'The page does not start with a clear summary. Add an excerpt, or a first paragraph that says what the page is about.', 'agentimus' ) ); } private static function check_evidence( array $s ) { // Short pages aren't expected to marshal figures or sources. if ( (int) $s['words'] < self::EVIDENCE_MIN_WORDS ) { return self::row( 'evidence', __( 'Backed with specifics', 'agentimus' ), 'pass', __( 'Short enough not to need figures or sources.', 'agentimus' ) ); } $figures = (int) ( isset( $s['figures'] ) ? $s['figures'] : 0 ); $outbound = (int) ( isset( $s['outbound_links'] ) ? $s['outbound_links'] : 0 ); if ( $figures >= 2 || $outbound >= 1 ) { return self::row( 'evidence', __( 'Backed with specifics', 'agentimus' ), 'pass', __( 'Carries figures or cited sources — the kind of specifics an AI assistant can quote and attribute.', 'agentimus' ) ); } return self::row( 'evidence', __( 'Short on specifics', 'agentimus' ), 'warn', __( 'No figures, dates, or outbound sources. AI assistants lift and cite specifics — add a statistic, a concrete detail, or a link to a source you build on.', 'agentimus' ) ); } private static function check_sources( array $s ) { // Sharper than the evidence check: figures can make a page QUOTABLE, but // only outbound references make it read VERIFIABLE — engines favour pages // that show where their facts come from. if ( (int) $s['words'] < self::SOURCES_MIN_WORDS ) { return self::row( 'sources', __( 'Cited sources', 'agentimus' ), 'pass', __( 'Short enough not to need references.', 'agentimus' ) ); } $outbound = (int) ( isset( $s['outbound_links'] ) ? $s['outbound_links'] : 0 ); if ( $outbound >= 1 ) { return self::row( 'sources', __( 'Cited sources', 'agentimus' ), 'pass', sprintf( /* translators: %d: outbound link count. */ __( '%d outbound link(s) — the page shows where its facts come from.', 'agentimus' ), $outbound ) ); } return self::row( 'sources', __( 'No outbound sources', 'agentimus' ), 'warn', __( 'A long page with no links to outside sources gives readers no way to check its facts. AI assistants prefer pages that show their sources — link the material you build on.', 'agentimus' ) ); } private static function check_headings( array $s ) { if ( (int) $s['words'] < self::HEADINGS_MIN_WORDS || ! empty( $s['headings'] ) ) { return self::row( 'headings', __( 'Section headings', 'agentimus' ), 'pass', empty( $s['headings'] ) ? __( 'Short enough to read without headings.', 'agentimus' ) : sprintf( /* translators: %d: heading count. */ __( '%d heading(s) give the page navigable structure.', 'agentimus' ), count( $s['headings'] ) ) ); } return self::row( 'headings', __( 'No headings', 'agentimus' ), 'warn', __( 'A long page with no headings is one big block of text. Add H2/H3 headings so an AI assistant can find and quote each part.', 'agentimus' ) ); } private static function check_heading_order( array $s ) { $prev = 0; foreach ( (array) $s['headings'] as $level ) { if ( $prev && $level > $prev + 1 ) { return self::row( 'heading_order', __( 'Heading order', 'agentimus' ), 'warn', sprintf( /* translators: 1: from level, 2: to level. */ __( 'Heading levels jump (H%1$d → H%2$d). Don’t skip levels — it breaks the outline an AI assistant builds from the page.', 'agentimus' ), (int) $prev, (int) $level ) ); } $prev = (int) $level; } return self::row( 'heading_order', __( 'Heading order', 'agentimus' ), 'pass', __( 'Heading levels nest without skips.', 'agentimus' ) ); } private static function check_passages( array $s ) { if ( (int) $s['words'] < self::MIN_WORDS || empty( $s['paragraphs'] ) ) { return self::row( 'passages', __( 'Quotable passages', 'agentimus' ), 'pass', __( 'Short enough to quote as-is.', 'agentimus' ) ); } $longest = (int) max( (array) $s['paragraphs'] ); if ( $longest > self::LONG_PARAGRAPH_WORDS ) { return self::row( 'passages', __( 'One long block', 'agentimus' ), 'warn', sprintf( /* translators: 1: word count, 2: the threshold. */ __( 'A paragraph runs ~%1$d words. Break blocks over ~%2$d into shorter ones, so an AI assistant can lift a clean, self-contained passage.', 'agentimus' ), $longest, self::LONG_PARAGRAPH_WORDS ) ); } return self::row( 'passages', __( 'Quotable passages', 'agentimus' ), 'pass', __( 'Paragraphs are a quotable length — easy to lift a clean passage from.', 'agentimus' ) ); } private static function check_reading_ease( array $s ) { // Flesch Reading Ease — an ENGLISH formula; on other languages it grades // noise, so the check skips honestly rather than mis-scoring. if ( array_key_exists( 'english', $s ) && empty( $s['english'] ) ) { return self::row( 'reading_ease', __( 'Reading ease', 'agentimus' ), 'pass', __( 'The reading-ease formula only fits English — skipped for this site’s language.', 'agentimus' ) ); } // A page that is mostly code may carry too little PROSE to grade, however // substantial it is — skip honestly rather than score a handful of lines. $gradable = (int) ( isset( $s['prose_words'] ) ? $s['prose_words'] : $s['words'] ); if ( $gradable < self::MIN_WORDS ) { return self::row( 'reading_ease', __( 'Reading ease', 'agentimus' ), 'pass', __( 'Too short to grade.', 'agentimus' ) ); } $score = self::reading_ease( $s ); if ( $score >= self::READING_EASE_OK ) { return self::row( 'reading_ease', __( 'Reading ease', 'agentimus' ), 'pass', sprintf( /* translators: %d: Flesch score. */ __( 'Score %d — plain enough for a general audience, the kind of writing AI assistants quote most.', 'agentimus' ), (int) round( $score ) ) ); } // A technical subject is not hard prose: rescore with the page's own // recurring terms priced as familiar. Clearing the bar HERE means the // writing around the vocabulary is plain — the raw formula was charging // the page for naming its own topic. $familiar = self::reading_ease_familiar( $s ); $terms = isset( $s['familiar_terms'] ) ? array_slice( array_keys( (array) $s['familiar_terms'] ), 0, 3 ) : array(); if ( $familiar >= self::READING_EASE_OK && $terms ) { return self::row( 'reading_ease', __( 'Reading ease', 'agentimus' ), 'pass', sprintf( /* translators: 1: raw Flesch score, 2: adjusted score, 3: the page's recurring terms. */ __( 'Score %1$d — %2$d with the page’s own recurring terms (%3$s) read as familiar. Plain prose about a technical subject.', 'agentimus' ), (int) round( $score ), (int) round( $familiar ), implode( ', ', $terms ) ) ); } $band = $familiar < self::READING_EASE_HARD ? __( 'university-level prose', 'agentimus' ) : __( 'college-level prose', 'agentimus' ); // floor, not round: a 49.5 must never display as "score 50" on a warn row // — the number shown should stay below the pass bar the row failed. $detail = sprintf( /* translators: 1: Flesch score, 2: difficulty band. */ __( 'Reading-ease score %1$d — %2$s. Shorter sentences and plainer words make passages easier for AI assistants to lift and for readers to trust.', 'agentimus' ), (int) floor( $familiar ), $band ); $heavy = isset( $s['heavy_words'] ) ? array_slice( array_keys( (array) $s['heavy_words'] ), 0, 3 ) : array(); if ( $heavy ) { $detail .= ' ' . sprintf( /* translators: %s: the page's heaviest words. */ __( 'Heaviest words here: %s.', 'agentimus' ), implode( ', ', $heavy ) ); } return self::row( 'reading_ease', __( 'Low reading ease', 'agentimus' ), 'warn', $detail ); } /** * PURE: the Flesch Reading Ease score from the parsed counts. * 206.835 − 1.015 × (words/sentences) − 84.6 × (syllables/words). * * @param array $s Stats with words/sentences/syllables. * @return float */ public static function reading_ease( array $s ) { // prose_words matches the code-stripped text the sentence and syllable // counts came from; plain `words` is the whole-content fallback. $words = max( 1, (int) ( isset( $s['prose_words'] ) ? $s['prose_words'] : $s['words'] ) ); $sentences = max( 1, (int) ( isset( $s['sentences'] ) ? $s['sentences'] : 0 ) ); $syllables = max( 1, (int) ( isset( $s['syllables'] ) ? $s['syllables'] : 0 ) ); return 206.835 - 1.015 * ( $words / $sentences ) - 84.6 * ( $syllables / $words ); } /** * PURE: the reading-ease score with the page's recurring long words priced * as familiar (see the FAMILIAR_* constants) — how the prose reads to * someone who accepts the page's subject vocabulary. Falls back to the raw * score when the parse carries no adjusted count. * * @param array $s Stats with words/sentences and familiar_syllables. * @return float */ public static function reading_ease_familiar( array $s ) { if ( isset( $s['familiar_syllables'] ) ) { $s['syllables'] = $s['familiar_syllables']; } return self::reading_ease( $s ); } private static function check_link_density( array $s ) { $words = (int) $s['words']; if ( $words >= 50 ) { $ratio = $s['link_words'] / max( 1, $words ); if ( $ratio > self::LINK_DENSITY_MAX ) { return self::row( 'link_density', __( 'Mostly links', 'agentimus' ), 'warn', sprintf( /* translators: %d: percentage. */ __( 'About %d%% of the words are inside links — this reads more like navigation than content. Add prose or trim the link lists.', 'agentimus' ), (int) round( $ratio * 100 ) ) ); } } return self::row( 'link_density', __( 'Prose vs links', 'agentimus' ), 'pass', __( 'The page is mostly readable prose, not link lists.', 'agentimus' ) ); } private static function check_alt_text( array $s ) { if ( (int) $s['images_no_alt'] > 0 ) { return self::row( 'alt_text', __( 'Image alt text', 'agentimus' ), 'warn', sprintf( /* translators: 1: missing count, 2: total. */ __( '%1$d of %2$d image(s) have no alt text. AI assistants can’t read pixels — describe each image so its meaning survives.', 'agentimus' ), (int) $s['images_no_alt'], (int) $s['images'] ) ); } $detail = (int) $s['images'] > 0 ? __( 'Every image has alt text.', 'agentimus' ) : __( 'No images to describe.', 'agentimus' ); return self::row( 'alt_text', __( 'Image alt text', 'agentimus' ), 'pass', $detail ); } /** * Can an agent tell what this page's media is about? * * Deliberately NOT "is there a transcript?". A transcript is one way to * answer, not the requirement — and it belongs to whatever tool the owner * already uses for it. Any of four things settles this row, and the row says * which one did. * * @param array $s Stats. * @return array */ private static function check_media( array $s ) { $videos = (int) ( isset( $s['videos'] ) ? $s['videos'] : 0 ); $audios = (int) ( isset( $s['audios'] ) ? $s['audios'] : 0 ); $total = $videos + $audios; $label = __( 'Video & audio', 'agentimus' ); if ( $total < 1 ) { return self::row( 'media', $label, 'pass', __( 'No video or audio on this page.', 'agentimus' ) ); } $noun = self::media_noun( $videos, $audios ); // Every item is described — the outcome this row exists to produce. $described = (int) ( isset( $s['media_described'] ) ? $s['media_described'] : 0 ); if ( $described >= $total ) { return self::row( 'media', $label, 'pass', sprintf( /* translators: %s: e.g. "2 videos". */ __( 'Every item has a line of context, so an assistant knows what it holds (%s).', 'agentimus' ), $noun ) ); } // A transcript already published on the page — by any tool — answers it too. if ( ! empty( $s['has_transcript'] ) ) { return self::row( 'media', $label, 'pass', __( 'The page carries a transcript, so what is said here can be quoted.', 'agentimus' ) ); } $words = (int) $s['words']; if ( $words >= self::VIDEO_MIN_WORDS ) { return self::row( 'media', $label, 'pass', sprintf( /* translators: %d: word count. */ __( '%d words of readable text accompany it — the page stands on its own.', 'agentimus' ), $words ) ); } // Nothing answers it. Ask for the cheapest thing that would. $missing = $total - $described; if ( $described > 0 ) { return self::row( 'media', __( 'Media without context', 'agentimus' ), 'warn', sprintf( /* translators: 1: how many items lack a line, 2: e.g. "3 videos". */ _n( '%1$d of %2$s still has no line of context. An assistant reads no sound or picture — describe it so it knows what is there.', '%1$d of %2$s still have no line of context. An assistant reads no sound or picture — describe them so it knows what is there.', $missing, 'agentimus' ), $missing, $noun ) ); } $detail = (int) ( isset( $s['video_captions'] ) ? $s['video_captions'] : 0 ) > 0 // Captions exist but reach nothing: name the shorter next step. ? __( 'A captions file is attached but nothing on the page says what this is about. Add a line of context, or publish those words as a transcript.', 'agentimus' ) : __( 'An assistant reads no sound or picture, so this page tells it almost nothing. Select each one in the editor and say what it is about.', 'agentimus' ); return self::row( 'media', __( 'Media without context', 'agentimus' ), 'warn', sprintf( /* translators: 1: e.g. "3 videos", 2: the advice. */ __( '%1$s and %2$d words. %3$s', 'agentimus' ), ucfirst( $noun ), (int) $s['words'], $detail ) ); } /** * "2 videos", "a podcast and 1 video" — a plain phrase for what a page holds. * * @param int $videos Video count. * @param int $audios Audio count. * @return string */ private static function media_noun( $videos, $audios ) { $parts = array(); if ( $videos > 0 ) { /* translators: %d: number of videos. */ $parts[] = sprintf( _n( '%d video', '%d videos', $videos, 'agentimus' ), $videos ); } if ( $audios > 0 ) { /* translators: %d: number of audio items. */ $parts[] = sprintf( _n( '%d audio item', '%d audio items', $audios, 'agentimus' ), $audios ); } return implode( __( ' and ', 'agentimus' ), $parts ); } /** * Whether an iframe src points at a video player. Hosts only — the path and * query differ per provider and prove nothing. * * Public because {@see Markdown} names the same players in the plain-text * edition and must agree with this check about what counts as one. * * @param string $src The iframe's src attribute. * @return bool */ public static function is_video_embed( $src ) { $host = (string) wp_parse_url( trim( (string) $src ), PHP_URL_HOST ); if ( '' === $host ) { return false; } $host = strtolower( preg_replace( '~^www\.~i', '', $host ) ); foreach ( self::video_hosts() as $known ) { $known = strtolower( ltrim( (string) $known, '.' ) ); if ( '' !== $known && ( $host === $known || substr( $host, -strlen( '.' . $known ) ) === '.' . $known ) ) { return true; } } return false; } /** * The hosts recognised as video players. * * Public and separate from {@see is_video_embed()} because the block-editor * panel needs the same list to recognise a raw