*/ public static function abilities(): array { $allowed = static fn (array $args): bool => current_user_can('edit_theme_options'); $slugSchema = ['maxLength' => 200, 'minLength' => 1, 'pattern' => '^[a-z0-9-]+$', 'type' => 'string']; return [ new Ability( 'sarcio/theme-parts-list', __('Look at the header and footer', 'sarcio'), Ability::READ, 'The active block theme’s template parts (header, footer, sidebar…): slug, title, area, whether the site has customised it, and the text each shows. Text that is not on a page or post (a phone number in the footer, a header link) usually lives here. Only on a block theme.', ['properties' => [], 'type' => 'object'], $allowed, static fn (array $args): array => self::list(), ), new Ability( 'sarcio/theme-part-get', __('Read a header or footer part', 'sarcio'), Ability::READ, 'Read one template part by slug: its full block markup (patterns expanded) and the text it shows. Read before you change text, so the search string is exact.', ['properties' => ['slug' => $slugSchema], 'required' => ['slug'], 'type' => 'object'], $allowed, static fn (array $args): array => self::get((string) $args['slug']), ), new Ability( 'sarcio/theme-part-replace', __('Change text in the header or footer', 'sarcio'), Ability::WRITE, 'Replace an exact piece of text inside a template part’s markup (patterns expanded). Refused when the text is not found, or found more than once unless all is true. The theme’s files are not touched: the change is saved as the site’s own copy of the part, which can be removed again.', [ 'properties' => [ 'all' => ['type' => 'boolean'], 'replace' => ['maxLength' => 20000, 'type' => 'string'], 'search' => ['maxLength' => 20000, 'minLength' => 1, 'type' => 'string'], 'slug' => $slugSchema, ], 'required' => ['slug', 'search', 'replace'], 'type' => 'object', ], $allowed, static fn (array $args): array => self::replace($args), null, static fn (array $before, array $after): array => self::restore($before), false, static fn (array $args, Overlay $overlay): array => self::stageReplace($args, $overlay), ), ]; } public static function supported(): bool { return function_exists('wp_is_block_theme') && wp_is_block_theme() && function_exists('get_block_templates'); } /** * One part as the site shows it: the override post when there is one, * else the theme file, with patterns expanded either way. * * @return array{slug:string,title:string,area:string,content:string,postId:int,custom:bool}|null */ public static function part(string $slug, ?Overlay $overlay = null): ?array { if (!self::supported()) { return null; } $template = get_block_template((string) get_stylesheet() . '//' . $slug, self::TYPE); if (!is_object($template) || !isset($template->content)) { return null; } $postId = (string) ($template->source ?? 'theme') === 'custom' ? (int) ($template->wp_id ?? 0) : 0; if ($postId > 0) { $post = $overlay !== null ? $overlay->post($postId) : get_post($postId); return [ 'area' => (string) ($template->area ?? 'uncategorized'), 'content' => self::expand((string) ($post !== null ? $post->post_content : $template->content)), 'custom' => true, 'postId' => $postId, 'slug' => $slug, 'title' => (string) ($template->title ?? $slug), ]; } $content = $overlay !== null && isset($overlay->templateParts[$slug]) ? $overlay->templateParts[$slug] : self::expand((string) $template->content); return [ 'area' => (string) ($template->area ?? 'uncategorized'), 'content' => $content, 'custom' => false, 'postId' => 0, 'slug' => $slug, 'title' => (string) ($template->title ?? $slug), ]; } private static function list(): array { if (!self::supported()) { return ['error' => __('This theme has no header and footer parts the assistant can change. Its text is set in Appearance → Customize or in the theme itself.', 'sarcio')]; } $overlay = Stage::active() ? Stage::overlay() : null; $parts = []; foreach ((array) get_block_templates([], self::TYPE) as $template) { if (!is_object($template) || !isset($template->slug)) { continue; } $part = self::part((string) $template->slug, $overlay); if ($part === null) { continue; } $parts[] = [ 'area' => $part['area'], 'custom' => $part['custom'], 'slug' => $part['slug'], 'text' => mb_substr(ContentAbilities::readable($part['content']), 0, 4000), 'title' => $part['title'], ]; } return ['parts' => $parts]; } private static function get(string $slug): array { $part = self::part($slug, Stage::active() ? Stage::overlay() : null); if ($part === null) { return ['error' => self::supported() ? __('There is no template part with that slug.', 'sarcio') : __('This theme has no header and footer parts the assistant can change.', 'sarcio')]; } $truncated = mb_strlen($part['content']) > self::MAX_CONTENT_CHARS; return [ 'area' => $part['area'], 'content' => $truncated ? mb_substr($part['content'], 0, self::MAX_CONTENT_CHARS) : $part['content'], 'custom' => $part['custom'], 'slug' => $part['slug'], 'text' => ContentAbilities::readable($part['content']), 'title' => $part['title'], 'truncated' => $truncated, ]; } /** @param array $args */ private static function replace(array $args): array { $part = self::part((string) $args['slug']); if ($part === null) { return ['error' => __('There is no template part with that slug.', 'sarcio')]; } $result = ContentAbilities::replaceText($part['content'], (string) $args['search'], (string) $args['replace'], (bool) ($args['all'] ?? false)); if (isset($result['error'])) { return ['error' => $result['error']]; } $saved = self::save($part, (string) $result['content']); if (isset($saved['error'])) { return $saved; } return [ 'journal' => [ 'after' => ['content' => (string) $result['content'], 'postId' => $saved['postId'], 'slug' => $part['slug']], 'before' => ['content' => $part['content'], 'custom' => $part['custom'], 'postId' => $part['postId'], 'slug' => $part['slug']], 'summary' => sprintf( /* translators: 1: the old text, 2: the new text, 3: the part title. */ __('Changed “%1$s” to “%2$s” in the %3$s', 'sarcio'), mb_substr((string) $args['search'], 0, 60), mb_substr((string) $args['replace'], 0, 60), $part['title'], ), 'target' => 'template_part:' . $part['slug'], ], 'postId' => $saved['postId'], 'replaced' => $result['count'], 'slug' => $part['slug'], ]; } /** @param array $args */ private static function stageReplace(array $args, Overlay $overlay): array { $part = self::part((string) $args['slug'], $overlay); if ($part === null) { return ['error' => __('There is no template part with that slug.', 'sarcio')]; } $result = ContentAbilities::replaceText($part['content'], (string) $args['search'], (string) $args['replace'], (bool) ($args['all'] ?? false)); if (isset($result['error'])) { return ['error' => $result['error']]; } if ($part['postId'] > 0) { $overlay->setPost($part['postId'], ['post_content' => (string) $result['content']]); } else { $overlay->templateParts[$part['slug']] = (string) $result['content']; } return [ 'after' => ContentAbilities::readable((string) $result['content']), 'before' => ContentAbilities::readable($part['content']), 'summary' => sprintf( /* translators: %s: the part title. */ __('Change this text in the %s', 'sarcio'), $part['title'], ), ]; } /** * Writes the site's own copy of the part, as the Site Editor does. * * @param array{slug:string,title:string,area:string,content:string,postId:int,custom:bool} $part * @return array{postId:int}|array{error:string} */ private static function save(array $part, string $content): array { if ($part['postId'] > 0) { $result = wp_update_post(['ID' => $part['postId'], 'post_content' => $content], true); if (is_wp_error($result) || (int) $result <= 0) { return ['error' => __('WordPress could not save the change.', 'sarcio')]; } return ['postId' => $part['postId']]; } $id = wp_insert_post([ 'post_content' => $content, 'post_name' => $part['slug'], 'post_status' => 'publish', 'post_title' => $part['title'], 'post_type' => self::TYPE, ], true); if (is_wp_error($id) || !is_int($id) || $id <= 0) { return ['error' => __('WordPress could not save the change.', 'sarcio')]; } wp_set_post_terms($id, [(string) get_stylesheet()], 'wp_theme'); wp_set_post_terms($id, [$part['area']], 'wp_template_part_area'); update_post_meta($id, 'origin', 'theme'); return ['postId' => $id]; } /** @param array $before */ private static function restore(array $before): array { $slug = (string) ($before['slug'] ?? ''); $current = self::part($slug); $post = $current !== null && $current['postId'] > 0 ? get_post($current['postId']) : null; if (!empty($before['custom'])) { $id = $post !== null ? (int) $post->ID : (int) ($before['postId'] ?? 0); if ($id <= 0 || get_post($id) === null) { return ['error' => __('That part no longer exists.', 'sarcio')]; } $result = wp_update_post(['ID' => $id, 'post_content' => (string) ($before['content'] ?? '')], true); if (is_wp_error($result) || (int) $result <= 0) { return ['error' => __('WordPress could not restore the part.', 'sarcio')]; } return ['postId' => $id, 'restored' => true, 'slug' => $slug]; } if ($post === null) { return ['restored' => true, 'slug' => $slug]; } if (!wp_delete_post((int) $post->ID, true)) { return ['error' => __('WordPress could not remove the site’s copy of the part.', 'sarcio')]; } return ['restored' => true, 'slug' => $slug, 'themeVersion' => true]; } /** Patterns a part pulls in are expanded, so their text is there to be changed. */ private static function expand(string $content): string { if (!function_exists('resolve_pattern_blocks') || !function_exists('parse_blocks') || !function_exists('serialize_blocks')) { return $content; } return (string) serialize_blocks(resolve_pattern_blocks(parse_blocks($content))); } }