[ new key, callable mapping the old value or null ]. * Applied to stored settings (migration) and to imported settings files. */ const RENAMED = array( // 1.0: "Hide on documentation pages" became the "Show on" choice. 'widget_exclude_docs' => array( 'widget_display', array( __CLASS__, 'map_widget_exclude' ) ), ); /** * Runtime cache. * * @var array|null */ private static $cache = null; /** * Saved settings (without preview overrides). * * @var array|null */ private static $saved = null; /** * Settings the design studio previews and presets change. */ const DESIGN_KEYS = array( 'primary_color', 'font_family', 'radius', 'container_width', 'columns', 'home_layout', 'article_width', 'color_scheme', 'card_thumbnails', 'show_sidebar', 'sidebar_style', 'docs_shell', 'docs_logo', 'docs_logo_dark', 'docs_favicon', 'docs_header_links', 'design_preset', 'use_inter_font', ); /** * Settings limited to a fixed list of values (the first is the fallback). */ const CHOICES = array( 'home_layout' => array( 'cards', 'list', 'minimal', 'boxed' ), 'color_scheme' => array( 'toggle', 'light', 'dark' ), 'font_family' => array( 'inter', 'system', 'serif' ), 'article_width' => array( 'normal', 'wide' ), 'sidebar_style' => array( 'classic', 'lined', 'boxed', 'filled', 'minimal', 'accordion' ), 'docs_shell' => array( 'theme', 'app' ), 'report_frequency' => array( 'off', 'weekly', 'monthly' ), 'embedding_provider' => array( 'openai', 'google', 'voyage' ), ); /** * Default values. Types here drive sanitization. * * @return array */ public static function defaults() { return array( // General. 'docs_slug' => 'docs', 'category_base' => 'category', 'home_title' => __( 'How can we help?', 'karma-docs' ), 'home_subtitle' => __( 'Search our knowledge base or browse the topics below.', 'karma-docs' ), 'single_kb_redirect' => true, 'articles_per_section' => 5, 'show_popular' => true, 'contact_enabled' => true, 'contact_title' => __( 'Still need help?', 'karma-docs' ), 'contact_text' => __( 'Can’t find what you’re looking for? Our team is happy to help.', 'karma-docs' ), 'contact_label' => __( 'Contact support', 'karma-docs' ), 'contact_url' => '', 'enable_comments' => false, // Appearance. 'primary_color' => '#6d5efc', 'home_layout' => 'cards', // cards | list | minimal (pro) | boxed (pro). 'columns' => 3, 'container_width' => 1240, 'color_scheme' => 'toggle', // toggle | light | dark. 'show_sidebar' => true, 'show_toc' => true, 'toc_depth' => 3, 'use_inter_font' => true, 'font_family' => 'inter', // inter | system | serif. 'radius' => 12, 'article_width' => 'normal', // normal | wide. 'sidebar_style' => 'classic', // classic | lined | boxed | filled | minimal | accordion. 'card_thumbnails' => false, 'design_preset' => '', // Docs app mode: own slim header instead of the theme's. 'docs_shell' => 'theme', // theme | app. 'docs_logo' => 0, 'docs_logo_dark' => 0, 'docs_favicon' => 0, 'docs_header_links' => '', // Article features. 'enable_search' => true, 'search_shortcut' => true, 'enable_feedback' => true, 'feedback_reasons' => true, // Analytics. 'analytics_count_editors' => false, 'analytics_retention' => 90, 'impact_ticket_rate' => 20, // % of self-service sessions that would have become a ticket. 'impact_ticket_cost' => 15, 'impact_currency' => '$', 'report_frequency' => 'off', // off | weekly | monthly (Pro). 'report_recipients' => '', 'report_kb' => 0, 'show_reading_time' => true, 'show_last_updated' => true, 'show_print' => true, 'show_breadcrumbs' => true, 'show_prev_next' => true, 'show_related' => true, 'show_author' => false, 'copy_code' => true, 'anchor_links' => true, 'enable_schema' => true, // Pro. 'feedback_form' => true, 'notify_email' => '', 'feedback_retention' => 0, 'notify_negative' => true, 'notify_digest' => true, 'widget_enabled' => false, 'widget_position' => 'right', 'widget_title' => __( 'Need help?', 'karma-docs' ), 'widget_kb' => 0, 'widget_contact_url' => '', 'widget_display' => 'everywhere', // everywhere | docs | not_docs. 'widget_greeting' => __( 'Search our docs or ask a question.', 'karma-docs' ), 'review_interval' => 180, 'white_label_name' => '', 'custom_css' => '', // Content blocks. 'faq_schema' => true, // Data. 'delete_data' => false, // Pro: PDF export. 'pdf_enabled' => true, // Pro: versions. 'version_banner' => true, // Pro: contributors & approval workflow. 'workflow_enabled' => false, 'workflow_notify' => true, // Pro: integrations. 'webhook_url' => '', 'webhook_secret' => '', 'slack_webhook_url' => '', 'integration_events' => 'feedback.negative,feedback.message,article.published,search.no_results,review.submitted', // Pro: search provider. 'search_provider' => 'wordpress', // wordpress | algolia. 'algolia_app_id' => '', 'algolia_search_key' => '', 'algolia_admin_key' => '', 'algolia_index' => 'karma_docs', // Pro: AI. 'ai_enabled' => false, 'ai_provider' => 'anthropic', // anthropic | openai | google. 'ai_key_anthropic' => '', 'ai_key_openai' => '', 'ai_key_google' => '', 'ai_model' => '', 'ai_name' => __( 'Docs AI', 'karma-docs' ), 'ai_in_search' => true, 'ai_in_widget' => true, 'ai_writer' => true, 'ai_rate_limit' => 20, 'ai_instructions' => '', 'ai_budget' => 0, 'summary_show' => true, 'summary_auto' => false, 'summary_open' => true, 'summary_min_words' => 250, 'summary_approved_only' => false, 'mermaid' => false, 'llms_txt' => true, 'slack_digest' => false, 'forward_ga4' => false, 'forward_plausible' => false, 'forward_matomo' => false, 'cors_origins' => '', 'slack_signing_secret' => '', 'discord_public_key' => '', 'chat_ai' => true, 'share_images' => true, 'semantic_search' => false, 'embedding_provider' => 'openai', 'ai_key_voyage' => '', 'reader_memory' => true, ); } /** * Settings that hold credentials: never sent back to the browser in clear text. * * @return string[] */ public static function secret_keys() { return array( 'ai_key_anthropic', 'ai_key_openai', 'ai_key_google', 'algolia_admin_key', 'webhook_secret', 'slack_signing_secret', 'ai_key_voyage' ); } /** * Masks a secret for display (keeps the last 4 characters). * * @param string $value Secret. * @return string */ public static function mask( $value ) { $value = (string) $value; if ( '' === $value ) { return ''; } return str_repeat( '•', 8 ) . substr( $value, -4 ); } /** * Settings safe to expose to the admin app (secrets masked). * * @return array */ public static function for_client() { $all = self::all(); foreach ( self::secret_keys() as $key ) { $all[ $key ] = self::mask( $all[ $key ] ); } return $all; } /** * All settings merged with defaults. * * @return array */ public static function all() { if ( null === self::$cache ) { $all = array_merge( self::saved(), self::preview() ); // A preview request can't be recognised before the user is known: don't cache yet. if ( ! empty( $_GET['kdocs_preview'] ) && ! did_action( 'set_current_user' ) ) { // phpcs:ignore WordPress.Security.NonceVerification return $all; } self::$cache = $all; } return self::$cache; } /** * Saved settings merged with defaults (never the preview draft). * * @return array */ public static function saved() { if ( null === self::$saved ) { $saved = get_option( self::OPTION, array() ); self::$saved = wp_parse_args( is_array( $saved ) ? $saved : array(), self::defaults() ); } return self::$saved; } /** * Design studio: unsaved design values shown only to the admin who is * previewing them (?kdocs_preview=1 in the studio's preview frame). * * @return array */ private static function preview() { if ( empty( $_GET['kdocs_preview'] ) || ! did_action( 'set_current_user' ) || ! current_user_can( Capabilities::SETTINGS ) ) { // phpcs:ignore WordPress.Security.NonceVerification return array(); } $draft = get_transient( self::preview_key() ); return is_array( $draft ) ? array_intersect_key( $draft, array_flip( self::DESIGN_KEYS ) ) : array(); } /** * Stores a design draft for the current user's preview frame. * * @param array $input Design values. * @return array Sanitized draft. */ public static function store_preview( array $input ) { $input = array_intersect_key( $input, array_flip( self::DESIGN_KEYS ) ); $clean = array_intersect_key( self::sanitize( $input, self::saved() ), array_flip( self::DESIGN_KEYS ) ); set_transient( self::preview_key(), $clean, 30 * MINUTE_IN_SECONDS ); return $clean; } /** * Transient key of the current user's preview draft. * * @return string */ private static function preview_key() { return 'kdocs_design_preview_' . get_current_user_id(); } /** * Single setting. * * @param string $key Key. * @param mixed $default Fallback. * @return mixed */ public static function get( $key, $default = null ) { $all = self::all(); return array_key_exists( $key, $all ) ? $all[ $key ] : $default; } /** * Maps renamed keys to their replacements (the new key wins when both exist). * * @param array $values Settings. * @return array */ public static function rename( array $values ) { foreach ( self::RENAMED as $old => $to ) { if ( ! array_key_exists( $old, $values ) ) { continue; } if ( ! array_key_exists( $to[0], $values ) ) { $values[ $to[0] ] = $to[1] ? call_user_func( $to[1], $values[ $old ] ) : $values[ $old ]; } unset( $values[ $old ] ); } return $values; } /** * `widget_exclude_docs` → `widget_display`. Saved 1.0 values can't be trusted * (every save stored the old default, which hid the widget on docs pages), * so the new default is used. * * @return string */ public static function map_widget_exclude() { return 'everywhere'; } /** * Stored settings brought up to date: renamed keys mapped, removed keys * dropped, every value re-sanitized to its current type. Only stored keys are * kept, so defaults keep applying to the rest. * * @param array $saved Stored settings. * @return array */ public static function upgrade( array $saved ) { $saved = self::rename( $saved ); $defaults = self::defaults(); $known = array_intersect_key( $saved, $defaults ); $clean = self::sanitize( $known, wp_parse_args( $known, $defaults ) ); return array_intersect_key( $clean, $known ); } /** * Saves (partial) settings with sanitization driven by the default types. * * @param array $input Raw values. * @return array Saved settings. */ public static function update( array $input ) { $current = self::saved(); $old_slug = $current['docs_slug'] . '|' . $current['category_base']; $current = self::sanitize( $input, $current ); update_option( self::OPTION, $current ); self::$saved = $current; self::$cache = null; if ( $old_slug !== $current['docs_slug'] . '|' . $current['category_base'] ) { update_option( 'kdocs_flush_rewrite', 1 ); } return $current; } /** * Sanitizes (partial) input over existing values, driven by the default types. * * @param array $input Raw values. * @param array $current Values to update. * @return array */ public static function sanitize( array $input, array $current ) { $defaults = self::defaults(); $input = self::rename( $input ); foreach ( $input as $key => $value ) { if ( ! array_key_exists( $key, $defaults ) ) { continue; } // Unchanged masked secrets come back from the browser as-is: keep the stored value. if ( in_array( $key, self::secret_keys(), true ) && is_string( $value ) && false !== strpos( $value, '•' ) ) { continue; } $type = gettype( $defaults[ $key ] ); switch ( $type ) { case 'boolean': $current[ $key ] = (bool) rest_sanitize_boolean( $value ); break; case 'integer': $current[ $key ] = (int) $value; if ( 'analytics_retention' === $key ) { $current[ $key ] = max( 7, min( 730, $current[ $key ] ) ); } elseif ( 'feedback_retention' === $key ) { $current[ $key ] = max( 0, min( 3650, $current[ $key ] ) ); } elseif ( in_array( $key, array( 'ai_budget', 'summary_min_words' ), true ) ) { $current[ $key ] = max( 0, min( 100000, $current[ $key ] ) ); } elseif ( 'impact_ticket_rate' === $key ) { $current[ $key ] = max( 0, min( 100, $current[ $key ] ) ); } elseif ( 'radius' === $key ) { $current[ $key ] = max( 0, min( 24, $current[ $key ] ) ); } elseif ( in_array( $key, array( 'docs_logo', 'docs_logo_dark', 'docs_favicon' ), true ) && $current[ $key ] && ! wp_attachment_is_image( $current[ $key ] ) && 'image/svg+xml' !== get_post_mime_type( $current[ $key ] ) ) { $current[ $key ] = 0; } break; default: if ( 'custom_css' === $key ) { $current[ $key ] = wp_strip_all_tags( (string) $value ); } elseif ( 'primary_color' === $key ) { $current[ $key ] = sanitize_hex_color( $value ) ? sanitize_hex_color( $value ) : $defaults[ $key ]; } elseif ( in_array( $key, array( 'docs_slug', 'category_base' ), true ) ) { $slug = sanitize_title( $value ); $current[ $key ] = $slug ? $slug : $defaults[ $key ]; } elseif ( 'widget_display' === $key ) { $current[ $key ] = in_array( $value, array( 'everywhere', 'docs', 'not_docs' ), true ) ? $value : 'everywhere'; } elseif ( in_array( $key, array( 'widget_contact_url', 'contact_url', 'webhook_url', 'slack_webhook_url' ), true ) ) { $current[ $key ] = esc_url_raw( trim( (string) $value ) ); } elseif ( 'ai_instructions' === $key ) { $current[ $key ] = sanitize_textarea_field( (string) $value ); } elseif ( 'ai_provider' === $key ) { $current[ $key ] = in_array( $value, array( 'anthropic', 'openai', 'google' ), true ) ? $value : 'anthropic'; } elseif ( 'search_provider' === $key ) { $current[ $key ] = in_array( $value, array( 'wordpress', 'algolia' ), true ) ? $value : 'wordpress'; // phpcs:ignore WordPress.WP.CapitalPDangit -- Option value, not the brand. } elseif ( 'algolia_app_id' === $key ) { // Becomes part of the Algolia API host name. $current[ $key ] = strtoupper( preg_replace( '/[^A-Za-z0-9]/', '', (string) $value ) ); } elseif ( 'algolia_index' === $key ) { $index = preg_replace( '/[^A-Za-z0-9_\-]/', '', (string) $value ); $current[ $key ] = $index ? $index : $defaults[ $key ]; } elseif ( 'notify_email' === $key ) { $current[ $key ] = sanitize_email( $value ); } elseif ( isset( self::CHOICES[ $key ] ) ) { $current[ $key ] = in_array( $value, self::CHOICES[ $key ], true ) ? $value : self::CHOICES[ $key ][0]; } elseif ( 'report_recipients' === $key ) { $current[ $key ] = implode( ', ', array_filter( array_map( 'sanitize_email', preg_split( '/[\s,;]+/', (string) $value ) ) ) ); } elseif ( 'cors_origins' === $key ) { $current[ $key ] = implode( "\n", array_filter( array_map( static fn( $o ) => '*' === $o ? '*' : esc_url_raw( $o, array( 'http', 'https' ) ), preg_split( '/[\s,]+/', (string) $value ) ) ) ); } elseif ( 'discord_public_key' === $key ) { $current[ $key ] = strtolower( preg_replace( '/[^0-9a-fA-F]/', '', (string) $value ) ); } elseif ( 'docs_header_links' === $key ) { $current[ $key ] = sanitize_textarea_field( (string) $value ); } else { $current[ $key ] = sanitize_text_field( (string) $value ); } } } // Free plan cannot persist premium-only layouts. if ( ! Features::is_pro() && ! in_array( $current['home_layout'], array( 'cards', 'list' ), true ) ) { $current['home_layout'] = 'cards'; } return $current; } }