, [gallery ids], Divi 4 * gallery_ids, core media blocks, Divi 5 "id"/"galleryIds") or only by its file URL. A regex * that fails (PCRE limit) is undecided and counts as a possible usage. No database access. */ final class ContentMediaReferences { private const CORE_MEDIA_BLOCKS = ['image', 'gallery', 'cover', 'media-text', 'file', 'video', 'audio']; /** Block delimiter pattern of WordPress' block parser (WP_Block_Parser::next_token()). */ private const BLOCK_PATTERN = '/).)*+)?}\s+)?\/?-->/s'; /** [gallery ids="…"] and Divi 4 gallery_ids="…" – quotes also JSON-escaped inside Divi 5 blocks. */ private const ID_LIST_PATTERN = '/(?:\[gallery\b[^\]]*?\bids|\bgallery_ids)=(?:"|\'|\\\\u0022)?([\d,\s]+)/i'; /** Content a regex could not check (PCRE limit) counts as a possible usage. */ public static function certainty(string $content, int $attachmentId, ?string $fileRegex): ?string { $references = self::referencesId($content, $attachmentId); if ($references !== false) { return $references === true ? MediaUsageFinder::CERTAIN : MediaUsageFinder::POSSIBLE; } return self::matches($fileRegex, $content) === false ? null : MediaUsageFinder::POSSIBLE; } /** @return bool|null Null when a regex failed (e.g. backtrack limit) – undecided. */ private static function referencesId(string $content, int $attachmentId): ?bool { $wpImage = self::matches('/\bwp-image-' . $attachmentId . '\b/', $content); if ($wpImage !== false) { return $wpImage; } $listCount = preg_match_all(self::ID_LIST_PATTERN, $content, $lists); if ($listCount === false) { return null; } if ($listCount > 0 && self::inList($lists[1], $attachmentId)) { return true; } if (preg_match_all(self::BLOCK_PATTERN, $content, $blocks, PREG_SET_ORDER) === false) { return null; } foreach ($blocks as $block) { $attrs = json_decode(trim((string) ($block['attrs'] ?? '')), true); if (! is_array($attrs)) { continue; } $namespace = rtrim((string) $block['namespace'], '/'); $isCoreMedia = in_array($namespace, ['', 'core'], true) && in_array($block['name'], self::CORE_MEDIA_BLOCKS, true); if ( ($isCoreMedia && self::coreBlockReferences($attrs, $attachmentId)) || ($namespace === 'divi' && self::diviAttrsReference($attrs, $attachmentId)) ) { return true; } } return false; } /** * preg_match() as ?bool: null when the regex failed (backtrack/recursion limit), false * without a pattern. */ public static function matches(?string $pattern, string $subject): ?bool { if ($pattern === null) { return false; } $result = preg_match($pattern, $subject); return $result === false ? null : $result === 1; } /** @param array $attrs */ private static function coreBlockReferences(array $attrs, int $attachmentId): bool { foreach (['id', 'mediaId'] as $key) { if (isset($attrs[$key]) && is_numeric($attrs[$key]) && (int) $attrs[$key] === $attachmentId) { return true; } } $ids = is_array($attrs['ids'] ?? null) ? array_filter($attrs['ids'], 'is_numeric') : []; return in_array($attachmentId, array_map('intval', $ids), true); } /** * Divi 5 image modules store {"src": url, "id": ""}, galleries "galleryIds" as "1,2,3" * or as an array of IDs. * * @param array $attrs */ private static function diviAttrsReference(array $attrs, int $attachmentId): bool { foreach ($attrs as $key => $value) { if ($key === 'id' && (is_int($value) || is_string($value)) && (string) $value === (string) $attachmentId) { return true; } if ($key === 'galleryIds' && self::inList(self::stringLeaves($value), $attachmentId)) { return true; } if (is_array($value) && self::diviAttrsReference($value, $attachmentId)) { return true; } } return false; } /** @return list String and integer leaves, integers as strings. */ public static function stringLeaves(mixed $value): array { if (is_string($value) || is_int($value)) { return [(string) $value]; } $leaves = []; if (is_array($value)) { array_walk_recursive($value, static function ($leaf) use (&$leaves): void { if (is_string($leaf) || is_int($leaf)) { $leaves[] = (string) $leaf; } }); } return $leaves; } /** @param list $lists Comma-separated ID lists. */ public static function inList(array $lists, int $attachmentId): bool { foreach ($lists as $list) { foreach (explode(',', $list) as $item) { $item = trim($item); if ($item !== '' && ctype_digit($item) && (int) $item === $attachmentId) { return true; } } } return false; } /** Original or a generated size of the file, with plain or JSON-escaped slashes. */ public static function fileRegex(string $stem): string { $separator = '(?:/|\\\\/)'; $parts = array_map(static fn (string $part): string => preg_quote($part, '~'), explode('/', $stem)); return '~' . $separator . implode($separator, $parts) . '(?:-\d+x\d+|-scaled|-rotated|-e\d{10,})*\.[a-z0-9]{2,5}(?![a-z0-9])~i'; } }