"lorem" and/or "email".
*/
public static function placeholders( string $text ): array {
$found = array();
if ( 1 === preg_match( '/\blorem\s+ipsum\b|\bdolor\s+sit\s+amet\b/i', $text ) ) {
$found[] = 'lorem';
}
if ( 1 === preg_match( self::PLACEHOLDER_MAIL, $text ) ) {
$found[] = 'email';
}
return $found;
}
/**
* Whether a post is the sample content WordPress creates on installation.
*
* @param string $type Post type.
* @param string $slug Post slug.
* @param string $title Post title.
*/
public static function is_default_post( string $type, string $slug, string $title ): bool {
return ( 'post' === $type && 'hello-world' === $slug && 'Hello world!' === $title )
|| ( 'page' === $type && 'sample-page' === $slug && 'Sample Page' === $title );
}
/**
* Number of empty paragraphs and headings in a piece of content.
*
* @param string $html Post content.
*/
public static function empty_elements( string $html ): int {
$blank = '(?:\s| |\xC2\xA0|
)*';
$count = (int) preg_match_all( '/
]*>' . $blank . '<\/p>/i', $html );
return $count + (int) preg_match_all( '/]*>' . $blank . '<\/h\1>/i', $html );
}
/**
* Elementor element types that only hold other elements: classic sections, columns and
* containers, and the atomic (V4) div block, flexbox and grid. Any other element type, such
* as atomic tabs or a form, counts as content.
*
* @var string[]
*/
public const ELEMENTOR_CONTAINERS = array( 'section', 'column', 'container', 'e-div-block', 'e-flexbox', 'e-grid' );
/**
* Elementor sections, columns and containers that hold no widget at all. An empty one is
* counted once, without the empty elements inside it.
*
* @param array $elements Decoded `_elementor_data` (a list of elements).
*/
public static function empty_containers( array $elements ): int {
$count = 0;
foreach ( $elements as $element ) {
if ( ! is_array( $element ) || self::is_content( $element ) ) {
continue;
}
if ( ! self::has_widget( $element ) ) {
++$count;
continue;
}
$count += self::empty_containers( is_array( $element['elements'] ?? null ) ? $element['elements'] : array() );
}
return $count;
}
/**
* Whether an Elementor element is a widget or holds one.
*
* @param array $element Element.
*/
private static function has_widget( array $element ): bool {
if ( self::is_content( $element ) ) {
return true;
}
foreach ( is_array( $element['elements'] ?? null ) ? $element['elements'] : array() as $child ) {
if ( is_array( $child ) && self::has_widget( $child ) ) {
return true;
}
}
return false;
}
/**
* Whether an Elementor element is content itself: a widget, or an element type that is not
* a plain container.
*
* @param array $element Element.
*/
public static function is_content( array $element ): bool {
return ! empty( $element['widgetType'] ) || ! in_array( $element['elType'] ?? '', self::ELEMENTOR_CONTAINERS, true );
}
/**
* Whether a post was last changed longer ago than the given number of months.
*
* @param int $modified Unix time of the last change.
* @param int $now Unix time now.
* @param int $months Months.
*/
public static function is_stale( int $modified, int $now, int $months ): bool {
return $modified > 0 && $now - $modified > $months * 30 * 86400;
}
/**
* Dates in the past that sit in a sentence about something still to come ("join us on",
* "sale ends", "register by"). Only full dates are read: 2024-05-12, 12 May 2024 and
* May 12, 2024. Plain statements such as "founded on 3 June 2001" are not touched.
*
* @param string $text Post content.
* @param int $now Unix time now.
* @return array The dates as written, at most five.
*/
public static function past_event_dates( string $text, int $now ): array {
$text = html_entity_decode( wp_strip_all_tags( $text ), ENT_QUOTES, 'UTF-8' );
$found = array();
$month = '(?:January|February|March|April|May|June|July|August|September|October|November|December|Jan|Feb|Mar|Apr|Jun|Jul|Aug|Sep|Sept|Oct|Nov|Dec)\.?';
$date = '(\d{4}-\d{2}-\d{2}|\d{1,2}(?:st|nd|rd|th)?\s+' . $month . ',?\s+\d{4}|' . $month . '\s+\d{1,2}(?:st|nd|rd|th)?,?\s+\d{4})';
$cue = '/\b(?:join us|takes? place|will be held|register(?:ation)?\s+(?:by|until|before)|sign up\s+(?:by|before)|sale ends|offer (?:ends|valid)|valid until|until|deadline|early bird|doors open|starts? on|ends? on|coming soon|save the date|event|tickets?|book (?:now|by|before)|expires?|closes?)\b/i';
foreach ( (array) preg_split( '/(?<=[.!?])\s+|\n+/', $text ) as $sentence ) {
if ( 1 !== preg_match( $cue, (string) $sentence ) || 1 !== preg_match_all( '/' . $date . '/i', (string) $sentence, $m ) ) {
continue;
}
foreach ( $m[1] as $written ) {
$time = strtotime( preg_replace( '/(\d)(?:st|nd|rd|th)/i', '$1', str_replace( 'Sept', 'Sep', $written ) ) . ' 23:59:59 UTC' );
if ( false !== $time && $time < $now - DAY_IN_SECONDS ) {
$found[ $written ] = true;
}
}
}
return array_slice( array_keys( $found ), 0, 5 );
}
/**
* Hashes of the longer paragraphs of a piece of content, for finding text that appears on
* several pages. Short paragraphs are skipped, because short sentences repeat by chance.
*
* @param string $html Post content.
* @return array Hash values without repeats.
*/
public static function paragraph_hashes( string $html ): array {
$out = array();
if ( false === preg_match_all( '/]*>(.*?)<\/p>/is', $html, $m ) ) {
return $out;
}
foreach ( $m[1] as $paragraph ) {
$text = strtolower( trim( (string) preg_replace( '/\s+/', ' ', html_entity_decode( wp_strip_all_tags( $paragraph ), ENT_QUOTES, 'UTF-8' ) ) ) );
if ( mb_strlen( $text ) >= self::DUPLICATE_MIN ) {
$out[ crc32( $text ) ] = crc32( $text );
}
}
return array_values( $out );
}
}