*/ public static function fields() { return [ 'title' => ['type' => 'template', 'label' => __('SEO Title', 'crownify')], 'description' => ['type' => 'template', 'label' => __('Meta Description', 'crownify')], 'canonical' => ['type' => 'url', 'label' => __('Canonical URL', 'crownify')], 'focus_keywords' => ['type' => 'text', 'label' => __('Focus Keywords', 'crownify')], 'noindex' => ['type' => 'toggle', 'label' => __('Noindex', 'crownify')], 'nofollow' => ['type' => 'toggle', 'label' => __('Nofollow', 'crownify')], 'noarchive' => ['type' => 'toggle', 'label' => __('No Archive', 'crownify')], 'noimageindex' => ['type' => 'toggle', 'label' => __('No Image Index', 'crownify')], 'og_title' => ['type' => 'template', 'label' => __('Social Title', 'crownify')], 'og_description' => ['type' => 'template', 'label' => __('Social Description', 'crownify')], 'image' => ['type' => 'attachment', 'label' => __('Social Image', 'crownify')], // Yoast parity. Cornerstone marks the handful of pages a site most wants // to rank; it changes nothing about the output and everything about how // Crownify advises — link suggestions steer traffic towards these, and an // orphaned cornerstone is the single worst internal-linking mistake. 'cornerstone' => ['type' => 'toggle', 'label' => __('Cornerstone content', 'crownify')], // Schema type overrides. Crownify picks a sensible type per post type; a // per-page override matters because "About us" is an AboutPage and the // contact page is a ContactPage, and no post-type default can know that. 'page_type' => ['type' => 'text', 'label' => __('Page Type', 'crownify')], 'article_type' => ['type' => 'text', 'label' => __('Article Type', 'crownify')], ]; } public static function metaKey($field) { return Resolver::META_PREFIX . $field; } /** * Sanitize one value for storage. * * Templates keep their %%variables%% intact — see sanitizeTemplate(), which * exists because the obvious sanitizer does not. */ public static function sanitize($field, $value) { $fields = self::fields(); $type = isset($fields[$field]) ? $fields[$field]['type'] : 'text'; switch ($type) { case 'toggle': return 'on' === $value ? 'on' : ''; case 'url': return esc_url_raw(trim((string) $value)); case 'attachment': // Either an attachment ID or a URL; Resolver::attachmentIdFromSetting() // accepts both, so both are allowed through. $value = trim((string) $value); return is_numeric($value) ? (string) absint($value) : esc_url_raw($value); case 'template': return self::sanitizeTemplate($value); case 'text': default: return sanitize_text_field(wp_unslash((string) $value)); } } /** * Sanitize a template without eating its variables. * * `sanitize_text_field()` strips every `%XX` hex pair, on the assumption that * a percent-encoded sequence in plain text is a defused payload. That is * reasonable for text and wrong for a template: `%%category%%` loses its `%ca` * and becomes `%tegory%%`, and `%%date%%` becomes `%te%%`. Both then render * as that literal string in search results, and nothing anywhere reports it. * * So the variables are masked out first, the rest of the string gets the full * treatment, and they are put back — the hardening still applies to every * character the user actually typed. */ protected static function sanitizeTemplate($value) { $value = wp_unslash((string) $value); $tokens = []; $masked = preg_replace_callback( '/%%[a-z0-9_]+%%/i', static function ($matches) use (&$tokens) { // Purely alphanumeric, so it cannot itself look like a percent-encoded // sequence or an HTML tag and survives sanitizing untouched. $placeholder = 'zzRANKIFYVAR' . count($tokens) . 'ENDzz'; $tokens[$placeholder] = $matches[0]; return $placeholder; }, $value, ); $masked = sanitize_text_field(is_string($masked) ? $masked : $value); return empty($tokens) ? $masked : strtr($masked, $tokens); } /** * Read every override for a post, as field => stored value. */ public static function all($postId) { $values = []; foreach (array_keys(self::fields()) as $field) { $stored = get_post_meta((int) $postId, self::metaKey($field), true); $values[$field] = is_string($stored) ? $stored : ''; } return $values; } /** * Persist a submitted payload. * * An empty value deletes the meta row rather than storing ''. That matters: * Resolver treats a missing override as "fall back to the template", and a * stored empty string would be indistinguishable from one the user meant to * blank — which would make clearing a field impossible to undo. */ public static function save($postId, array $payload) { $postId = (int) $postId; foreach (self::fields() as $field => $config) { // A toggle absent from the payload means unchecked; every other field // absent means "not submitted", so it must be left alone. if (!array_key_exists($field, $payload)) { if ('toggle' !== $config['type']) { continue; } $payload[$field] = ''; } $value = self::sanitize($field, $payload[$field]); if ('' === $value) { delete_post_meta($postId, self::metaKey($field)); continue; } update_post_meta($postId, self::metaKey($field), $value); } } /** * Register the keys so the block editor can read and write them over REST. * * `show_in_rest` with an explicit auth callback rather than the default: * these control whether a page is indexable, so editing them needs the same * capability as editing the post itself. */ public static function register() { foreach (self::fields() as $field => $config) { foreach (get_post_types(['public' => true], 'names') as $postType) { register_post_meta($postType, self::metaKey($field), [ 'type' => 'string', 'single' => true, 'default' => '', 'show_in_rest' => true, 'sanitize_callback' => function ($value) use ($field) { return self::sanitize($field, $value); }, 'auth_callback' => function ($allowed, $metaKey, $postId) { return current_user_can('edit_post', $postId); }, ]); } } } }