settings = $settings; $this->views = $views; $this->renderer = $renderer; $this->endpoint = $endpoint; } /** * Master switch for the whole feature. * * @return bool */ public function is_enabled() { /** * Toggle the AI Actions button independently of the setting. * * @since 4.8.0 * * @param bool $enabled */ return (bool) apply_filters( 'betterdocs_ai_actions_enabled', (bool) $this->settings->get( 'enable_ai_actions' ) ); } /** * Default keys, so a third-party registry entry may omit any of them. * * @return array */ public function defaults() { return [ 'id' => '', 'label' => '', 'description' => '', 'icon' => '', 'setting_key' => '', 'type' => 'link', 'handler' => '', 'source' => 'md', 'href_template' => '', 'target' => '_blank', 'primary' => false, 'priority' => 100, // Menu grouping. The view draws a rule wherever this changes between // two consecutive entries, so an added entry only has to say which side // of the divider it belongs on. `page` acts on this doc, `llm` hands it // to somebody else's assistant. 'group' => 'llm' ]; } /** * The registry. * * URL formats mirror what Mintlify ships in production. Two are worth calling * out because they look like mistakes and are not: * * - ChatGPT gets the plain permalink rather than the `.md` URL. Its browsing * tool renders HTML perfectly well, and the `hints=search` parameter makes it * fetch rather than guess. * - "Gemini" points at Google AI Studio. gemini.google.com has no prompt * prefill parameter — the browser extensions that claim otherwise exist * precisely because it does not — while AI Studio supports `?prompt=`. * * @return array id => definition */ public function registry() { $ask = __( 'Ask questions about this page', 'betterdocs' ); $actions = [ 'copy-page' => [ 'id' => 'copy-page', 'label' => __( 'Copy page', 'betterdocs' ), 'description' => __( 'Copy this page as Markdown for LLMs', 'betterdocs' ), 'icon' => 'copy', 'setting_key' => 'ai_actions_copy_page', 'type' => 'copy', 'handler' => 'copy-markdown', 'source' => 'md', 'target' => '', 'primary' => true, 'priority' => 10, 'group' => 'page' ], 'view-markdown' => [ 'id' => 'view-markdown', 'label' => __( 'View as Markdown', 'betterdocs' ), 'description' => __( 'Open this page as plain Markdown', 'betterdocs' ), 'icon' => 'markdown', 'setting_key' => 'ai_actions_view_markdown', 'href_template' => '{MD_URL}', 'priority' => 20, 'group' => 'page' ], 'open-chatgpt' => [ 'id' => 'open-chatgpt', 'label' => __( 'Open in ChatGPT', 'betterdocs' ), 'description' => $ask, 'icon' => 'chatgpt', 'setting_key' => 'ai_actions_chatgpt', 'source' => 'page', 'href_template' => 'https://chatgpt.com/?hints=search&q={PROMPT}', 'priority' => 30 ], 'open-claude' => [ 'id' => 'open-claude', 'label' => __( 'Open in Claude', 'betterdocs' ), 'description' => $ask, 'icon' => 'claude', 'setting_key' => 'ai_actions_claude', 'href_template' => 'https://claude.ai/new?q={PROMPT}', 'priority' => 40 ], 'open-aistudio' => [ 'id' => 'open-aistudio', 'label' => __( 'Open in Google AI Studio', 'betterdocs' ), 'description' => __( 'Ask questions about this page (Google account required)', 'betterdocs' ), 'icon' => 'gemini', 'setting_key' => 'ai_actions_gemini', 'href_template' => 'https://aistudio.google.com/prompts/new_chat?prompt={PROMPT}', 'priority' => 50 ], 'open-perplexity' => [ 'id' => 'open-perplexity', 'label' => __( 'Open in Perplexity', 'betterdocs' ), 'description' => $ask, 'icon' => 'perplexity', 'setting_key' => 'ai_actions_perplexity', // /search?q= answers with a 301; /search/new?q= is the current form. 'href_template' => 'https://www.perplexity.ai/search/new?q={PROMPT}', 'priority' => 60 ], 'open-grok' => [ 'id' => 'open-grok', 'label' => __( 'Open in Grok', 'betterdocs' ), 'description' => $ask, 'icon' => 'grok', 'setting_key' => 'ai_actions_grok', 'href_template' => 'https://grok.com/?q={PROMPT}', 'priority' => 70 ] ]; /** * Filter the AI Actions registry. * * Add an element to put a new item in the dropdown. A `link` action needs no * JavaScript at all; a `copy` action needs a matching handler registered on * `window.betterdocsAIActionHandlers`. * * @since 4.8.0 * * @param array $actions id => definition * @param Settings $settings */ return (array) apply_filters( 'betterdocs_ai_actions', $actions, $this->settings ); } /** * Registry, reduced to what this doc should actually show, with every URL * resolved. * * `$args` lets one caller — a block, a widget, a shortcode — configure its own * instance without touching the global settings. Every key is optional and a * blank one means "inherit", so an untouched control falls through to the * Settings panel rather than overriding it with emptiness. * * @param \WP_Post|int|null $post * @param array $args { * @type string $prompt_template Overrides `ai_actions_prompt_template`. * @type array $enabled_actions Action id => bool, overriding that action's * `setting_key`. * } * @return array */ public function resolve( $post, $args = [] ) { $post = get_post( $post ); if ( ! $post instanceof \WP_Post || ! $this->renderer->can_read( $post ) ) { return []; } $md_url = $this->endpoint->url( $post ); $page_url = get_permalink( $post ); $template = isset( $args['prompt_template'] ) ? (string) $args['prompt_template'] : ''; // Blank means inherit, at both levels: an untouched block control falls // through to the setting, and a cleared setting falls through to the string // the feature shipped with. if ( '' === trim( $template ) ) { $template = (string) $this->settings->get( 'ai_actions_prompt_template' ); } if ( '' === trim( $template ) ) { $template = __( 'Read from {URL} so I can ask questions about it.', 'betterdocs' ); } $resolved = []; foreach ( $this->registry() as $id => $action ) { $action = wp_parse_args( $action, $this->defaults() ); $action['id'] = '' !== $action['id'] ? $action['id'] : $id; if ( ! $this->action_enabled( $action, $args ) ) { continue; } // An action that needs the Markdown address is meaningless when the // endpoint is off or the doc has no Markdown URL. if ( 'md' === $action['source'] && '' === $md_url ) { continue; } $url = 'page' === $action['source'] ? $page_url : $md_url; $prompt = str_replace( '{URL}', $url, $template ); /** * Filter the prompt handed to an AI assistant. * * @since 4.8.0 * * @param string $prompt * @param array $action * @param \WP_Post $post */ $prompt = (string) apply_filters( 'betterdocs_ai_actions_prompt', $prompt, $action, $post ); $action['href'] = strtr( $action['href_template'], [ '{PROMPT}' => rawurlencode( $prompt ), '{URL}' => $url, '{MD_URL}' => $md_url, '{PAGE_URL}' => $page_url, '{TITLE}' => rawurlencode( get_the_title( $post ) ) ] ); $resolved[ $action['id'] ] = $action; } uasort( $resolved, function ( $a, $b ) { return (int) $a['priority'] - (int) $b['priority']; } ); /** * Last chance to alter the rendered action list for a doc. * * @since 4.8.0 * @since 4.9.1 `$args` added. * * @param array $resolved * @param \WP_Post $post * @param array $args Per-instance overrides from the calling surface. */ return (array) apply_filters( 'betterdocs_ai_actions_resolved', $resolved, $post, $args ); } /** * The registry, normalised and ordered, for a surface that has no doc to * resolve against. * * The block editor is the caller: it has to draw the dropdown so the author can * see what each toggle does, but there is no post to build hrefs from and the * preview must never navigate anywhere. So this is everything the menu needs to * be *drawn* — label, description, icon markup, whether it owns the primary half * — and nothing it would need to be *used*. * * Order and `setting_key` come straight from registry(), so an entry a third * party adds through `betterdocs_ai_actions` shows up in the editor too. * * @since 4.9.1 * * @return array[] Ordered list, lowest priority first. */ public function catalog() { $catalog = []; foreach ( $this->registry() as $id => $action ) { $action = wp_parse_args( $action, $this->defaults() ); $catalog[] = [ 'id' => '' !== $action['id'] ? $action['id'] : $id, 'label' => (string) $action['label'], 'description' => (string) $action['description'], 'icon' => self::icon( $action['icon'] ), 'setting_key' => (string) $action['setting_key'], 'primary' => (bool) $action['primary'], // Only a link can open a tab. The editor no longer draws an arrow for // it — neither does the frontend — but the flag still says which rows // leave the site, and a third-party preview may want it. 'external' => 'copy' !== $action['type'] && '_blank' === $action['target'], // So the editor preview can draw the same group rule as the frontend. 'group' => (string) $action['group'], 'priority' => (int) $action['priority'] ]; } usort( $catalog, function ( $a, $b ) { return $a['priority'] - $b['priority']; } ); return $catalog; } /** * Is this action switched on? * * An action whose `setting_key` is empty, or is not a key BetterDocs registers a * default for, is treated as always on. That is what makes a third-party * registry entry work without also registering a setting. * * Settings::get() is deliberately called with no `$default` argument: passing * one overrides the registered default, and `betterdocs_settings` only ever * contains keys the user has actually saved. * * A caller's `enabled_actions` map wins over both, and is keyed on the action id * rather than on `setting_key` so that a third-party registry entry with no * setting at all is still switchable per instance. * * @param array $action * @param array $args * @return bool */ protected function action_enabled( $action, $args = [] ) { if ( isset( $args['enabled_actions'][ $action['id'] ] ) ) { return (bool) $args['enabled_actions'][ $action['id'] ]; } $key = $action['setting_key']; if ( '' === $key ) { return true; } $registered = array_merge( $this->settings->get_default(), $this->settings->get_pro_defaults() ); if ( ! array_key_exists( $key, $registered ) ) { return true; } return (bool) $this->settings->get( $key ); } /** * Will render() actually output anything for this doc? * * Templates need this before they commit to a layout: every action can be * switched off individually, and a doc the reader may not read resolves to * nothing at all, so "the feature is on" is not the same as "there is a button". * * @param \WP_Post|int|null $post * @param array $args Per-instance overrides, as for resolve(). An * `enable` of false answers false outright. * @return bool */ public function has_actions( $post = null, $args = [] ) { if ( isset( $args['enable'] ) && ! $args['enable'] ) { return false; } if ( ! $this->is_enabled() ) { return false; } $post = get_post( $post ); return $post instanceof \WP_Post && ! empty( $this->resolve( $post, $args ) ); } /** * Render the button for a doc. * * @param \WP_Post|int|null $post * @param array $extra { * View params, plus the per-instance overrides resolve() understands. * * @type bool $enable Switch this instance off. Cannot switch it * *on* — see below. * @type string $button_label Overrides `ai_actions_button_label`. * @type string $prompt_template Overrides `ai_actions_prompt_template`. * @type array $enabled_actions Action id => bool. * @type string $widget_type * @type string $blockId * } */ public function render( $post = null, $extra = [] ) { $post = get_post( $post ); if ( ! $post instanceof \WP_Post ) { return; } // A surface may switch its own instance off, but never on: the global stays a // hard kill switch, so turning the feature off in Settings cannot be undone // by a block somebody placed a year ago and forgot about. if ( isset( $extra['enable'] ) && ! $extra['enable'] ) { return; } if ( ! $this->is_enabled() ) { return; } $actions = $this->resolve( $post, $extra ); if ( empty( $actions ) ) { return; } // Views is a shared singleton whose params are sticky across calls, so every // key this template reads has to be passed explicitly — otherwise a block // rendered earlier on the page leaks its blockId into the classic instance. // For the same reason the resolve-only keys are dropped here rather than // forwarded: no template reads them, so nothing should keep them alive. $this->views->get( 'templates/parts/ai-actions', wp_parse_args( array_diff_key( $extra, array_flip( self::$resolve_keys ) ), [ 'enable' => true, 'actions' => $actions, 'md_url' => $this->endpoint->url( $post ), // The clipboard gets the human-facing profile — no YAML front // matter, since this is about to be pasted into a chat box. 'copy_url' => $this->endpoint->url( $post, 'copy' ), 'page_url' => get_permalink( $post ), 'doc_id' => (int) $post->ID, 'uid' => wp_unique_id( 'betterdocs-ai-actions-' ), 'button_label' => (string) $this->settings->get( 'ai_actions_button_label' ), 'widget_type' => '', 'blockId' => '' ] ) ); } /** * Inline SVG for an icon key. An unrecognised key that looks like markup is * returned as-is (so a registry entry can supply its own), otherwise the generic * sparkle is used. * * @param string $key * @return string */ public static function icon( $key ) { if ( false !== strpos( (string) $key, ' '2.4', 'check' => '2.4' ]; $stroke = isset( $strokes[ $key ] ) ? $strokes[ $key ] : '2'; $open = ''; } /** * wp_kses allowlist for action icons, including SVGs supplied by third-party * registry entries. * * Attribute names are lower-cased by wp_kses, so `viewBox` is listed as * `viewbox` — which is what HTML parsing expects anyway. * * @return array */ public static function svg_kses() { $shape = [ 'fill' => [], 'fill-rule' => [], 'fill-opacity' => [], 'clip-rule' => [], 'clip-path' => [], 'stroke' => [], 'stroke-width' => [], 'stroke-linecap' => [], 'stroke-linejoin' => [], 'stroke-dasharray' => [], 'opacity' => [], 'transform' => [], 'class' => [], 'style' => [], 'mask' => [], 'id' => [] ]; return [ 'svg' => array_merge( $shape, [ 'xmlns' => [], 'viewbox' => [], 'width' => [], 'height' => [], 'aria-hidden' => [], 'role' => [], 'focusable' => [] ] ), 'g' => $shape, 'path' => array_merge( $shape, [ 'd' => [] ] ), 'rect' => array_merge( $shape, [ 'x' => [], 'y' => [], 'width' => [], 'height' => [], 'rx' => [], 'ry' => [] ] ), 'circle' => array_merge( $shape, [ 'cx' => [], 'cy' => [], 'r' => [] ] ), 'ellipse' => array_merge( $shape, [ 'cx' => [], 'cy' => [], 'rx' => [], 'ry' => [] ] ), 'line' => array_merge( $shape, [ 'x1' => [], 'y1' => [], 'x2' => [], 'y2' => [] ] ), 'polyline' => array_merge( $shape, [ 'points' => [] ] ), 'polygon' => array_merge( $shape, [ 'points' => [] ] ), 'mask' => array_merge( $shape, [ 'maskunits' => [], 'x' => [], 'y' => [], 'width' => [], 'height' => [] ] ), 'defs' => [], 'clippath' => [ 'id' => [] ], 'title' => [], 'desc' => [] ]; } }