/.
$loaded = $dom->loadHTML(
'' . $innerHtml,
LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
);
libxml_clear_errors();
libxml_use_internal_errors($previous);
if (!$loaded) {
return $innerHtml;
}
$anchors = $dom->getElementsByTagName('a');
if ($anchors->length !== 1) {
return $innerHtml;
}
$anchor = $anchors->item(0);
if (stripos((string) $anchor->getAttribute('href'), 'tel:') !== 0) {
return $innerHtml;
}
$normalized = self::normalizePhoneNumber($anchor->textContent);
if ($normalized === null) {
return $innerHtml;
}
$newHref = 'tel:' . $normalized;
// Rewrite the single anchor's opening tag only — the wrapper and the
// link text stay byte-for-byte intact.
return preg_replace_callback(
'/]*>/i',
static function ($match) use ($newHref) {
return self::setTagAttributes($match[0], [
'href' => $newHref,
'data-id' => $newHref,
'data-type' => 'tel',
]);
},
$innerHtml,
1
);
}
// Reduce visible phone text to bare dialable digits: keep a single leading
// + (international prefix) and drop spaces / dashes / parens / other visual
// separators. Returns null when the result isn't a plausible phone number
// (E.164 caps at 15 digits) so the caller leaves the href untouched rather
// than writing a broken tel: link.
private static function normalizePhoneNumber(string $text)
{
$text = trim($text);
$plus = (strncmp($text, '+', 1) === 0) ? '+' : '';
$digits = (string) preg_replace('/\D+/', '', $text);
$length = strlen($digits);
if ($length < 7 || $length > 15) {
return null;
}
return $plus . $digits;
}
// Set attributes within a single opening-tag string: replace an existing
// attribute's value in place, otherwise inject it before the closing '>'.
private static function setTagAttributes(string $tag, array $attributes): string
{
foreach ($attributes as $name => $value) {
$rendered = ' ' . $name . '="' . esc_attr($value) . '"';
$pattern = '/\s' . preg_quote($name, '/') . '\s*=\s*("[^"]*"|\'[^\']*\'|[^\s>]+)/i';
$tag = preg_match($pattern, $tag)
? preg_replace($pattern, $rendered, $tag, 1)
: preg_replace('/\s*\/?>$/', $rendered . '>', $tag, 1);
}
return $tag;
}
// The REST save request isn't language-scoped the way the page render is, so
// trust the translatedContext the client forwards (detected at enqueue); also
// re-check server-side in case this request is language-scoped on its own.
private static function isTranslatedRender(array $body): bool
{
$clientContext = $body['translatedContext'] ?? null;
if (is_array($clientContext) && !empty($clientContext['isTranslated'])) {
return true;
}
return !empty(TranslatedContext::detect()['isTranslated']);
}
// Render a single block through the same the_content chain a live page uses
// (expanding shortcodes, wptexturize, etc.). In a REST request the main
// query has no post, so wp_reset_postdata() can't restore $GLOBALS['post'] —
// snapshot and restore it so a template-part save can't leave global $post
// dangling for the rest of the request.
private static function renderBlockHtml(array $block, \WP_Post $sourcePost): string
{
$previousPost = $GLOBALS['post'] ?? null;
$GLOBALS['post'] = $sourcePost;
setup_postdata($sourcePost);
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- core WP filter
$html = apply_filters('the_content', serialize_blocks([$block]));
wp_reset_postdata();
$GLOBALS['post'] = $previousPost;
return (string) $html;
}
// Every block of $blockType that carries the fingerprint, with its path.
// The caller only proceeds on a *unique* match — two blocks with the same
// content can't be told apart, so that refuses rather than guesses. Skips
// ignored dynamic loops and nested template-part scopes. Tries the cheap
// raw/fold match across candidates first and only renders (expanding
// shortcodes etc.) if nothing matched raw.
private static function findBlocksByFingerprint(
array $blocks,
string $blockType,
array $fingerprint,
\WP_Post $sourcePost
): array {
$ignored = TagBlocks::$ignored;
$candidates = [];
$walk = function (array $list, array $pathSoFar) use (&$walk, &$candidates, $blockType, $ignored) {
foreach ($list as $i => $block) {
$name = $block['blockName'] ?? '';
if ($name === '') {
if (!empty($block['innerBlocks'])) {
$walk($block['innerBlocks'], array_merge($pathSoFar, [$i, 'innerBlocks']));
}
continue;
}
if ($name === 'core/template-part' || in_array($name, $ignored, true)) {
continue;
}
if ($name === $blockType) {
$candidates[] = ['block' => $block, 'path' => array_merge($pathSoFar, [$i])];
}
if (!empty($block['innerBlocks'])) {
$walk($block['innerBlocks'], array_merge($pathSoFar, [$i, 'innerBlocks']));
}
}
};
$walk($blocks, []);
$raw = array_values(array_filter(
$candidates,
static function ($c) use ($fingerprint) {
return BlockFingerprint::matches($c['block'], $fingerprint);
}
));
if ($raw) {
return $raw;
}
$rendered = array_values(array_filter(
$candidates,
static function ($c) use ($fingerprint, $sourcePost) {
return BlockFingerprint::matches(
$c['block'],
$fingerprint,
self::renderBlockHtml($c['block'], $sourcePost)
);
}
));
if ($rendered) {
return $rendered;
}
// Last resort: a block-level shortcode render (e.g. [products]) splits
// the paragraph in the browser, so the live element's text — and thus
// the fingerprint — is truncated to a prefix of the stored block.
return array_values(array_filter(
$candidates,
static function ($c) use ($fingerprint) {
return BlockFingerprint::matches($c['block'], $fingerprint, '', true);
}
));
}
private static function debugSnippet(array $block): string
{
return mb_substr(trim((string) wp_strip_all_tags((string) ($block['innerHTML'] ?? ''))), 0, 80);
}
// Devmode diagnostics: the raw text of every block of $blockType in the
// post, so a fingerprint mismatch can be compared against what's stored.
private static function collectTextsByType(array $blocks, string $blockType): array
{
$out = [];
$ignored = TagBlocks::$ignored;
$walk = function (array $list) use (&$walk, &$out, $blockType, $ignored) {
foreach ($list as $block) {
$name = $block['blockName'] ?? '';
if ($name === '') {
if (!empty($block['innerBlocks'])) {
$walk($block['innerBlocks']);
}
continue;
}
if ($name === 'core/template-part' || in_array($name, $ignored, true)) {
continue;
}
if ($name === $blockType) {
$out[] = self::debugSnippet($block);
}
if (!empty($block['innerBlocks'])) {
$walk($block['innerBlocks']);
}
}
};
$walk($blocks);
return $out;
}
/**
* @return \WP_Post|\WP_Error
*/
private static function resolveSourcePost(array $source)
{
$kind = (string) ($source['kind'] ?? '');
if ($kind === 'post') {
$id = (int) ($source['id'] ?? 0);
$post = $id ? get_post($id) : null;
if (!$post) {
return new \WP_Error('not_found', 'post not found');
}
$disallowed = ['revision', 'wp_navigation', 'wp_template',
'wp_template_part', 'wp_block', 'attachment'];
if (
in_array($post->post_type, $disallowed, true)
|| $post->post_status === 'auto-draft'
) {
return new \WP_Error(
'post_type_not_supported',
'Edit this content via its dedicated endpoint'
);
}
return $post;
}
if ($kind === 'template-part') {
$slug = (string) ($source['partSlug'] ?? '');
if ($slug === '') {
return new \WP_Error('bad_source', 'template-part requires partSlug');
}
// Use WP's own resolver so the save lands on the post WP renders
// from. Raw get_posts by name returns rows from every wp_theme
// term — when an install has had multiple theme variants active
// at different times (e.g. `extendable` and `extendable-2` both
// owning a "header" post), the wrong row wins on post_date
// ordering and we patch a stale orphan instead of the live part.
$stylesheet = wp_get_theme()->get_stylesheet();
$template = get_block_template("{$stylesheet}//{$slug}", 'wp_template_part');
if (!$template || empty($template->wp_id)) {
return new \WP_Error('not_found', 'template-part not found');
}
$post = get_post($template->wp_id);
if (!$post) {
return new \WP_Error('not_found', 'template-part not found');
}
return $post;
}
return new \WP_Error('bad_source', 'unknown source kind');
}
private static function userCanEditSource(\WP_Post $post): bool
{
if ($post->post_type === 'wp_template_part') {
return current_user_can('edit_theme_options');
}
return current_user_can('edit_post', $post->ID);
}
// Walks the parsed-block tree the same way TagBlocks counts on the front-end
// so client blockIds line up with what the server resolves.
private static function findBlock(array $blocks, int $targetId)
{
$ignored = TagBlocks::$ignored;
$counter = 0;
$found = null;
$walk = function (array &$list, array $pathSoFar, int $skipDepth)
use (&$walk, &$counter, &$found, $targetId, $ignored) {
foreach ($list as $i => &$block) {
if (empty($block['blockName'])) {
if (!empty($block['innerBlocks'])) {
$walk($block['innerBlocks'], array_merge($pathSoFar, [$i, 'innerBlocks']), $skipDepth);
if ($found !== null) {
return;
}
}
continue;
}
$isIgnored = in_array($block['blockName'], $ignored, true);
if ($isIgnored || $skipDepth > 0) {
if (!empty($block['innerBlocks'])) {
$walk(
$block['innerBlocks'],
array_merge($pathSoFar, [$i, 'innerBlocks']),
$skipDepth + ($isIgnored ? 1 : 0)
);
if ($found !== null) {
return;
}
}
continue;
}
$counter++;
if ($counter === $targetId) {
$found = ['block' => $block, 'path' => array_merge($pathSoFar, [$i])];
return;
}
if (!empty($block['innerBlocks'])) {
$walk($block['innerBlocks'], array_merge($pathSoFar, [$i, 'innerBlocks']), 0);
if ($found !== null) {
return;
}
}
}
unset($block);
};
$walk($blocks, [], 0);
return $found;
}
// Path elements alternate index / 'innerBlocks' / index / 'innerBlocks' / ...
private static function replaceBlockAtPath(array $blocks, array $path, array $newBlock): array
{
if (empty($path)) {
return $blocks;
}
$head = $path[0];
$rest = array_slice($path, 1);
if (!is_int($head) || !isset($blocks[$head])) {
return $blocks;
}
if (empty($rest)) {
$blocks[$head] = $newBlock;
return $blocks;
}
if ($rest[0] === 'innerBlocks') {
$blocks[$head]['innerBlocks'] = self::replaceBlockAtPath(
$blocks[$head]['innerBlocks'] ?? [],
array_slice($rest, 1),
$newBlock
);
}
return $blocks;
}
}