> */ public static function definitions(): array { return array( self::TITLE => array( 'type' => 'string', 'description' => __( 'Custom SEO title for this content.', 'acez-seo' ), 'single' => true, 'default' => '', // NOT sanitize_text_field — it strips %hex pairs, mangling %acf:…% tokens (M49). 'sanitize_callback' => array( \AcezSeo\Support\TemplateSanitizer::class, 'sanitize_line' ), ), self::DESCRIPTION => array( 'type' => 'string', 'description' => __( 'Custom meta description for this content.', 'acez-seo' ), 'single' => true, 'default' => '', // NOT sanitize_textarea_field — same %hex stripping as above (M49). 'sanitize_callback' => array( \AcezSeo\Support\TemplateSanitizer::class, 'sanitize_multiline' ), ), self::KEYPHRASE => array( 'type' => 'string', 'description' => __( 'Focus keyphrase used by the content analysis (M51 — lives in the BASE plugin; Pro reads the same field).', 'acez-seo' ), 'single' => true, 'default' => '', // Same %hex-safety rationale as TITLE (M49) — a keyphrase like "50%ab" must survive. 'sanitize_callback' => array( \AcezSeo\Support\TemplateSanitizer::class, 'sanitize_line' ), ), self::CANONICAL => array( 'type' => 'string', 'description' => __( 'Custom canonical URL.', 'acez-seo' ), 'single' => true, 'default' => '', 'sanitize_callback' => 'esc_url_raw', ), self::ROBOTS_INDEX => array( 'type' => 'boolean', 'description' => __( 'Allow search engines to index this content.', 'acez-seo' ), 'single' => true, 'default' => true, 'sanitize_callback' => 'rest_sanitize_boolean', ), self::ROBOTS_FOLLOW => array( 'type' => 'boolean', 'description' => __( 'Allow search engines to follow links on this content.', 'acez-seo' ), 'single' => true, 'default' => true, 'sanitize_callback' => 'rest_sanitize_boolean', ), // M56 — deliberately separate from ROBOTS_INDEX: a page can be // indexable (linked elsewhere, still noindex-free) yet omitted // from the SUBMITTED sitemap (e.g. a thin/duplicate page you'd // rather Google not prioritize crawling via the sitemap signal). self::SITEMAP_EXCLUDE => array( 'type' => 'boolean', 'description' => __( 'Exclude this content from the XML sitemap.', 'acez-seo' ), 'single' => true, 'default' => false, 'sanitize_callback' => 'rest_sanitize_boolean', ), // M57 — deliberately separate from ROBOTS_INDEX too: a page can be // indexable and still omitted from THIS one file (e.g. a legal/ // admin page you don't want an LLM summarizing as "important"), // same independence rationale as SITEMAP_EXCLUDE above. self::LLMS_EXCLUDE => array( 'type' => 'boolean', 'description' => __( 'Exclude this content from llms.txt.', 'acez-seo' ), 'single' => true, 'default' => false, 'sanitize_callback' => 'rest_sanitize_boolean', ), self::OG_IMAGE_ID => array( 'type' => 'integer', 'description' => __( 'Attachment ID used for the Open Graph / Twitter image.', 'acez-seo' ), 'single' => true, 'default' => 0, 'sanitize_callback' => 'absint', ), self::TWITTER_CARD => array( 'type' => 'string', 'description' => __( 'Twitter card type.', 'acez-seo' ), 'single' => true, 'default' => 'summary_large_image', 'sanitize_callback' => array( self::class, 'sanitize_twitter_card' ), ), self::SCHEMA_JSON => array( 'type' => 'string', 'description' => __( 'Per-post schema override, stored as JSON.', 'acez-seo' ), 'single' => true, 'default' => '', 'sanitize_callback' => array( self::class, 'sanitize_schema_json' ), ), self::CUSTOM_SCHEMAS => array( 'type' => 'string', 'description' => __( 'Additional custom schema.org nodes merged into the auto-generated graph, stored as a JSON-encoded array.', 'acez-seo' ), 'single' => true, 'default' => '', 'sanitize_callback' => array( self::class, 'sanitize_custom_schemas' ), ), self::AI_CONTEXT_SCHEMA => array( 'type' => 'string', 'description' => __( 'Optional extra instructions the user can give to the AI schema (FAQ/HowTo) node generator.', 'acez-seo' ), 'single' => true, 'default' => '', // Not sanitize_textarea_field — same %hex-stripping trap as TITLE/DESCRIPTION (M49). 'sanitize_callback' => array( self::class, 'sanitize_ai_context' ), ), self::AI_CONTEXT_KEYWORD => array( 'type' => 'string', 'description' => __( 'Optional extra instructions the user can give to the AI keyphrase generator.', 'acez-seo' ), 'single' => true, 'default' => '', 'sanitize_callback' => array( self::class, 'sanitize_ai_context' ), ), self::SEARCH_INTENT => array( 'type' => 'string', 'description' => __( 'User-declared search intent (informational/navigational/commercial/transactional), used as extra AI generation context.', 'acez-seo' ), 'single' => true, 'default' => '', 'sanitize_callback' => array( self::class, 'sanitize_search_intent' ), ), self::SCHEMA_TYPE_OVERRIDE => array( 'type' => 'string', 'description' => __( 'User-forced type of the main schema.org node (empty = automatic detection, "none" = no main node for this post).', 'acez-seo' ), 'single' => true, 'default' => '', 'sanitize_callback' => array( self::class, 'sanitize_schema_type_override' ), ), self::HOWTO_ENABLED => array( 'type' => 'boolean', 'description' => __( 'Treat this content as a HowTo — extracted from the first ordered list in the content.', 'acez-seo' ), 'single' => true, 'default' => false, 'sanitize_callback' => 'rest_sanitize_boolean', ), self::PRODUCT_PRICE => array( 'type' => 'string', 'description' => __( 'Manual product price (used when WooCommerce is not active).', 'acez-seo' ), 'single' => true, 'default' => '', 'sanitize_callback' => array( self::class, 'sanitize_price' ), ), self::PRODUCT_CURRENCY => array( 'type' => 'string', 'description' => __( 'Manual product currency (ISO 4217, e.g. PLN, USD).', 'acez-seo' ), 'single' => true, 'default' => '', 'sanitize_callback' => array( self::class, 'sanitize_currency' ), ), self::PRODUCT_SKU => array( 'type' => 'string', 'description' => __( 'Manual product SKU.', 'acez-seo' ), 'single' => true, 'default' => '', 'sanitize_callback' => 'sanitize_text_field', ), self::PRODUCT_AVAILABILITY => array( 'type' => 'string', 'description' => __( 'Manual product availability.', 'acez-seo' ), 'single' => true, 'default' => 'InStock', 'sanitize_callback' => array( self::class, 'sanitize_availability' ), ), ); } /** * Sanitizes the optional AI-context textareas: same %hex-safety as prose * fields (M49), capped to AI_CONTEXT_MAX_LENGTH so a long paste can't * blow up the AI prompt's token cost. */ public static function sanitize_ai_context( string $value ): string { $value = \AcezSeo\Support\TemplateSanitizer::sanitize_multiline( $value ); return mb_substr( trim( $value ), 0, self::AI_CONTEXT_MAX_LENGTH ); } public static function sanitize_price( string $value ): string { $value = trim( $value ); if ( '' === $value || ! is_numeric( str_replace( ',', '.', $value ) ) ) { return ''; } return (string) str_replace( ',', '.', $value ); } public static function sanitize_currency( string $value ): string { $value = strtoupper( sanitize_text_field( $value ) ); return preg_match( '/^[A-Z]{3}$/', $value ) ? $value : ''; } public static function sanitize_search_intent( string $value ): string { return in_array( $value, self::SEARCH_INTENT_VALUES, true ) ? $value : ''; } /** * Whitelist validation, NOT sanitize_key() — schema.org type names are * case-sensitive ('BlogPosting', not 'blogposting'). Anything outside the * whitelist collapses to '' (automatic), never to a guessed value. */ public static function sanitize_schema_type_override( string $value ): string { return in_array( $value, self::SCHEMA_TYPE_OVERRIDE_VALUES, true ) ? $value : ''; } /** * Single source of labels for the SCHEMA_TYPE_OVERRIDE dropdown — shared * by the Classic meta box (PHP ) and the Gutenberg sidebar (localized * to JS), so the two editors can never show different wording for the * same stored value. * * @return array value => label, '' (unset) first */ public static function search_intent_labels(): array { return array( '' => __( '— not set —', 'acez-seo' ), 'informational' => __( 'Informational (wants to learn something)', 'acez-seo' ), 'navigational' => __( 'Navigational (looking for a specific brand/page)', 'acez-seo' ), 'commercial' => __( 'Commercial (comparing before buying)', 'acez-seo' ), 'transactional' => __( 'Transactional (ready to buy/act)', 'acez-seo' ), ); } public static function sanitize_availability( string $value ): string { $allowed = array( 'InStock', 'OutOfStock', 'PreOrder', 'LimitedAvailability' ); return in_array( $value, $allowed, true ) ? $value : 'InStock'; } /** * Builds the value to pass to update_post_meta() for a full schema * override. Callers MUST use this (not a raw wp_json_encode() call) — * WordPress unslashes meta values before our sanitize_callback runs, * which strips backslashes; encoding with these flags avoids producing * any `\uXXXX`/`\/` sequences in the first place, so there's nothing * for that step to corrupt. * * @param array $data */ public static function encode_schema_json( array $data ): string { return (string) wp_json_encode( $data, self::JSON_ENCODE_FLAGS ); } /** * Builds the value to pass to update_post_meta() for the custom-schema * repeater. See encode_schema_json() for why this must be used instead * of a raw wp_json_encode() call. * * @param array}> $items */ public static function encode_custom_schemas( array $items ): string { return (string) wp_json_encode( $items, self::JSON_ENCODE_FLAGS ); } public static function sanitize_twitter_card( string $value ): string { $allowed = array( 'summary', 'summary_large_image' ); return in_array( $value, $allowed, true ) ? $value : 'summary_large_image'; } public static function sanitize_schema_json( string $value ): string { if ( '' === trim( $value ) ) { return ''; } // No wp_unslash() here: callers (ClassicMetaBox::save(), the REST API // meta handler) already hand us an unslashed value. Unslashing again // would strip legitimate backslash-escaped quotes from nested JSON. $decoded = json_decode( $value, true ); if ( null === $decoded && JSON_ERROR_NONE !== json_last_error() ) { return ''; } // json_decode() only proves the input was valid JSON, not that its // structure is safe/bounded — SchemaNodeSanitizer is the real // validation/whitelist-of-shapes layer (recursive depth/size limits, // invalid-UTF-8/control-char stripping on every string leaf). $sanitized = \AcezSeo\Support\SchemaNodeSanitizer::sanitize( $decoded ); return null === $sanitized ? '' : (string) wp_json_encode( $sanitized, self::JSON_ENCODE_FLAGS ); } /** * Sanitizes the "custom schema nodes" repeater value: a JSON-encoded * array of { label, node } items, where `node` is a *native* nested * array/object — never a re-stringified JSON blob. Storing a JSON string * inside a JSON string (double-encoding) produces literal backslash * escapes that WordPress's own meta-sanitization pipeline silently * strips (it unslashes the value before calling this callback), which * corrupts the nested JSON. Keeping everything as one native structure, * encoded exactly once, avoids that failure mode entirely. * * Defense against malicious/oversized import: caps the number of nodes * and the encoded size of each node, and drops (rather than errors on) * anything malformed. */ public static function sanitize_custom_schemas( string $value ): string { if ( '' === trim( $value ) ) { return ''; } $decoded = json_decode( $value, true ); if ( ! is_array( $decoded ) ) { return ''; } $clean = array(); foreach ( array_slice( $decoded, 0, self::CUSTOM_SCHEMAS_MAX_NODES ) as $item ) { if ( ! is_array( $item ) || ! isset( $item['node'] ) || ! is_array( $item['node'] ) ) { continue; } // json_decode() only proves the input was valid JSON, not that its // structure is safe/bounded — SchemaNodeSanitizer is the real // validation layer (recursive depth/size limits, invalid-UTF-8/ // control-char stripping on every string leaf). $node = \AcezSeo\Support\SchemaNodeSanitizer::sanitize( $item['node'] ); if ( null === $node ) { continue; } $encoded_node = wp_json_encode( $node, self::JSON_ENCODE_FLAGS ); if ( ! is_string( $encoded_node ) || strlen( $encoded_node ) > self::CUSTOM_SCHEMAS_MAX_NODE_SIZE ) { continue; } $clean[] = array( 'label' => isset( $item['label'] ) ? sanitize_text_field( (string) $item['label'] ) : '', 'node' => $node, ); } return (string) wp_json_encode( $clean, self::JSON_ENCODE_FLAGS ); } }