PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.2
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.2
4.9.2 4.9.1 4.9.0 4.8.2 4.8.1 4.8.0 4.7.0 4.6.2 4.6.1 4.6.0 4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 4.5.1 4.5.0 4.4.1 4.4.0 3.3.4 3.4.0 3.4.1 3.4.2 3.5.0 3.5.1 All 200 releases
← All changes | includes/Utils/Helper.php +523 -7 4.5.54.9.2 View file →
@@ -20,10 +20,16 @@
20 20
21 21 class Helper extends Base {
22 22
23 23 /**
24 - * Mask an API key for safe display: first 3 chars + 8 asterisks + last 4 chars.
25 - * Fixed asterisk count avoids leaking the real key length.
24 + * Mask an API key for safe display.
25 + *
26 + * Prefix-aware: when the key carries a recognizable provider prefix
27 + * (OpenAI sk-/sk-proj-, Anthropic sk-ant-/sk-ant-api03-, Gemini AIza) that
28 + * prefix is kept visible so an admin can tell which provider/key is set,
29 + * then a fixed 8-asterisk block, then the last 4 chars. Keys without a known
30 + * prefix fall back to first 3 + 8 asterisks + last 4. The asterisk count is
31 + * always fixed so the real key length is never leaked.
26 32 */
27 33 public static function mask_api_key( $key ) {
28 34 if ( ! is_string( $key ) || $key === '' ) {
29 35 return '';
@@ -28,8 +34,21 @@
28 34 if ( ! is_string( $key ) || $key === '' ) {
29 35 return '';
30 36 }
31 37 $key = trim( $key );
38 + if ( $key === '' ) {
39 + return '';
40 + }
41 +
42 + // Longest prefixes first so sk-proj-/sk-ant- win over the bare sk-.
43 + $prefixes = array( 'sk-ant-api03-', 'sk-ant-', 'sk-proj-', 'sk-', 'AIza' );
44 + foreach ( $prefixes as $prefix ) {
45 + if ( strncmp( $key, $prefix, strlen( $prefix ) ) === 0
46 + && strlen( $key ) >= strlen( $prefix ) + 4 ) {
47 + return $prefix . str_repeat( '*', 8 ) . substr( $key, -4 );
48 + }
49 + }
50 +
32 51 if ( strlen( $key ) < 8 ) {
33 52 return str_repeat( '*', strlen( $key ) );
34 53 }
35 54 return substr( $key, 0, 3 ) . str_repeat( '*', 8 ) . substr( $key, -4 );
@@ -34,8 +53,35 @@
34 53 }
35 54 return substr( $key, 0, 3 ) . str_repeat( '*', 8 ) . substr( $key, -4 );
36 55 }
37 56
57 + /**
58 + * Resolve the WPML-translated base slug of a taxonomy for the CURRENT language.
59 + *
60 + * WPML registers each translatable taxonomy's rewrite slug as a string named
61 + * "URL <taxonomy> tax slug" in the "WordPress" domain (e.g. "URL doc_tag tax slug").
62 + * BetterDocs stores only the default-language slug in its settings, so routing and
63 + * term links must read the translated value back here. Returns the trimmed default
64 + * slug unchanged when WPML is inactive or the string has no translation.
65 + *
66 + * @param string $taxonomy Taxonomy key, e.g. 'doc_tag'.
67 + * @param string $default_slug Default-language base slug from settings.
68 + * @return string Translated base slug for the active language (falls back to default).
69 + */
70 + public static function wpml_translated_tax_slug( $taxonomy, $default_slug ) {
71 + $default_slug = trim( (string) $default_slug, '/' );
72 +
73 + if ( $default_slug === '' || ! has_filter( 'wpml_translate_single_string' ) ) {
74 + return $default_slug;
75 + }
76 +
77 + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WPML-owned filter; name must be used verbatim.
78 + $translated = apply_filters( 'wpml_translate_single_string', $default_slug, 'WordPress', 'URL ' . $taxonomy . ' tax slug' );
79 + $translated = trim( (string) $translated, '/' );
80 +
81 + return $translated !== '' ? $translated : $default_slug;
82 + }
83 +
38 84 public static function get_plugins( $plugin_basename = null ) {
39 85 if ( ! function_exists( 'get_plugins' ) ) {
40 86 include_once ABSPATH . 'wp-admin/includes/plugin.php';
41 87 }
@@ -51,8 +97,41 @@
51 97
52 98 return is_plugin_active( $plugin_basename );
53 99 }
54 100
101 + /**
102 + * Whether an SEO plugin already emits FAQPage schema on the current page.
103 + *
104 + * True only when Yoast or Rank Math is active AND its FAQ block is present
105 + * in the post's content, so BetterDocs can skip its own FAQPage JSON-LD and
106 + * avoid duplicate structured data. Defaults to the queried object when no
107 + * post is given.
108 + *
109 + * @param int|\WP_Post|null $post
110 + * @return bool
111 + */
112 + public static function seo_plugin_outputs_faq_schema( $post = null ) {
113 + if ( null === $post ) {
114 + $post = get_queried_object();
115 + }
116 +
117 + $post = get_post( $post );
118 + if ( ! $post instanceof \WP_Post ) {
119 + return false;
120 + }
121 +
122 + if ( self::is_plugin_active( 'wordpress-seo/wp-seo.php' ) && has_block( 'yoast/faq-block', $post ) ) {
123 + return true;
124 + }
125 +
126 + if ( self::is_plugin_active( 'seo-by-rank-math/rank-math.php' ) && has_block( 'rank-math/faq-block', $post ) ) {
127 + return true;
128 + }
129 +
130 + // Extension seam for Pro / other SEO integrations.
131 + return (bool) apply_filters( 'betterdocs_seo_plugin_outputs_faq_schema', false, $post );
132 + }
133 +
55 134 public static function get_tax( $tax = '' ) {
56 135 global $wp_query;
57 136
58 137 if ( is_tax( 'knowledge_base' ) ) {
@@ -268,8 +347,58 @@
268 347 ( class_exists( 'TRP_Translate_Press' ) && function_exists( 'trp_get_current_language' ) );
269 348 }
270 349
271 350 /**
351 + * Configured/active languages from whichever multilingual plugin is present.
352 + *
353 + * Returns a list of { value, label } pairs (language code + display name).
354 + * Used to populate the optional language selector in the Write-with-AI modal;
355 + * returns an empty array when no multilingual plugin is active so the
356 + * selector stays hidden. Mirrors the Pro cross-domain language options.
357 + *
358 + * @return array<int,array{value:string,label:string}>
359 + */
360 + public static function get_active_languages() {
361 + $options = array();
362 +
363 + // WPML
364 + if ( is_plugin_active( 'sitepress-multilingual-cms/sitepress.php' ) ) {
365 + global $sitepress;
366 + if ( $sitepress && method_exists( $sitepress, 'get_active_languages' ) ) {
367 + $active_languages = $sitepress->get_active_languages();
368 + if ( is_array( $active_languages ) ) {
369 + foreach ( $active_languages as $code => $lang ) {
370 + $options[] = array(
371 + 'value' => (string) $code,
372 + 'label' => isset( $lang['native_name'] ) ? $lang['native_name'] : (string) $code,
373 + );
374 + }
375 + }
376 + }
377 + } elseif ( function_exists( 'pll_languages_list' ) ) {
378 + // Polylang
379 + $languages = pll_languages_list( array( 'fields' => array() ) );
380 + if ( is_array( $languages ) ) {
381 + foreach ( $languages as $lang ) {
382 + if ( is_object( $lang ) && isset( $lang->slug ) ) {
383 + $options[] = array(
384 + 'value' => (string) $lang->slug,
385 + 'label' => isset( $lang->name ) ? $lang->name : (string) $lang->slug,
386 + );
387 + }
388 + }
389 + }
390 + }
391 +
392 + /**
393 + * Filter the language options exposed to the Write-with-AI modal.
394 + *
395 + * @param array $options List of { value, label } language pairs.
396 + */
397 + return apply_filters( 'betterdocs_active_languages', $options );
398 + }
399 +
400 + /**
272 401 * Check if we should apply language filtering
273 402 * Only apply on frontend or when specifically requested
274 403 *
275 404 * @return bool
@@ -308,9 +437,9 @@
308 437 // otherwise resolve to the site's default language instead of the
309 438 // admin UI language.
310 439 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only UI language hint, sanitized; not a state-changing form submission.
311 440 if ( isset( $_POST['lang'] ) && ! empty( $_POST['lang'] ) ) {
312 - return sanitize_text_field( wp_unslash( $_POST['lang'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- see note above.
441 + return self::sanitize_language_code( wp_unslash( $_POST['lang'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- see note above.
313 442 }
314 443
315 444 // Limit GET handling to admin/REST contexts so a frontend ?lang= switch
316 445 // doesn't hijack admin meta-key resolution.
@@ -315,9 +444,9 @@
315 444 // Limit GET handling to admin/REST contexts so a frontend ?lang= switch
316 445 // doesn't hijack admin meta-key resolution.
317 446 if ( isset( $_GET['lang'] ) && ! empty( $_GET['lang'] )
318 447 && ( is_admin() || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) ) {
319 - return sanitize_text_field( wp_unslash( $_GET['lang'] ) );
448 + return self::sanitize_language_code( wp_unslash( $_GET['lang'] ) );
320 449 }
321 450
322 451 // WPML Support - Admin language detection
323 452 if ( is_plugin_active( 'sitepress-multilingual-cms/sitepress.php' ) ) {
@@ -384,12 +513,32 @@
384 513 elseif ( class_exists( 'TRP_Translate_Press' ) && function_exists( 'trp_get_current_language' ) ) {
385 514 $current_language = trp_get_current_language();
386 515 }
387 516
388 - return $current_language;
517 + return self::sanitize_language_code( $current_language );
389 518 }
390 519
391 520 /**
521 + * Normalize a language code to the character set real language codes use
522 + * (`en`, `en_US`, `zh-Hans`). Values reach this from `?lang=`, `$_POST['lang']`
523 + * and the WPML admin cookie, and `sanitize_text_field()` leaves quotes intact —
524 + * so anything used to build a meta key or SQL fragment must be narrowed here.
525 + * Defense in depth: callers that reach SQL must still bind their values.
526 + *
527 + * @param string|null $language Raw language code.
528 + * @return string|null Normalized code, or null when nothing usable remains.
529 + */
530 + private static function sanitize_language_code( $language ) {
531 + if ( ! is_string( $language ) || '' === $language ) {
532 + return null;
533 + }
534 +
535 + $language = preg_replace( '/[^A-Za-z0-9_-]/', '', $language );
536 +
537 + return '' !== $language ? $language : null;
538 + }
539 +
540 + /**
392 541 * Generate language-specific meta key for category ordering
393 542 * Always falls back to base key if language-specific key doesn't exist
394 543 *
395 544 * @param string $base_key The base meta key (e.g., 'doc_category_order')
@@ -650,8 +799,287 @@
650 799
651 800 return $languages;
652 801 }
653 802
803 + /**
804 + * Rich list of active site languages for the React admin language bar.
805 + *
806 + * @return array<int,array{code:string,label:string,native:string,flag:string}>
807 + * Empty when no supported multilingual plugin is active.
808 + */
809 + public static function get_admin_languages() {
810 + $languages = [];
811 +
812 + // WPML
813 + if ( is_plugin_active( 'sitepress-multilingual-cms/sitepress.php' ) ) {
814 + global $sitepress;
815 + if ( $sitepress && $sitepress->is_setup_complete() ) {
816 + $active = $sitepress->get_active_languages();
817 + if ( is_array( $active ) ) {
818 + foreach ( $active as $code => $lang ) {
819 + $languages[] = [
820 + 'code' => $code,
821 + 'label' => isset( $lang['english_name'] ) ? $lang['english_name'] : $code,
822 + 'native' => isset( $lang['native_name'] ) ? $lang['native_name'] : ( isset( $lang['display_name'] ) ? $lang['display_name'] : $code ),
823 + 'flag' => isset( $lang['country_flag_url'] ) ? $lang['country_flag_url'] : '',
824 + ];
825 + }
826 + }
827 + }
828 + }
829 + // Polylang
830 + elseif ( function_exists( 'pll_languages_list' ) ) {
831 + $list = pll_languages_list( [ 'fields' => '' ] ); // full PLL_Language objects
832 + if ( is_array( $list ) ) {
833 + foreach ( $list as $lang ) {
834 + if ( ! is_object( $lang ) ) {
835 + continue;
836 + }
837 + $languages[] = [
838 + 'code' => isset( $lang->slug ) ? $lang->slug : '',
839 + 'label' => isset( $lang->name ) ? $lang->name : ( isset( $lang->slug ) ? $lang->slug : '' ),
840 + 'native' => isset( $lang->name ) ? $lang->name : '',
841 + 'flag' => isset( $lang->flag_url ) ? $lang->flag_url : '',
842 + ];
843 + }
844 + }
845 + }
846 +
847 + return $languages;
848 + }
849 +
850 + /**
851 + * Read a term's language code via the active multilingual plugin.
852 + *
853 + * @param \WP_Term $term
854 + * @return string Language code, or '' when unavailable.
855 + */
856 + public static function get_term_language( $term ) {
857 + if ( ! is_object( $term ) || empty( $term->term_id ) ) {
858 + return '';
859 + }
860 +
861 + // Polylang — takes the term_id.
862 + if ( function_exists( 'pll_get_term_language' ) ) {
863 + $lang = pll_get_term_language( $term->term_id, 'slug' );
864 + return $lang ? $lang : '';
865 + }
866 +
867 + // WPML — element_id is the term_taxonomy_id (NOT the term_id); WPML
868 + // normalizes the element_type to `tax_<taxonomy>` internally.
869 + if ( is_plugin_active( 'sitepress-multilingual-cms/sitepress.php' ) && ! empty( $term->term_taxonomy_id ) ) {
870 + $lang = apply_filters( 'wpml_element_language_code', null, [
871 + 'element_id' => $term->term_taxonomy_id,
872 + 'element_type' => isset( $term->taxonomy ) ? $term->taxonomy : 'doc_category',
873 + ] );
874 + return $lang ? $lang : '';
875 + }
876 +
877 + return '';
878 + }
879 +
880 + /**
881 + * Stamp a term's language via the active multilingual plugin. Standalone
882 + * assignment only — it sets/re-stamps the term's own language and does not
883 + * link it into an existing translation group.
884 + *
885 + * @param \WP_Term $term
886 + * @param string $lang_code
887 + */
888 + public static function set_term_language( $term, $lang_code ) {
889 + $lang_code = sanitize_text_field( (string) $lang_code );
890 + if ( $lang_code === '' || ! is_object( $term ) || empty( $term->term_id ) ) {
891 + return;
892 + }
893 +
894 + // Polylang
895 + if ( function_exists( 'pll_set_term_language' ) ) {
896 + pll_set_term_language( $term->term_id, $lang_code );
897 + return;
898 + }
899 +
900 + // WPML — element_id is the term_taxonomy_id; element_type is tax_<taxonomy>;
901 + // trid=null sets it as a standalone original in the chosen language.
902 + if ( is_plugin_active( 'sitepress-multilingual-cms/sitepress.php' ) && ! empty( $term->term_taxonomy_id ) ) {
903 + $taxonomy = isset( $term->taxonomy ) ? $term->taxonomy : 'doc_category';
904 + do_action( 'wpml_set_element_language_details', [
905 + 'element_id' => $term->term_taxonomy_id,
906 + 'element_type' => 'tax_' . $taxonomy,
907 + 'trid' => null,
908 + 'language_code' => $lang_code,
909 + 'source_language_code' => null,
910 + ] );
911 + }
912 + }
913 +
914 + /**
915 + * The site's default language code, or '' when no multilingual plugin is active.
916 + */
917 + public static function get_default_language() {
918 + if ( is_plugin_active( 'sitepress-multilingual-cms/sitepress.php' ) ) {
919 + global $sitepress;
920 + if ( $sitepress ) {
921 + return (string) $sitepress->get_default_language();
922 + }
923 + }
924 + if ( function_exists( 'pll_default_language' ) ) {
925 + return (string) pll_default_language( 'slug' );
926 + }
927 + return '';
928 + }
929 +
930 + /**
931 + * All terms in a term's translation group, keyed by language code.
932 + *
933 + * @param \WP_Term $term
934 + * @return array<string,array{term_id:int,name:string}>
935 + */
936 + public static function get_term_translations( $term ) {
937 + if ( ! is_object( $term ) || empty( $term->term_id ) ) {
938 + return [];
939 + }
940 + $taxonomy = isset( $term->taxonomy ) ? $term->taxonomy : 'doc_category';
941 + $out = [];
942 +
943 + // Polylang
944 + if ( function_exists( 'pll_get_term_translations' ) ) {
945 + $group = pll_get_term_translations( $term->term_id ); // [lang => term_id]
946 + if ( is_array( $group ) ) {
947 + foreach ( $group as $lang => $tid ) {
948 + $t = get_term( (int) $tid, $taxonomy );
949 + if ( $t && ! is_wp_error( $t ) ) {
950 + $out[ $lang ] = [ 'term_id' => (int) $tid, 'name' => $t->name ];
951 + }
952 + }
953 + }
954 + return $out;
955 + }
956 +
957 + // WPML
958 + if ( is_plugin_active( 'sitepress-multilingual-cms/sitepress.php' ) && ! empty( $term->term_taxonomy_id ) ) {
959 + $el_type = 'tax_' . $taxonomy;
960 + $trid = apply_filters( 'wpml_element_trid', null, $term->term_taxonomy_id, $el_type );
961 + if ( ! $trid ) {
962 + return $out;
963 + }
964 + $translations = apply_filters( 'wpml_get_element_translations', null, $trid, $el_type );
965 + if ( is_array( $translations ) ) {
966 + foreach ( $translations as $lang => $tr ) {
967 + $tid = isset( $tr->term_id ) ? (int) $tr->term_id : 0;
968 + if ( ! $tid ) {
969 + continue;
970 + }
971 + $t = get_term( $tid, $taxonomy );
972 + $out[ $lang ] = [
973 + 'term_id' => $tid,
974 + 'name' => ( $t && ! is_wp_error( $t ) ) ? $t->name : ( isset( $tr->name ) ? $tr->name : '' ),
975 + ];
976 + }
977 + }
978 + }
979 +
980 + return $out;
981 + }
982 +
983 + /**
984 + * Candidate source terms for the "This is a translation of" dropdown — terms in
985 + * $source_lang (default language) that aren't yet translated into $target_lang.
986 + *
987 + * @return array<int,array{term_id:int,name:string}>
988 + */
989 + public static function get_translation_candidates( $taxonomy, $target_lang, $source_lang ) {
990 + $candidates = [];
991 + $target_lang = sanitize_text_field( (string) $target_lang );
992 + $source_lang = sanitize_text_field( (string) $source_lang );
993 + if ( $taxonomy === '' || $source_lang === '' ) {
994 + return $candidates;
995 + }
996 +
997 + // WPML
998 + if ( is_plugin_active( 'sitepress-multilingual-cms/sitepress.php' ) ) {
999 + global $sitepress;
1000 + if ( $sitepress && method_exists( $sitepress, 'get_elements_without_translations' ) ) {
1001 + $ttids = $sitepress->get_elements_without_translations( 'tax_' . $taxonomy, $target_lang, $source_lang );
1002 + foreach ( (array) $ttids as $ttid ) {
1003 + $t = get_term_by( 'term_taxonomy_id', (int) $ttid, $taxonomy );
1004 + if ( $t && ! is_wp_error( $t ) ) {
1005 + $candidates[] = [ 'term_id' => (int) $t->term_id, 'name' => $t->name ];
1006 + }
1007 + }
1008 + }
1009 + return $candidates;
1010 + }
1011 +
1012 + // Polylang — source-lang terms whose group lacks the target language.
1013 + if ( function_exists( 'pll_get_term_translations' ) && function_exists( 'pll_get_term_language' ) ) {
1014 + $terms = get_terms( [ 'taxonomy' => $taxonomy, 'hide_empty' => false, 'lang' => $source_lang ] );
1015 + foreach ( (array) $terms as $t ) {
1016 + if ( is_wp_error( $t ) ) {
1017 + continue;
1018 + }
1019 + $group = pll_get_term_translations( $t->term_id );
1020 + if ( ! isset( $group[ $target_lang ] ) ) {
1021 + $candidates[] = [ 'term_id' => (int) $t->term_id, 'name' => $t->name ];
1022 + }
1023 + }
1024 + }
1025 +
1026 + return $candidates;
1027 + }
1028 +
1029 + /**
1030 + * Set a term's language and (optionally) link it into the translation group of
1031 + * $translation_of_term_id. Empty $translation_of_term_id = standalone.
1032 + *
1033 + * @param \WP_Term $term
1034 + * @param string $lang_code
1035 + * @param int $translation_of_term_id
1036 + */
1037 + public static function link_term_translation( $term, $lang_code, $translation_of_term_id = 0 ) {
1038 + $lang_code = sanitize_text_field( (string) $lang_code );
1039 + if ( $lang_code === '' || ! is_object( $term ) || empty( $term->term_id ) ) {
1040 + return;
1041 + }
1042 + $taxonomy = isset( $term->taxonomy ) ? $term->taxonomy : 'doc_category';
1043 + $translation_of_term_id = (int) $translation_of_term_id;
1044 +
1045 + // Polylang
1046 + if ( function_exists( 'pll_set_term_language' ) ) {
1047 + pll_set_term_language( $term->term_id, $lang_code );
1048 + if ( $translation_of_term_id && function_exists( 'pll_save_term_translations' ) ) {
1049 + $group = function_exists( 'pll_get_term_translations' )
1050 + ? (array) pll_get_term_translations( $translation_of_term_id )
1051 + : [];
1052 + $group[ $lang_code ] = $term->term_id;
1053 + pll_save_term_translations( $group );
1054 + }
1055 + return;
1056 + }
1057 +
1058 + // WPML
1059 + if ( is_plugin_active( 'sitepress-multilingual-cms/sitepress.php' ) && ! empty( $term->term_taxonomy_id ) ) {
1060 + $el_type = 'tax_' . $taxonomy;
1061 + $trid = null;
1062 + $src = null;
1063 +
1064 + if ( $translation_of_term_id ) {
1065 + $source = get_term( $translation_of_term_id, $taxonomy );
1066 + if ( $source && ! is_wp_error( $source ) ) {
1067 + $trid = apply_filters( 'wpml_element_trid', null, $source->term_taxonomy_id, $el_type );
1068 + $src = self::get_term_language( $source );
1069 + }
1070 + }
1071 +
1072 + do_action( 'wpml_set_element_language_details', [
1073 + 'element_id' => $term->term_taxonomy_id,
1074 + 'element_type' => $el_type,
1075 + 'trid' => $trid,
1076 + 'language_code' => $lang_code,
1077 + 'source_language_code' => $src,
1078 + ] );
1079 + }
1080 + }
1081 +
654 1082 public static function get_current_letter_docs( $current_letter, $limit = 0 ) {
655 1083 global $wpdb;
656 1084
657 1085 $limit = absint( $limit );
@@ -958,16 +1386,22 @@
958 1386 ] );
959 1387 return isset( $terms[0] ) ? $terms[0] : [];
960 1388 }
961 1389
962 - public static function delete_specific_faq_posts_by_faq_category( $term_id ) {
1390 + public static function delete_specific_faq_posts_by_faq_category( $term_id, $taxonomy = 'betterdocs_faq_category' ) {
963 1391 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- targeted bulk delete by FAQ category; tax filter is required.
964 1392 $args = [
965 1393 'post_type' => 'betterdocs_faq',
966 1394 'posts_per_page' => -1,
1395 + // EVERY status, explicitly. WP_Query defaults to 'publish', so "delete this
1396 + // group and its FAQs" was deleting only the published ones — the drafts (and
1397 + // pending/scheduled/private/trashed FAQs) survived, and the wp_delete_term()
1398 + // that follows then stripped their category, leaving them orphaned under
1399 + // "Uncategorized". Note 'any' is NOT enough here: it excludes trash.
1400 + 'post_status' => [ 'publish', 'draft', 'pending', 'future', 'private', 'trash' ],
967 1401 'tax_query' => [
968 1402 [
969 - 'taxonomy' => 'betterdocs_faq_category',
1403 + 'taxonomy' => $taxonomy,
970 1404 'field' => 'id',
971 1405 'terms' => $term_id,
972 1406 'operator' => 'IN'
973 1407 ]
@@ -1063,8 +1497,9 @@
1063 1497 'json' => '📋',
1064 1498 'yaml' => '📋',
1065 1499 'xml' => '📄',
1066 1500 'markdown' => '📝',
1501 + 'curl' => '💻',
1067 1502 'bash' => '💻',
1068 1503 'shell' => '💻',
1069 1504 'powershell' => '💻',
1070 1505 'dockerfile' => '🐳',
@@ -1070,8 +1505,89 @@
1070 1505 'dockerfile' => '🐳',
1071 1506 ];
1072 1507
1073 1508 return isset( $icons[$language] ) ? $icons[$language] : '📄';
1509 + }
1510 +
1511 + /**
1512 + * Echo the copy-to-clipboard button used by the Code Snippet and Code
1513 + * Snippet Tab templates.
1514 + *
1515 + * Both icons ship in the markup and CSS cross-fades between them on
1516 + * `.is-copied`, so the frontend script never rewrites the SVG. The tooltip
1517 + * carries its own strings as data attributes so the script can swap
1518 + * "Copy" → "Copied!" without hard-coding English.
1519 + *
1520 + * @return void
1521 + */
1522 + public static function code_snippet_copy_button() {
1523 + ?>
1524 + <div class="betterdocs-code-snippet-copy-container">
1525 + <button class="betterdocs-code-snippet-copy-button"
1526 + type="button"
1527 + aria-label="<?php esc_attr_e( 'Copy code to clipboard', 'betterdocs' ); ?>">
1528 + <span class="betterdocs-code-snippet-copy-icon" aria-hidden="true">
1529 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
1530 + <rect x="9" y="9" width="12.5" height="12.5" rx="3" stroke="currentColor" stroke-width="1.7"/>
1531 + <path d="M15.5 5.75V5A2.5 2.5 0 0 0 13 2.5H5A2.5 2.5 0 0 0 2.5 5v8A2.5 2.5 0 0 0 5 15.5h.75" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/>
1532 + </svg>
1533 + </span>
1534 + <span class="betterdocs-code-snippet-copied-icon" aria-hidden="true">
1535 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
1536 + <path d="M20 6.5 9.5 17 4 11.5" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
1537 + </svg>
1538 + </span>
1539 + </button>
1540 + <span class="betterdocs-code-snippet-tooltip"
1541 + role="status"
1542 + data-copy-label="<?php esc_attr_e( 'Copy', 'betterdocs' ); ?>"
1543 + data-copied-label="<?php esc_attr_e( 'Copied!', 'betterdocs' ); ?>"
1544 + data-error-label="<?php esc_attr_e( 'Copy failed', 'betterdocs' ); ?>"><?php esc_html_e( 'Copy', 'betterdocs' ); ?></span>
1545 + </div>
1546 + <?php
1547 + }
1548 +
1549 + /**
1550 + * Human-readable label for a programming-language identifier, used as the
1551 + * language-dropdown label on multi-language code snippets. Mirrors the
1552 + * block's LANGUAGE_OPTIONS; falls back to an upper-cased identifier.
1553 + *
1554 + * @param string $language Programming language identifier
1555 + * @return string
1556 + */
1557 + public static function get_language_label( $language ) {
1558 + $labels = [
1559 + 'javascript' => 'JavaScript',
1560 + 'typescript' => 'TypeScript',
1561 + 'php' => 'PHP',
1562 + 'python' => 'Python',
1563 + 'java' => 'Java',
1564 + 'ruby' => 'Ruby',
1565 + 'curl' => 'cURL',
1566 + 'bash' => 'Bash',
1567 + 'shell' => 'Shell',
1568 + 'json' => 'JSON',
1569 + 'yaml' => 'YAML',
1570 + 'html' => 'HTML',
1571 + 'css' => 'CSS',
1572 + 'scss' => 'SCSS',
1573 + 'sql' => 'SQL',
1574 + 'xml' => 'XML',
1575 + 'cpp' => 'C++',
1576 + 'csharp' => 'C#',
1577 + 'c' => 'C',
1578 + 'go' => 'Go',
1579 + 'rust' => 'Rust',
1580 + 'swift' => 'Swift',
1581 + 'kotlin' => 'Kotlin',
1582 + 'markdown' => 'Markdown'
1583 + ];
1584 +
1585 + if ( isset( $labels[ $language ] ) ) {
1586 + return $labels[ $language ];
1587 + }
1588 +
1589 + return ucwords( str_replace( [ '-', '_' ], ' ', (string) $language ) );
1074 1590 }
1075 1591
1076 1592 /**
1077 1593 * Check if AI Chatbot is enabled