PluginProbe
King Addons for Elementor – 100+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce Builder, Mega Menu, Popup Builder / 51.1.83
King Addons for Elementor – 100+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce Builder, Mega Menu, Popup Builder v51.1.83
51.1.84 51.1.85 51.1.83 51.1.82 51.1.81 51.1.79 51.1.78 51.1.77 51.1.76 51.1.74 51.1.75 51.1.65 51.1.64 51.1.63 trunk 51.1.14 51.1.2 51.1.35 51.1.36 51.1.37 51.1.38 51.1.39 51.1.44 51.1.45 51.1.46 All 39 releases
← All changes | includes/extensions/Docs_KB/Docs_KB.php +601 -805 51.1.4451.1.83 View file →
@@ -1,9 +1,10 @@
1 1 <?php
2 2 /**
3 - * Docs & Knowledge Base Extension.
3 + * Docs & Knowledge Base Extension — v2.
4 4 *
5 - * Full-featured documentation system for WordPress.
5 + * Full-featured documentation system for WordPress with an Apple-inspired,
6 + * premium Liquid-Glass design language.
6 7 *
7 8 * @package King_Addons
8 9 */
9 10
@@ -13,51 +14,27 @@
13 14 exit;
14 15 }
15 16
16 17 /**
17 - * Main Docs & Knowledge Base class.
18 + * Main Docs & Knowledge Base class (singleton).
18 19 */
19 20 final class Docs_KB
20 21 {
21 - /**
22 - * Option name for settings.
23 - */
22 + /* ───────── Constants ───────── */
23 +
24 24 private const OPTION_NAME = 'king_addons_docs_kb_options';
25 -
26 - /**
27 - * Post type name.
28 - */
29 25 public const POST_TYPE = 'kng_doc';
30 -
31 - /**
32 - * Taxonomy name.
33 - */
34 26 public const TAXONOMY = 'kng_doc_category';
27 + public const TAG_TAXONOMY = 'kng_doc_tag';
28 + public const API_NAMESPACE = 'king-addons/v1';
35 29
36 - /**
37 - * REST API namespace.
38 - */
39 - public const API_NAMESPACE = 'king-addons/v1';
30 + /* ───────── Singleton ───────── */
40 31
41 - /**
42 - * Singleton instance.
43 - *
44 - * @var Docs_KB|null
45 - */
46 32 private static ?Docs_KB $instance = null;
47 33
48 - /**
49 - * Cached options.
50 - *
51 - * @var array<string, mixed>
52 - */
34 + /** @var array<string,mixed> */
53 35 private array $options = [];
54 36
55 - /**
56 - * Gets singleton instance.
57 - *
58 - * @return Docs_KB
59 - */
60 37 public static function instance(): Docs_KB
61 38 {
62 39 if (is_null(self::$instance)) {
63 40 self::$instance = new self();
@@ -64,11 +41,10 @@
64 41 }
65 42 return self::$instance;
66 43 }
67 44
68 - /**
69 - * Constructor.
70 - */
45 + /* ───────── Bootstrap ───────── */
46 +
71 47 public function __construct()
72 48 {
73 49 $this->options = $this->get_options();
74 50
@@ -77,11 +53,14 @@
77 53
78 54 // Init
79 55 add_action('init', [$this, 'register_post_type']);
80 56 add_action('init', [$this, 'register_taxonomy']);
57 + add_action('init', [$this, 'register_tag_taxonomy']);
81 58 add_action('init', [$this, 'add_rewrite_rules']);
82 59
83 60 // Admin
61 + add_filter('parent_file', [$this, 'fix_admin_parent_file']);
62 + add_filter('submenu_file', [$this, 'fix_admin_submenu_file']);
84 63 add_action('admin_enqueue_scripts', [$this, 'enqueue_admin_assets']);
85 64 add_action('admin_post_king_addons_docs_kb_save', [$this, 'handle_save_settings']);
86 65 add_filter('manage_' . self::POST_TYPE . '_posts_columns', [$this, 'add_admin_columns']);
87 66 add_action('manage_' . self::POST_TYPE . '_posts_custom_column', [$this, 'render_admin_columns'], 10, 2);
@@ -88,70 +67,70 @@
88 67
89 68 // Frontend
90 69 add_action('wp_enqueue_scripts', [$this, 'enqueue_frontend_assets']);
91 70 add_filter('template_include', [$this, 'template_loader']);
92 - add_filter('the_content', [$this, 'maybe_add_toc']);
93 71
94 - // REST API
72 + // REST
95 73 add_action('rest_api_init', [$this, 'register_rest_routes']);
96 74
97 - // AJAX feedback (Pro)
98 - add_action('wp_ajax_king_docs_feedback', [$this, 'ajax_save_feedback']);
99 - add_action('wp_ajax_nopriv_king_docs_feedback', [$this, 'ajax_save_feedback']);
75 + // AJAX – reactions & feedback
76 + add_action('wp_ajax_king_docs_reaction', [$this, 'ajax_save_reaction']);
77 + add_action('wp_ajax_nopriv_king_docs_reaction', [$this, 'ajax_save_reaction']);
100 78
101 79 // Track views (Pro)
102 80 add_action('wp', [$this, 'track_view']);
103 81 }
104 82
105 - /**
106 - * Handles plugin activation.
107 - *
108 - * @return void
109 - */
83 + /* ═══════════════════════════════════════════
84 + ACTIVATION / OPTIONS
85 + ═══════════════════════════════════════════ */
86 +
110 87 public function handle_activation(): void
111 88 {
112 89 if (!get_option(self::OPTION_NAME)) {
113 90 add_option(self::OPTION_NAME, $this->get_default_options());
114 91 }
115 -
116 92 $this->register_post_type();
117 93 $this->register_taxonomy();
94 + $this->register_tag_taxonomy();
118 95 $this->add_rewrite_rules();
119 96 flush_rewrite_rules();
120 97 }
121 98
122 - /**
123 - * Gets default options.
124 - *
125 - * @return array<string, mixed>
126 - */
99 + /** @return array<string,mixed> */
127 100 private function get_default_options(): array
128 101 {
129 102 return [
130 103 // General
131 - 'enabled' => false,
132 - 'docs_slug' => 'docs',
133 - 'main_page_id' => 0,
134 - 'docs_per_page' => 10,
104 + 'enabled' => true,
105 + 'docs_slug' => 'docs',
106 + 'main_page_id' => 0,
107 + 'docs_per_page' => 12,
108 + 'archive_title' => __('How can we help?', 'king-addons'),
109 + 'archive_subtitle' => __('Find guides, tutorials and answers to your questions.', 'king-addons'),
135 110
136 111 // Layout
137 - 'layout' => 'card', // box, card, modern
138 - 'columns' => 3,
112 + 'layout' => 'glass-card', // glass-card | glass-list | glass-grid
113 + 'columns' => 3,
139 114 'show_article_count' => true,
140 115 'show_category_icon' => true,
116 + 'dark_mode' => 'auto', // light | dark | auto
141 117
142 - // Single Article
143 - 'toc_enabled' => true,
144 - 'toc_sticky' => true,
145 - 'toc_headings' => 'h2,h3',
146 - 'sidebar_enabled' => true,
118 + // Single article
119 + 'toc_enabled' => true,
120 + 'toc_sticky' => true,
121 + 'toc_headings' => 'h2,h3',
122 + 'sidebar_enabled' => true,
147 123 'navigation_enabled' => true,
148 - 'print_button' => true,
124 + 'print_button' => true,
125 + 'reading_time' => true,
126 + 'social_share' => true,
127 + 'reactions_enabled' => true,
149 128
150 129 // Search
151 - 'search_enabled' => true,
152 - 'search_placeholder' => __('Search documentation...', 'king-addons'),
153 - 'search_min_chars' => 2,
130 + 'search_enabled' => true,
131 + 'search_placeholder' => __('Search documentation…', 'king-addons'),
132 + 'search_min_chars' => 2,
154 133
155 134 // Pro: Multiple KBs
156 135 'multiple_kb_enabled' => false,
157 136
@@ -159,32 +138,30 @@
159 138 'internal_docs_enabled' => false,
160 139 'internal_docs_roles' => ['administrator'],
161 140
162 141 // Pro: Feedback
163 - 'feedback_enabled' => false,
142 + 'feedback_enabled' => false,
164 143 'feedback_question' => __('Was this article helpful?', 'king-addons'),
144 + 'feedback_thanks' => __('Thank you for your feedback!', 'king-addons'),
145 + 'empty_text' => __('No articles yet', 'king-addons'),
165 146
166 147 // Pro: Related
167 - 'related_enabled' => false,
168 - 'related_count' => 3,
148 + 'related_enabled' => true,
149 + 'related_count' => 3,
169 150
170 151 // Pro: Analytics
171 152 'analytics_enabled' => false,
172 153 'analytics_email_report' => false,
173 - 'analytics_email' => get_option('admin_email'),
154 + 'analytics_email' => get_option('admin_email'),
174 155
175 - // Colors
176 - 'primary_color' => '#0066ff',
177 - 'category_icon_color' => '#0066ff',
178 - 'link_color' => '#0066ff',
156 + // Appearance
157 + 'primary_color' => '#0071e3',
158 + 'category_icon_color' => '#0071e3',
159 + 'link_color' => '#0071e3',
179 160 ];
180 161 }
181 162
182 - /**
183 - * Gets options.
184 - *
185 - * @return array<string, mixed>
186 - */
163 + /** @return array<string,mixed> */
187 164 public function get_options(): array
188 165 {
189 166 $saved = get_option(self::OPTION_NAME, []);
190 167 return wp_parse_args($saved, $this->get_default_options());
@@ -189,13 +166,8 @@
189 166 $saved = get_option(self::OPTION_NAME, []);
190 167 return wp_parse_args($saved, $this->get_default_options());
191 168 }
192 169
193 - /**
194 - * Checks if premium.
195 - *
196 - * @return bool
197 - */
198 170 public function is_premium(): bool
199 171 {
200 172 return function_exists('king_addons_freemius')
201 173 && king_addons_freemius()->can_use_premium_code__premium_only();
@@ -200,95 +172,72 @@
200 172 return function_exists('king_addons_freemius')
201 173 && king_addons_freemius()->can_use_premium_code__premium_only();
202 174 }
203 175
204 - /**
205 - * Registers the Doc post type.
206 - *
207 - * @return void
208 - */
176 + /* ═══════════════════════════════════════════
177 + POST TYPE / TAXONOMIES
178 + ═══════════════════════════════════════════ */
179 +
209 180 public function register_post_type(): void
210 181 {
211 182 $slug = sanitize_title($this->options['docs_slug'] ?? 'docs');
212 183
213 - $labels = [
214 - 'name' => __('Docs', 'king-addons'),
215 - 'singular_name' => __('Doc', 'king-addons'),
216 - 'add_new' => __('Add New Doc', 'king-addons'),
217 - 'add_new_item' => __('Add New Doc', 'king-addons'),
218 - 'edit_item' => __('Edit Doc', 'king-addons'),
219 - 'new_item' => __('New Doc', 'king-addons'),
220 - 'view_item' => __('View Doc', 'king-addons'),
221 - 'search_items' => __('Search Docs', 'king-addons'),
222 - 'not_found' => __('No docs found', 'king-addons'),
223 - 'not_found_in_trash' => __('No docs found in Trash', 'king-addons'),
224 - 'menu_name' => __('Docs', 'king-addons'),
225 - ];
226 -
227 - $args = [
228 - 'labels' => $labels,
229 - 'public' => true,
184 + register_post_type(self::POST_TYPE, [
185 + 'labels' => [
186 + 'name' => __('Docs', 'king-addons'),
187 + 'singular_name' => __('Doc', 'king-addons'),
188 + 'add_new' => __('Add New Doc', 'king-addons'),
189 + 'add_new_item' => __('Add New Doc', 'king-addons'),
190 + 'edit_item' => __('Edit Doc', 'king-addons'),
191 + 'new_item' => __('New Doc', 'king-addons'),
192 + 'view_item' => __('View Doc', 'king-addons'),
193 + 'search_items' => __('Search Docs', 'king-addons'),
194 + 'not_found' => __('No docs found', 'king-addons'),
195 + 'not_found_in_trash' => __('No docs found in Trash', 'king-addons'),
196 + 'menu_name' => __('Docs', 'king-addons'),
197 + ],
198 + 'public' => true,
230 199 'publicly_queryable' => true,
231 - 'show_ui' => true,
232 - 'show_in_menu' => false,
233 - 'show_in_rest' => true,
234 - 'query_var' => true,
235 - 'rewrite' => [
236 - 'slug' => $slug,
237 - 'with_front' => false,
238 - ],
239 - 'capability_type' => 'post',
240 - 'has_archive' => true,
241 - 'hierarchical' => false,
242 - 'menu_position' => 25,
243 - 'menu_icon' => 'dashicons-book-alt',
244 - 'supports' => ['title', 'editor', 'excerpt', 'thumbnail', 'revisions', 'custom-fields'],
245 - ];
246 -
247 - register_post_type(self::POST_TYPE, $args);
200 + 'show_ui' => true,
201 + 'show_in_menu' => false,
202 + 'show_in_rest' => true,
203 + 'query_var' => true,
204 + 'rewrite' => ['slug' => $slug, 'with_front' => false],
205 + 'capability_type' => 'post',
206 + 'has_archive' => $slug,
207 + 'hierarchical' => false,
208 + 'menu_position' => 25,
209 + 'menu_icon' => 'dashicons-book-alt',
210 + 'supports' => ['title', 'editor', 'excerpt', 'thumbnail', 'revisions', 'custom-fields', 'page-attributes'],
211 + ]);
248 212 }
249 213
250 - /**
251 - * Registers the Doc Category taxonomy.
252 - *
253 - * @return void
254 - */
255 214 public function register_taxonomy(): void
256 215 {
257 216 $slug = sanitize_title($this->options['docs_slug'] ?? 'docs');
258 217
259 - $labels = [
260 - 'name' => __('Doc Categories', 'king-addons'),
261 - 'singular_name' => __('Doc Category', 'king-addons'),
262 - 'search_items' => __('Search Categories', 'king-addons'),
263 - 'all_items' => __('All Categories', 'king-addons'),
264 - 'parent_item' => __('Parent Category', 'king-addons'),
265 - 'parent_item_colon' => __('Parent Category:', 'king-addons'),
266 - 'edit_item' => __('Edit Category', 'king-addons'),
267 - 'update_item' => __('Update Category', 'king-addons'),
268 - 'add_new_item' => __('Add New Category', 'king-addons'),
269 - 'new_item_name' => __('New Category Name', 'king-addons'),
270 - 'menu_name' => __('Categories', 'king-addons'),
271 - ];
272 -
273 - $args = [
274 - 'labels' => $labels,
275 - 'hierarchical' => true,
276 - 'public' => true,
277 - 'show_ui' => true,
218 + register_taxonomy(self::TAXONOMY, self::POST_TYPE, [
219 + 'labels' => [
220 + 'name' => __('Doc Categories', 'king-addons'),
221 + 'singular_name' => __('Doc Category', 'king-addons'),
222 + 'search_items' => __('Search Categories', 'king-addons'),
223 + 'all_items' => __('All Categories', 'king-addons'),
224 + 'parent_item' => __('Parent Category', 'king-addons'),
225 + 'edit_item' => __('Edit Category', 'king-addons'),
226 + 'update_item' => __('Update Category', 'king-addons'),
227 + 'add_new_item' => __('Add New Category', 'king-addons'),
228 + 'menu_name' => __('Categories', 'king-addons'),
229 + ],
230 + 'hierarchical' => true,
231 + 'public' => true,
232 + 'show_ui' => true,
278 233 'show_admin_column' => true,
279 - 'show_in_rest' => true,
280 - 'query_var' => true,
281 - 'rewrite' => [
282 - 'slug' => $slug . '/category',
283 - 'with_front' => false,
284 - 'hierarchical' => true,
285 - ],
286 - ];
234 + 'show_in_rest' => true,
235 + 'query_var' => true,
236 + 'rewrite' => ['slug' => $slug . '/category', 'with_front' => false, 'hierarchical' => true],
237 + ]);
287 238
288 - register_taxonomy(self::TAXONOMY, self::POST_TYPE, $args);
289 -
290 - // Add custom meta fields to taxonomy
239 + // Custom meta fields
291 240 add_action(self::TAXONOMY . '_add_form_fields', [$this, 'add_category_fields']);
292 241 add_action(self::TAXONOMY . '_edit_form_fields', [$this, 'edit_category_fields'], 10, 2);
293 242 add_action('created_' . self::TAXONOMY, [$this, 'save_category_fields']);
294 243 add_action('edited_' . self::TAXONOMY, [$this, 'save_category_fields']);
@@ -293,79 +242,76 @@
293 242 add_action('created_' . self::TAXONOMY, [$this, 'save_category_fields']);
294 243 add_action('edited_' . self::TAXONOMY, [$this, 'save_category_fields']);
295 244 }
296 245
297 - /**
298 - * Adds category custom fields on add form.
299 - *
300 - * @return void
301 - */
246 + public function register_tag_taxonomy(): void
247 + {
248 + $slug = sanitize_title($this->options['docs_slug'] ?? 'docs');
249 +
250 + register_taxonomy(self::TAG_TAXONOMY, self::POST_TYPE, [
251 + 'labels' => [
252 + 'name' => __('Doc Tags', 'king-addons'),
253 + 'singular_name' => __('Doc Tag', 'king-addons'),
254 + 'search_items' => __('Search Tags', 'king-addons'),
255 + 'all_items' => __('All Tags', 'king-addons'),
256 + 'edit_item' => __('Edit Tag', 'king-addons'),
257 + 'update_item' => __('Update Tag', 'king-addons'),
258 + 'add_new_item' => __('Add New Tag', 'king-addons'),
259 + 'menu_name' => __('Tags', 'king-addons'),
260 + ],
261 + 'hierarchical' => false,
262 + 'public' => true,
263 + 'show_ui' => true,
264 + 'show_admin_column' => true,
265 + 'show_in_rest' => true,
266 + 'rewrite' => ['slug' => $slug . '/tag', 'with_front' => false],
267 + ]);
268 + }
269 +
270 + /* ── Category form fields ── */
271 +
302 272 public function add_category_fields(): void
303 273 {
274 + $icons = self::get_available_icons();
304 275 ?>
305 276 <div class="form-field">
306 277 <label for="kng_doc_cat_icon"><?php esc_html_e('Category Icon', 'king-addons'); ?></label>
307 278 <select name="kng_doc_cat_icon" id="kng_doc_cat_icon">
308 - <option value="book"><?php esc_html_e('Book', 'king-addons'); ?></option>
309 - <option value="lightbulb"><?php esc_html_e('Lightbulb', 'king-addons'); ?></option>
310 - <option value="gear"><?php esc_html_e('Gear', 'king-addons'); ?></option>
311 - <option value="rocket"><?php esc_html_e('Rocket', 'king-addons'); ?></option>
312 - <option value="star"><?php esc_html_e('Star', 'king-addons'); ?></option>
313 - <option value="code"><?php esc_html_e('Code', 'king-addons'); ?></option>
314 - <option value="help"><?php esc_html_e('Help', 'king-addons'); ?></option>
315 - <option value="video"><?php esc_html_e('Video', 'king-addons'); ?></option>
279 + <?php foreach ($icons as $value => $label): ?>
280 + <option value="<?php echo esc_attr($value); ?>"><?php echo esc_html($label); ?></option>
281 + <?php endforeach; ?>
316 282 </select>
317 - <p class="description"><?php esc_html_e('Select an icon for this category.', 'king-addons'); ?></p>
318 283 </div>
319 284 <div class="form-field">
320 285 <label for="kng_doc_cat_order"><?php esc_html_e('Order', 'king-addons'); ?></label>
321 286 <input type="number" name="kng_doc_cat_order" id="kng_doc_cat_order" value="0" min="0">
322 - <p class="description"><?php esc_html_e('Custom order for sorting categories.', 'king-addons'); ?></p>
323 287 </div>
324 288 <?php
325 289 }
326 290
327 - /**
328 - * Adds category custom fields on edit form.
329 - *
330 - * @param \WP_Term $term Term object.
331 - * @return void
332 - */
333 291 public function edit_category_fields(\WP_Term $term): void
334 292 {
335 - $icon = get_term_meta($term->term_id, 'kng_doc_cat_icon', true) ?: 'book';
293 + $icon = get_term_meta($term->term_id, 'kng_doc_cat_icon', true) ?: 'book';
336 294 $order = get_term_meta($term->term_id, 'kng_doc_cat_order', true) ?: 0;
295 + $icons = self::get_available_icons();
337 296 ?>
338 297 <tr class="form-field">
339 - <th scope="row"><label for="kng_doc_cat_icon"><?php esc_html_e('Category Icon', 'king-addons'); ?></label></th>
298 + <th><label for="kng_doc_cat_icon"><?php esc_html_e('Category Icon', 'king-addons'); ?></label></th>
340 299 <td>
341 300 <select name="kng_doc_cat_icon" id="kng_doc_cat_icon">
342 - <option value="book" <?php selected($icon, 'book'); ?>><?php esc_html_e('Book', 'king-addons'); ?></option>
343 - <option value="lightbulb" <?php selected($icon, 'lightbulb'); ?>><?php esc_html_e('Lightbulb', 'king-addons'); ?></option>
344 - <option value="gear" <?php selected($icon, 'gear'); ?>><?php esc_html_e('Gear', 'king-addons'); ?></option>
345 - <option value="rocket" <?php selected($icon, 'rocket'); ?>><?php esc_html_e('Rocket', 'king-addons'); ?></option>
346 - <option value="star" <?php selected($icon, 'star'); ?>><?php esc_html_e('Star', 'king-addons'); ?></option>
347 - <option value="code" <?php selected($icon, 'code'); ?>><?php esc_html_e('Code', 'king-addons'); ?></option>
348 - <option value="help" <?php selected($icon, 'help'); ?>><?php esc_html_e('Help', 'king-addons'); ?></option>
349 - <option value="video" <?php selected($icon, 'video'); ?>><?php esc_html_e('Video', 'king-addons'); ?></option>
301 + <?php foreach ($icons as $value => $label): ?>
302 + <option value="<?php echo esc_attr($value); ?>" <?php selected($icon, $value); ?>><?php echo esc_html($label); ?></option>
303 + <?php endforeach; ?>
350 304 </select>
351 305 </td>
352 306 </tr>
353 307 <tr class="form-field">
354 - <th scope="row"><label for="kng_doc_cat_order"><?php esc_html_e('Order', 'king-addons'); ?></label></th>
355 - <td>
356 - <input type="number" name="kng_doc_cat_order" id="kng_doc_cat_order" value="<?php echo esc_attr($order); ?>" min="0">
357 - </td>
308 + <th><label for="kng_doc_cat_order"><?php esc_html_e('Order', 'king-addons'); ?></label></th>
309 + <td><input type="number" name="kng_doc_cat_order" id="kng_doc_cat_order" value="<?php echo esc_attr($order); ?>" min="0"></td>
358 310 </tr>
359 311 <?php
360 312 }
361 313
362 - /**
363 - * Saves category custom fields.
364 - *
365 - * @param int $term_id Term ID.
366 - * @return void
367 - */
368 314 public function save_category_fields(int $term_id): void
369 315 {
370 316 if (isset($_POST['kng_doc_cat_icon'])) {
371 317 update_term_meta($term_id, 'kng_doc_cat_icon', sanitize_text_field($_POST['kng_doc_cat_icon']));
@@ -374,39 +320,20 @@
374 320 update_term_meta($term_id, 'kng_doc_cat_order', intval($_POST['kng_doc_cat_order']));
375 321 }
376 322 }
377 323
378 - /**
379 - * Adds rewrite rules.
380 - *
381 - * @return void
382 - */
324 + /* ═══════════════════════════════════════════
325 + REWRITE RULES
326 + ═══════════════════════════════════════════ */
327 +
383 328 public function add_rewrite_rules(): void
384 329 {
385 330 $slug = sanitize_title($this->options['docs_slug'] ?? 'docs');
386 331
387 - // Archive page
388 - add_rewrite_rule(
389 - '^' . $slug . '/?$',
390 - 'index.php?post_type=' . self::POST_TYPE,
391 - 'top'
392 - );
332 + add_rewrite_rule('^' . $slug . '/?$', 'index.php?post_type=' . self::POST_TYPE, 'top');
333 + add_rewrite_rule('^' . $slug . '/category/([^/]+)/?$', 'index.php?' . self::TAXONOMY . '=$matches[1]', 'top');
334 + add_rewrite_rule('^' . $slug . '/([^/]+)/?$', 'index.php?' . self::POST_TYPE . '=$matches[1]', 'top');
393 335
394 - // Category page
395 - add_rewrite_rule(
396 - '^' . $slug . '/category/([^/]+)/?$',
397 - 'index.php?' . self::TAXONOMY . '=$matches[1]',
398 - 'top'
399 - );
400 -
401 - // Single doc
402 - add_rewrite_rule(
403 - '^' . $slug . '/([^/]+)/?$',
404 - 'index.php?' . self::POST_TYPE . '=$matches[1]',
405 - 'top'
406 - );
407 -
408 - // Flush rules only once after settings change
409 336 if (get_option('king_addons_docs_kb_rewrite_flushed') !== $slug) {
410 337 flush_rewrite_rules();
411 338 update_option('king_addons_docs_kb_rewrite_flushed', $slug);
412 339 }
@@ -411,68 +338,55 @@
411 338 update_option('king_addons_docs_kb_rewrite_flushed', $slug);
412 339 }
413 340 }
414 341
415 - /**
416 - * Adds admin columns.
417 - *
418 - * @param array $columns Existing columns.
419 - * @return array
420 - */
342 + /* ═══════════════════════════════════════════
343 + ADMIN COLUMNS
344 + ═══════════════════════════════════════════ */
345 +
421 346 public function add_admin_columns(array $columns): array
422 347 {
423 - $new_columns = [];
424 - foreach ($columns as $key => $value) {
425 - $new_columns[$key] = $value;
426 - if ($key === 'title') {
427 - $new_columns['doc_category'] = __('Category', 'king-addons');
348 + $new = [];
349 + foreach ($columns as $k => $v) {
350 + $new[$k] = $v;
351 + if ($k === 'title') {
352 + $new['doc_category'] = __('Category', 'king-addons');
428 353 }
429 354 }
430 - $new_columns['doc_views'] = __('Views', 'king-addons');
431 - $new_columns['doc_feedback'] = __('Feedback', 'king-addons');
432 - return $new_columns;
355 + $new['doc_views'] = __('Views', 'king-addons');
356 + $new['doc_reactions'] = __('Reactions', 'king-addons');
357 + return $new;
433 358 }
434 359
435 - /**
436 - * Renders admin columns.
437 - *
438 - * @param string $column Column name.
439 - * @param int $post_id Post ID.
440 - * @return void
441 - */
442 360 public function render_admin_columns(string $column, int $post_id): void
443 361 {
444 362 switch ($column) {
445 363 case 'doc_category':
446 364 $terms = get_the_terms($post_id, self::TAXONOMY);
447 - if ($terms && !is_wp_error($terms)) {
448 - $term_names = wp_list_pluck($terms, 'name');
449 - echo esc_html(implode(', ', $term_names));
450 - } else {
451 - echo '—';
452 - }
365 + echo ($terms && !is_wp_error($terms))
366 + ? esc_html(implode(', ', wp_list_pluck($terms, 'name')))
367 + : '—';
453 368 break;
454 369
455 370 case 'doc_views':
456 - $views = get_post_meta($post_id, '_kng_doc_views', true) ?: 0;
457 - echo esc_html(number_format_i18n($views));
371 + echo esc_html(number_format_i18n(get_post_meta($post_id, '_kng_doc_views', true) ?: 0));
458 372 break;
459 373
460 - case 'doc_feedback':
461 - $helpful = get_post_meta($post_id, '_kng_doc_helpful', true) ?: 0;
462 - $not_helpful = get_post_meta($post_id, '_kng_doc_not_helpful', true) ?: 0;
463 - echo '<span style="color:#34c759">👍 ' . esc_html($helpful) . '</span> / ';
464 - echo '<span style="color:#ff3b30">👎 ' . esc_html($not_helpful) . '</span>';
374 + case 'doc_reactions':
375 + $happy = get_post_meta($post_id, '_kng_doc_react_happy', true) ?: 0;
376 + $neutral = get_post_meta($post_id, '_kng_doc_react_neutral', true) ?: 0;
377 + $sad = get_post_meta($post_id, '_kng_doc_react_sad', true) ?: 0;
378 + echo '<span style="color:#34c759">😊 ' . esc_html($happy) . '</span> ';
379 + echo '<span style="color:#ff9f0a">😐 ' . esc_html($neutral) . '</span> ';
380 + echo '<span style="color:#ff3b30">😞 ' . esc_html($sad) . '</span>';
465 381 break;
466 382 }
467 383 }
468 384
469 - /**
470 - * Enqueues admin assets.
471 - *
472 - * @param string $hook Current admin page.
473 - * @return void
474 - */
385 + /* ═══════════════════════════════════════════
386 + ASSETS
387 + ═══════════════════════════════════════════ */
388 +
475 389 public function enqueue_admin_assets(string $hook): void
476 390 {
477 391 if ($hook !== 'king-addons_page_king-addons-docs-kb') {
478 392 return;
@@ -486,30 +400,10 @@
486 400 KING_ADDONS_URL . 'includes/admin/layouts/shared/admin-v3-styles.css',
487 401 [],
488 402 KING_ADDONS_VERSION
489 403 );
490 -
491 - wp_enqueue_style(
492 - 'king-addons-docs-kb-admin',
493 - KING_ADDONS_URL . 'includes/extensions/Docs_KB/assets/admin.css',
494 - ['king-addons-v3-styles'],
495 - KING_ADDONS_VERSION
496 - );
497 -
498 - wp_enqueue_script(
499 - 'king-addons-docs-kb-admin',
500 - KING_ADDONS_URL . 'includes/extensions/Docs_KB/assets/admin.js',
501 - ['jquery', 'wp-color-picker'],
502 - KING_ADDONS_VERSION,
503 - true
504 - );
505 404 }
506 405
507 - /**
508 - * Enqueues frontend assets.
509 - *
510 - * @return void
511 - */
512 406 public function enqueue_frontend_assets(): void
513 407 {
514 408 if (!$this->options['enabled']) {
515 409 return;
@@ -514,12 +408,13 @@
514 408 if (!$this->options['enabled']) {
515 409 return;
516 410 }
517 411
518 - // Only on docs pages
519 - if (!is_post_type_archive(self::POST_TYPE)
520 - && !is_singular(self::POST_TYPE)
412 + if (
413 + !is_post_type_archive(self::POST_TYPE)
414 + && !is_singular(self::POST_TYPE)
521 415 && !is_tax(self::TAXONOMY)
416 + && !is_tax(self::TAG_TAXONOMY)
522 417 && !$this->is_docs_main_page()
523 418 ) {
524 419 return;
525 420 }
@@ -538,74 +433,54 @@
538 433 KING_ADDONS_VERSION,
539 434 true
540 435 );
541 436
542 - $options = $this->options;
543 -
544 437 wp_localize_script('king-addons-docs-kb', 'kingDocsKB', [
545 - 'restUrl' => rest_url(self::API_NAMESPACE . '/docs'),
546 - 'ajaxUrl' => admin_url('admin-ajax.php'),
547 - 'nonce' => wp_create_nonce('wp_rest'),
548 - 'feedbackNonce' => wp_create_nonce('king_docs_feedback'),
549 - 'searchEnabled' => !empty($options['search_enabled']),
550 - 'searchMinChars' => intval($options['search_min_chars'] ?? 2),
551 - 'tocEnabled' => !empty($options['toc_enabled']),
552 - 'tocSticky' => !empty($options['toc_sticky']),
553 - 'tocHeadings' => $options['toc_headings'] ?? 'h2,h3',
554 - 'feedbackEnabled' => !empty($options['feedback_enabled']) && $this->is_premium(),
555 - 'strings' => [
556 - 'searchPlaceholder' => $options['search_placeholder'] ?? __('Search documentation...', 'king-addons'),
557 - 'noResults' => __('No results found', 'king-addons'),
558 - 'searching' => __('Searching...', 'king-addons'),
559 - 'feedbackQuestion' => $options['feedback_question'] ?? __('Was this article helpful?', 'king-addons'),
560 - 'feedbackThanks' => __('Thanks for your feedback!', 'king-addons'),
561 - 'tocTitle' => __('On this page', 'king-addons'),
438 + 'restUrl' => rest_url(self::API_NAMESPACE . '/docs/'),
439 + 'ajaxUrl' => admin_url('admin-ajax.php'),
440 + 'nonce' => wp_create_nonce('wp_rest'),
441 + 'reactionNonce' => wp_create_nonce('king_docs_reaction'),
442 + 'searchEnabled' => !empty($this->options['search_enabled']),
443 + 'searchMinChars' => intval($this->options['search_min_chars'] ?? 2),
444 + 'tocEnabled' => !empty($this->options['toc_enabled']),
445 + 'tocSticky' => !empty($this->options['toc_sticky']),
446 + 'tocHeadings' => $this->options['toc_headings'] ?? 'h2,h3',
447 + 'reactionsEnabled' => !empty($this->options['reactions_enabled']),
448 + 'darkMode' => $this->options['dark_mode'] ?? 'auto',
449 + 'i18n' => [
450 + 'searchPlaceholder' => $this->options['search_placeholder'] ?? __('Search documentation…', 'king-addons'),
451 + 'noResults' => __('No results found', 'king-addons'),
452 + 'searching' => __('Searching…', 'king-addons'),
453 + 'tocTitle' => __('On this page', 'king-addons'),
454 + 'reactionThanks' => __('Thanks for your feedback!', 'king-addons'),
455 + 'copied' => __('Link copied!', 'king-addons'),
562 456 ],
563 457 ]);
564 458
565 - // Add inline CSS for custom colors
566 - $custom_css = $this->get_custom_css();
567 - wp_add_inline_style('king-addons-docs-kb', $custom_css);
459 + // Inline custom-property overrides
460 + wp_add_inline_style('king-addons-docs-kb', $this->get_custom_css());
568 461 }
569 462
570 - /**
571 - * Gets custom CSS based on settings.
572 - *
573 - * @return string
574 - */
575 463 private function get_custom_css(): string
576 464 {
577 - $options = $this->options;
578 - $primary = sanitize_hex_color($options['primary_color'] ?? '#0066ff');
579 - $icon_color = sanitize_hex_color($options['category_icon_color'] ?? '#0066ff');
580 - $link_color = sanitize_hex_color($options['link_color'] ?? '#0066ff');
465 + $o = $this->options;
466 + $primary = sanitize_hex_color($o['primary_color'] ?? '#0071e3');
467 + $icon = sanitize_hex_color($o['category_icon_color'] ?? '#0071e3');
468 + $link = sanitize_hex_color($o['link_color'] ?? '#0071e3');
581 469
582 - return "
583 - :root {
584 - --kng-docs-primary: {$primary};
585 - --kng-docs-icon-color: {$icon_color};
586 - --kng-docs-link-color: {$link_color};
587 - }
588 - ";
470 + return ":root{--kng-docs-primary:{$primary};--kng-docs-icon-color:{$icon};--kng-docs-link-color:{$link};}";
589 471 }
590 472
591 - /**
592 - * Checks if current page is docs main page.
593 - *
594 - * @return bool
595 - */
596 473 private function is_docs_main_page(): bool
597 474 {
598 - $main_page_id = intval($this->options['main_page_id'] ?? 0);
599 - return $main_page_id > 0 && is_page($main_page_id);
475 + $id = intval($this->options['main_page_id'] ?? 0);
476 + return $id > 0 && is_page($id);
600 477 }
601 478
602 - /**
603 - * Loads custom templates.
604 - *
605 - * @param string $template Current template.
606 - * @return string
607 - */
479 + /* ═══════════════════════════════════════════
480 + TEMPLATE LOADER
481 + ═══════════════════════════════════════════ */
482 +
608 483 public function template_loader(string $template): string
609 484 {
610 485 if (!$this->options['enabled']) {
611 486 return $template;
@@ -610,538 +485,413 @@
610 485 if (!$this->options['enabled']) {
611 486 return $template;
612 487 }
613 488
614 - // Main docs page
615 - if ($this->is_docs_main_page()) {
616 - $custom_template = __DIR__ . '/templates/archive-docs.php';
617 - if (file_exists($custom_template)) {
618 - return $custom_template;
619 - }
620 - }
489 + $base = __DIR__ . '/templates/';
621 490
622 - // Archive
623 - if (is_post_type_archive(self::POST_TYPE)) {
624 - $custom_template = __DIR__ . '/templates/archive-docs.php';
625 - if (file_exists($custom_template)) {
626 - return $custom_template;
627 - }
491 + if ($this->is_docs_main_page() || is_post_type_archive(self::POST_TYPE)) {
492 + $t = $base . 'archive-docs.php';
493 + return file_exists($t) ? $t : $template;
628 494 }
629 495
630 - // Taxonomy
631 - if (is_tax(self::TAXONOMY)) {
632 - $custom_template = __DIR__ . '/templates/taxonomy-docs.php';
633 - if (file_exists($custom_template)) {
634 - return $custom_template;
635 - }
496 + if (is_tax(self::TAXONOMY) || is_tax(self::TAG_TAXONOMY)) {
497 + $t = $base . 'taxonomy-docs.php';
498 + return file_exists($t) ? $t : $template;
636 499 }
637 500
638 - // Single doc
639 501 if (is_singular(self::POST_TYPE)) {
640 - $custom_template = __DIR__ . '/templates/single-doc.php';
641 - if (file_exists($custom_template)) {
642 - return $custom_template;
643 - }
502 + $t = $base . 'single-doc.php';
503 + return file_exists($t) ? $t : $template;
644 504 }
645 505
646 506 return $template;
647 507 }
648 508
649 - /**
650 - * Maybe adds TOC to content.
651 - *
652 - * @param string $content Post content.
653 - * @return string
654 - */
655 - public function maybe_add_toc(string $content): string
656 - {
657 - if (!is_singular(self::POST_TYPE) || !$this->options['toc_enabled']) {
658 - return $content;
659 - }
509 + /* ═══════════════════════════════════════════
510 + REST API
511 + ═══════════════════════════════════════════ */
660 512
661 - // TOC is added via JavaScript for better control
662 - return $content;
663 - }
664 -
665 - /**
666 - * Registers REST API routes.
667 - *
668 - * @return void
669 - */
670 513 public function register_rest_routes(): void
671 514 {
672 - // Search endpoint
673 515 register_rest_route(self::API_NAMESPACE, '/docs/search', [
674 - 'methods' => 'GET',
516 + 'methods' => 'GET',
675 517 'callback' => [$this, 'rest_search'],
676 518 'permission_callback' => '__return_true',
677 519 'args' => [
678 - 'q' => [
679 - 'required' => true,
680 - 'type' => 'string',
681 - 'sanitize_callback' => 'sanitize_text_field',
682 - ],
683 - 'kb' => [
684 - 'required' => false,
685 - 'type' => 'integer',
686 - 'default' => 0,
687 - ],
520 + 'q' => ['required' => true, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field'],
521 + 'cat' => ['required' => false, 'type' => 'integer', 'default' => 0],
688 522 ],
689 523 ]);
690 524
691 - // Get categories endpoint
692 525 register_rest_route(self::API_NAMESPACE, '/docs/categories', [
693 - 'methods' => 'GET',
526 + 'methods' => 'GET',
694 527 'callback' => [$this, 'rest_get_categories'],
695 528 'permission_callback' => '__return_true',
696 529 ]);
697 530
698 - // Get articles by category endpoint
699 531 register_rest_route(self::API_NAMESPACE, '/docs/category/(?P<id>\d+)', [
700 - 'methods' => 'GET',
532 + 'methods' => 'GET',
701 533 'callback' => [$this, 'rest_get_category_articles'],
702 534 'permission_callback' => '__return_true',
703 535 ]);
704 536 }
705 537
706 - /**
707 - * REST: Search docs.
708 - *
709 - * @param \WP_REST_Request $request Request object.
710 - * @return \WP_REST_Response
711 - */
712 538 public function rest_search(\WP_REST_Request $request): \WP_REST_Response
713 539 {
714 - $query = $request->get_param('q');
715 - $kb = $request->get_param('kb');
540 + $q = $request->get_param('q');
541 + $cat = $request->get_param('cat');
716 542
717 - if (strlen($query) < ($this->options['search_min_chars'] ?? 2)) {
543 + if (strlen($q) < intval($this->options['search_min_chars'] ?? 2)) {
718 544 return new \WP_REST_Response(['results' => []], 200);
719 545 }
720 546
721 547 $args = [
722 - 'post_type' => self::POST_TYPE,
723 - 'post_status' => 'publish',
548 + 'post_type' => self::POST_TYPE,
549 + 'post_status' => 'publish',
724 550 'posts_per_page' => 10,
725 - 's' => $query,
726 - 'orderby' => 'relevance',
551 + 's' => $q,
552 + 'orderby' => 'relevance',
727 553 ];
728 554
729 - // Pro: Internal docs visibility check
730 - if ($this->is_premium() && !empty($this->options['internal_docs_enabled'])) {
731 - if (!is_user_logged_in()) {
732 - $args['meta_query'] = [
733 - [
734 - 'key' => '_kng_doc_visibility',
735 - 'value' => 'public',
736 - 'compare' => '=',
737 - ],
738 - ];
739 - }
555 + if ($cat) {
556 + $args['tax_query'] = [['taxonomy' => self::TAXONOMY, 'field' => 'term_id', 'terms' => $cat]];
740 557 }
741 558
742 - $search_query = new \WP_Query($args);
559 + if ($this->should_limit_to_public_docs()) {
560 + $args['meta_query'] = $this->get_public_docs_meta_query();
561 + }
562 +
563 + $wp = new \WP_Query($args);
743 564 $results = [];
744 565
745 - foreach ($search_query->posts as $post) {
746 - $categories = get_the_terms($post->ID, self::TAXONOMY);
747 - $category_name = $categories && !is_wp_error($categories)
748 - ? $categories[0]->name
749 - : '';
566 + foreach ($wp->posts as $post) {
567 + $cats = get_the_terms($post->ID, self::TAXONOMY);
568 + $cat_name = ($cats && !is_wp_error($cats)) ? $cats[0]->name : '';
569 + $excerpt = $post->post_excerpt ?: wp_trim_words($post->post_content, 20, '…');
750 570
751 - $excerpt = $post->post_excerpt ?: wp_trim_words($post->post_content, 20, '...');
752 -
753 - // Highlight matches in title and excerpt
754 - $highlighted_title = $this->highlight_matches($post->post_title, $query);
755 - $highlighted_excerpt = $this->highlight_matches($excerpt, $query);
756 -
757 571 $results[] = [
758 - 'id' => $post->ID,
759 - 'title' => $post->post_title,
760 - 'highlighted_title' => $highlighted_title,
761 - 'url' => get_permalink($post->ID),
762 - 'excerpt' => $excerpt,
763 - 'highlighted_excerpt' => $highlighted_excerpt,
764 - 'category' => $category_name,
765 - 'date' => get_the_date('', $post),
572 + 'id' => $post->ID,
573 + 'title' => $post->post_title,
574 + 'h_title' => $this->highlight($post->post_title, $q),
575 + 'url' => get_permalink($post->ID),
576 + 'excerpt' => $excerpt,
577 + 'h_excerpt' => $this->highlight($excerpt, $q),
578 + 'category' => $cat_name,
579 + 'date' => get_the_date('', $post),
580 + 'reading_time' => $this->estimate_reading_time($post->post_content),
766 581 ];
767 582 }
768 583
769 - // Pro: Log search query for analytics
584 + // Pro: analytics log
770 585 if ($this->is_premium() && !empty($this->options['analytics_enabled'])) {
771 - $this->log_search_query($query, count($results));
586 + $this->log_search($q, count($results));
772 587 }
773 588
774 589 return new \WP_REST_Response(['results' => $results], 200);
775 590 }
776 591
777 - /**
778 - * Highlights search matches in text.
779 - *
780 - * @param string $text Text to highlight.
781 - * @param string $query Search query.
782 - * @return string
783 - */
784 - private function highlight_matches(string $text, string $query): string
592 + public function rest_get_categories(): \WP_REST_Response
785 593 {
786 - if (empty($query)) {
787 - return $text;
788 - }
594 + $args = [
595 + 'taxonomy' => self::TAXONOMY,
596 + 'hide_empty' => true,
597 + 'orderby' => 'meta_value_num',
598 + 'meta_key' => 'kng_doc_cat_order',
599 + 'order' => 'ASC',
600 + ];
789 601
790 - $words = explode(' ', $query);
791 - foreach ($words as $word) {
792 - if (strlen($word) >= 2) {
793 - $text = preg_replace(
794 - '/(' . preg_quote($word, '/') . ')/iu',
795 - '<mark>$1</mark>',
796 - $text
797 - );
602 + if ($this->should_limit_to_public_docs()) {
603 + $public_doc_ids = get_posts([
604 + 'post_type' => self::POST_TYPE,
605 + 'post_status' => 'publish',
606 + 'fields' => 'ids',
607 + 'posts_per_page' => -1,
608 + 'meta_query' => $this->get_public_docs_meta_query(),
609 + ]);
610 +
611 + if (empty($public_doc_ids)) {
612 + return new \WP_REST_Response([], 200);
798 613 }
614 +
615 + $args['object_ids'] = $public_doc_ids;
799 616 }
800 617
801 - return $text;
802 - }
618 + $cats = get_terms($args);
803 619
804 - /**
805 - * Logs search query for analytics.
806 - *
807 - * @param string $query Search query.
808 - * @param int $results_count Number of results.
809 - * @return void
810 - */
811 - private function log_search_query(string $query, int $results_count): void
812 - {
813 - $logs = get_option('king_addons_docs_search_logs', []);
814 - $logs[] = [
815 - 'query' => $query,
816 - 'results' => $results_count,
817 - 'timestamp' => current_time('mysql'),
818 - ];
819 -
820 - // Keep only last 1000 entries
821 - if (count($logs) > 1000) {
822 - $logs = array_slice($logs, -1000);
620 + if (is_wp_error($cats)) {
621 + return new \WP_REST_Response([], 200);
823 622 }
824 623
825 - update_option('king_addons_docs_search_logs', $logs);
826 - }
827 -
828 - /**
829 - * REST: Get categories.
830 - *
831 - * @return \WP_REST_Response
832 - */
833 - public function rest_get_categories(): \WP_REST_Response
834 - {
835 - $categories = get_terms([
836 - 'taxonomy' => self::TAXONOMY,
837 - 'hide_empty' => true,
838 - 'orderby' => 'meta_value_num',
839 - 'meta_key' => 'kng_doc_cat_order',
840 - 'order' => 'ASC',
841 - ]);
842 -
843 - $results = [];
844 - foreach ($categories as $cat) {
845 - if (is_wp_error($cat)) {
846 - continue;
847 - }
848 -
849 - $results[] = [
850 - 'id' => $cat->term_id,
851 - 'name' => $cat->name,
852 - 'slug' => $cat->slug,
853 - 'description' => $cat->description,
854 - 'count' => $cat->count,
855 - 'icon' => get_term_meta($cat->term_id, 'kng_doc_cat_icon', true) ?: 'book',
856 - 'url' => get_term_link($cat),
624 + $out = [];
625 + foreach ($cats as $c) {
626 + if (is_wp_error($c)) continue;
627 + $out[] = [
628 + 'id' => $c->term_id,
629 + 'name' => $c->name,
630 + 'slug' => $c->slug,
631 + 'desc' => $c->description,
632 + 'count' => $c->count,
633 + 'icon' => get_term_meta($c->term_id, 'kng_doc_cat_icon', true) ?: 'book',
634 + 'url' => get_term_link($c),
857 635 ];
858 636 }
859 637
860 - return new \WP_REST_Response($results, 200);
638 + return new \WP_REST_Response($out, 200);
861 639 }
862 640
863 - /**
864 - * REST: Get articles by category.
865 - *
866 - * @param \WP_REST_Request $request Request object.
867 - * @return \WP_REST_Response
868 - */
869 641 public function rest_get_category_articles(\WP_REST_Request $request): \WP_REST_Response
870 642 {
871 - $category_id = $request->get_param('id');
643 + $cat_id = absint($request->get_param('id'));
872 644
873 645 $args = [
874 - 'post_type' => self::POST_TYPE,
875 - 'post_status' => 'publish',
646 + 'post_type' => self::POST_TYPE,
647 + 'post_status' => 'publish',
876 648 'posts_per_page' => -1,
877 - 'tax_query' => [
878 - [
879 - 'taxonomy' => self::TAXONOMY,
880 - 'field' => 'term_id',
881 - 'terms' => $category_id,
882 - ],
883 - ],
884 - 'orderby' => 'menu_order title',
885 - 'order' => 'ASC',
649 + 'tax_query' => [['taxonomy' => self::TAXONOMY, 'field' => 'term_id', 'terms' => $cat_id]],
650 + 'orderby' => 'menu_order title',
651 + 'order' => 'ASC',
886 652 ];
887 653
888 - $query = new \WP_Query($args);
889 - $results = [];
654 + if ($this->should_limit_to_public_docs()) {
655 + $args['meta_query'] = $this->get_public_docs_meta_query();
656 + }
890 657
891 - foreach ($query->posts as $post) {
892 - $results[] = [
893 - 'id' => $post->ID,
894 - 'title' => $post->post_title,
895 - 'url' => get_permalink($post->ID),
896 - 'excerpt' => $post->post_excerpt ?: wp_trim_words($post->post_content, 15, '...'),
658 + $wp = new \WP_Query($args);
659 +
660 + $out = [];
661 + foreach ($wp->posts as $p) {
662 + $out[] = [
663 + 'id' => $p->ID,
664 + 'title' => $p->post_title,
665 + 'url' => get_permalink($p->ID),
666 + 'excerpt' => $p->post_excerpt ?: wp_trim_words($p->post_content, 15, '…'),
897 667 ];
898 668 }
899 669
900 - return new \WP_REST_Response($results, 200);
670 + return new \WP_REST_Response($out, 200);
901 671 }
902 672
903 - /**
904 - * Tracks article view.
905 - *
906 - * @return void
907 - */
908 - public function track_view(): void
673 + private function should_limit_to_public_docs(): bool
909 674 {
910 - if (!is_singular(self::POST_TYPE) || !$this->options['analytics_enabled'] || !$this->is_premium()) {
911 - return;
675 + if (!$this->is_premium() || empty($this->options['internal_docs_enabled'])) {
676 + return false;
912 677 }
913 678
914 - $post_id = get_the_ID();
915 - $views = get_post_meta($post_id, '_kng_doc_views', true) ?: 0;
916 - update_post_meta($post_id, '_kng_doc_views', $views + 1);
679 + if (!is_user_logged_in()) {
680 + return true;
681 + }
917 682
918 - // Track unique views via cookie
919 - $cookie_name = 'kng_doc_viewed_' . $post_id;
920 - if (!isset($_COOKIE[$cookie_name])) {
921 - $unique_views = get_post_meta($post_id, '_kng_doc_unique_views', true) ?: 0;
922 - update_post_meta($post_id, '_kng_doc_unique_views', $unique_views + 1);
923 - setcookie($cookie_name, '1', time() + DAY_IN_SECONDS, '/');
924 - }
683 + $user = wp_get_current_user();
684 + $allowed_roles = array_map('sanitize_key', (array)($this->options['internal_docs_roles'] ?? ['administrator']));
685 +
686 + return empty(array_intersect($allowed_roles, (array)$user->roles));
925 687 }
926 688
927 - /**
928 - * AJAX: Save article feedback.
929 - *
930 - * @return void
931 - */
932 - public function ajax_save_feedback(): void
689 + private function get_public_docs_meta_query(): array
933 690 {
934 - check_ajax_referer('king_docs_feedback', 'nonce');
691 + return [['key' => '_kng_doc_visibility', 'value' => 'public']];
692 + }
935 693
936 - $post_id = intval($_POST['post_id'] ?? 0);
937 - $helpful = sanitize_text_field($_POST['helpful'] ?? '');
694 + /* ═══════════════════════════════════════════
695 + REACTIONS (AJAX)
696 + ═══════════════════════════════════════════ */
938 697
939 - if (!$post_id || !in_array($helpful, ['yes', 'no'])) {
940 - wp_send_json_error('Invalid data');
698 + public function ajax_save_reaction(): void
699 + {
700 + check_ajax_referer('king_docs_reaction', 'nonce');
701 +
702 + $post_id = intval($_POST['post_id'] ?? 0);
703 + $reaction = sanitize_text_field($_POST['reaction'] ?? '');
704 +
705 + if (!$post_id || !in_array($reaction, ['happy', 'neutral', 'sad'], true)) {
706 + wp_send_json_error('Invalid');
941 707 }
942 708
943 - // Check if already voted via cookie
944 - $cookie_name = 'kng_doc_feedback_' . $post_id;
945 - if (isset($_COOKIE[$cookie_name])) {
709 + $cookie = 'kng_docs_reaction_' . $post_id;
710 + if (isset($_COOKIE[$cookie])) {
946 711 wp_send_json_error('Already voted');
947 712 }
948 713
949 - $meta_key = $helpful === 'yes' ? '_kng_doc_helpful' : '_kng_doc_not_helpful';
950 - $count = get_post_meta($post_id, $meta_key, true) ?: 0;
951 - update_post_meta($post_id, $meta_key, $count + 1);
714 + $key = '_kng_doc_react_' . $reaction;
715 + $count = get_post_meta($post_id, $key, true) ?: 0;
716 + update_post_meta($post_id, $key, $count + 1);
952 717
953 - // Set cookie to prevent duplicate votes
954 - setcookie($cookie_name, $helpful, time() + YEAR_IN_SECONDS, '/');
718 + setcookie($cookie, $reaction, time() + YEAR_IN_SECONDS, '/');
955 719
956 720 wp_send_json_success([
957 - 'helpful' => get_post_meta($post_id, '_kng_doc_helpful', true) ?: 0,
958 - 'not_helpful' => get_post_meta($post_id, '_kng_doc_not_helpful', true) ?: 0,
721 + 'happy' => get_post_meta($post_id, '_kng_doc_react_happy', true) ?: 0,
722 + 'neutral' => get_post_meta($post_id, '_kng_doc_react_neutral', true) ?: 0,
723 + 'sad' => get_post_meta($post_id, '_kng_doc_react_sad', true) ?: 0,
959 724 ]);
960 725 }
961 726
727 + /* ═══════════════════════════════════════════
728 + VIEW TRACKING (Pro)
729 + ═══════════════════════════════════════════ */
730 +
731 + public function track_view(): void
732 + {
733 + if (!is_singular(self::POST_TYPE)) return;
734 + if (empty($this->options['analytics_enabled']) || !$this->is_premium()) return;
735 +
736 + $id = get_the_ID();
737 + update_post_meta($id, '_kng_doc_views', (get_post_meta($id, '_kng_doc_views', true) ?: 0) + 1);
738 +
739 + $ck = 'kng_doc_viewed_' . $id;
740 + if (!isset($_COOKIE[$ck])) {
741 + update_post_meta($id, '_kng_doc_unique_views', (get_post_meta($id, '_kng_doc_unique_views', true) ?: 0) + 1);
742 + setcookie($ck, '1', time() + DAY_IN_SECONDS, '/');
743 + }
744 + }
745 +
746 + /* ═══════════════════════════════════════════
747 + HELPERS
748 + ═══════════════════════════════════════════ */
749 +
750 + private function highlight(string $text, string $q): string
751 + {
752 + if (!$q) return $text;
753 + foreach (explode(' ', $q) as $w) {
754 + if (strlen($w) >= 2) {
755 + $text = preg_replace('/(' . preg_quote($w, '/') . ')/iu', '<mark>$1</mark>', $text);
756 + }
757 + }
758 + return $text;
759 + }
760 +
761 + private function log_search(string $q, int $cnt): void
762 + {
763 + $logs = get_option('king_addons_docs_search_logs', []);
764 + $logs[] = ['query' => $q, 'results' => $cnt, 'ts' => current_time('mysql')];
765 + if (count($logs) > 1000) $logs = array_slice($logs, -1000);
766 + update_option('king_addons_docs_search_logs', $logs);
767 + }
768 +
769 + public static function estimate_reading_time(string $content): string
770 + {
771 + $min = max(1, (int)ceil(str_word_count(strip_tags($content)) / 200));
772 + return sprintf(_n('%d min read', '%d min read', $min, 'king-addons'), $min);
773 + }
774 +
962 775 /**
963 - * Gets docs by category for display.
776 + * Get all docs grouped by category — used by archive template.
964 777 *
965 - * @param int $limit Limit per category.
966 778 * @return array
967 779 */
968 780 public function get_categories_with_docs(int $limit = 5): array
969 781 {
970 - $categories = get_terms([
971 - 'taxonomy' => self::TAXONOMY,
782 + $cats = get_terms([
783 + 'taxonomy' => self::TAXONOMY,
972 784 'hide_empty' => true,
973 - 'parent' => 0,
974 - 'orderby' => 'meta_value_num',
975 - 'meta_key' => 'kng_doc_cat_order',
976 - 'order' => 'ASC',
785 + 'parent' => 0,
786 + 'orderby' => 'meta_value_num',
787 + 'meta_key' => 'kng_doc_cat_order',
788 + 'order' => 'ASC',
977 789 ]);
978 790
979 - $results = [];
791 + $out = [];
792 + foreach ($cats as $c) {
793 + if (is_wp_error($c)) continue;
980 794
981 - foreach ($categories as $cat) {
982 - if (is_wp_error($cat)) {
983 - continue;
984 - }
985 -
986 795 $docs = get_posts([
987 - 'post_type' => self::POST_TYPE,
988 - 'post_status' => 'publish',
796 + 'post_type' => self::POST_TYPE,
797 + 'post_status' => 'publish',
989 798 'posts_per_page' => $limit,
990 - 'tax_query' => [
991 - [
992 - 'taxonomy' => self::TAXONOMY,
993 - 'field' => 'term_id',
994 - 'terms' => $cat->term_id,
995 - ],
996 - ],
997 - 'orderby' => 'menu_order title',
998 - 'order' => 'ASC',
799 + 'tax_query' => [['taxonomy' => self::TAXONOMY, 'field' => 'term_id', 'terms' => $c->term_id]],
800 + 'orderby' => 'menu_order title',
801 + 'order' => 'ASC',
999 802 ]);
1000 803
1001 804 $doc_items = [];
1002 - foreach ($docs as $doc) {
1003 - $doc_items[] = [
1004 - 'id' => $doc->ID,
1005 - 'title' => $doc->post_title,
1006 - 'url' => get_permalink($doc->ID),
1007 - ];
805 + foreach ($docs as $d) {
806 + $doc_items[] = ['id' => $d->ID, 'title' => $d->post_title, 'url' => get_permalink($d->ID)];
1008 807 }
1009 808
1010 - // Get subcategories
1011 809 $subcats = get_terms([
1012 - 'taxonomy' => self::TAXONOMY,
810 + 'taxonomy' => self::TAXONOMY,
1013 811 'hide_empty' => true,
1014 - 'parent' => $cat->term_id,
1015 - 'orderby' => 'meta_value_num',
1016 - 'meta_key' => 'kng_doc_cat_order',
1017 - 'order' => 'ASC',
812 + 'parent' => $c->term_id,
813 + 'orderby' => 'meta_value_num',
814 + 'meta_key' => 'kng_doc_cat_order',
815 + 'order' => 'ASC',
1018 816 ]);
1019 817
1020 - $subcat_items = [];
1021 - foreach ($subcats as $subcat) {
1022 - if (is_wp_error($subcat)) {
1023 - continue;
1024 - }
1025 - $subcat_items[] = [
1026 - 'id' => $subcat->term_id,
1027 - 'name' => $subcat->name,
1028 - 'count' => $subcat->count,
1029 - 'url' => get_term_link($subcat),
818 + $sub_items = [];
819 + foreach ($subcats as $sc) {
820 + if (is_wp_error($sc)) continue;
821 + $sub_items[] = [
822 + 'id' => $sc->term_id, 'name' => $sc->name, 'count' => $sc->count, 'url' => get_term_link($sc),
1030 823 ];
1031 824 }
1032 825
1033 - $results[] = [
1034 - 'id' => $cat->term_id,
1035 - 'name' => $cat->name,
1036 - 'slug' => $cat->slug,
1037 - 'description' => $cat->description,
1038 - 'count' => $cat->count,
1039 - 'icon' => get_term_meta($cat->term_id, 'kng_doc_cat_icon', true) ?: 'book',
1040 - 'url' => get_term_link($cat),
1041 - 'docs' => $doc_items,
1042 - 'subcategories' => $subcat_items,
826 + $out[] = [
827 + 'id' => $c->term_id,
828 + 'name' => $c->name,
829 + 'slug' => $c->slug,
830 + 'description' => $c->description,
831 + 'count' => $c->count,
832 + 'icon' => get_term_meta($c->term_id, 'kng_doc_cat_icon', true) ?: 'book',
833 + 'url' => get_term_link($c),
834 + 'docs' => $doc_items,
835 + 'subcategories' => $sub_items,
1043 836 ];
1044 837 }
1045 838
1046 - return $results;
839 + return $out;
1047 840 }
1048 841
1049 - /**
1050 - * Gets related docs for an article.
1051 - *
1052 - * @param int $post_id Post ID.
1053 - * @param int $count Number of related docs.
1054 - * @return array
1055 - */
1056 842 public function get_related_docs(int $post_id, int $count = 3): array
1057 843 {
1058 - $categories = wp_get_post_terms($post_id, self::TAXONOMY, ['fields' => 'ids']);
844 + $cats = wp_get_post_terms($post_id, self::TAXONOMY, ['fields' => 'ids']);
845 + if (empty($cats) || is_wp_error($cats)) return [];
1059 846
1060 - if (empty($categories) || is_wp_error($categories)) {
1061 - return [];
1062 - }
1063 -
1064 - $args = [
1065 - 'post_type' => self::POST_TYPE,
1066 - 'post_status' => 'publish',
847 + $q = new \WP_Query([
848 + 'post_type' => self::POST_TYPE,
849 + 'post_status' => 'publish',
1067 850 'posts_per_page' => $count,
1068 - 'post__not_in' => [$post_id],
1069 - 'tax_query' => [
1070 - [
1071 - 'taxonomy' => self::TAXONOMY,
1072 - 'field' => 'term_id',
1073 - 'terms' => $categories,
1074 - ],
1075 - ],
1076 - 'orderby' => 'rand',
1077 - ];
851 + 'post__not_in' => [$post_id],
852 + 'tax_query' => [['taxonomy' => self::TAXONOMY, 'field' => 'term_id', 'terms' => $cats]],
853 + 'orderby' => 'rand',
854 + ]);
1078 855
1079 - $query = new \WP_Query($args);
1080 - $results = [];
1081 -
1082 - foreach ($query->posts as $post) {
1083 - $results[] = [
1084 - 'id' => $post->ID,
1085 - 'title' => $post->post_title,
1086 - 'url' => get_permalink($post->ID),
1087 - 'excerpt' => $post->post_excerpt ?: wp_trim_words($post->post_content, 15, '...'),
856 + $out = [];
857 + foreach ($q->posts as $p) {
858 + $out[] = [
859 + 'id' => $p->ID,
860 + 'title' => $p->post_title,
861 + 'url' => get_permalink($p->ID),
862 + 'excerpt' => $p->post_excerpt ?: wp_trim_words($p->post_content, 15, '…'),
1088 863 ];
1089 864 }
1090 -
1091 - return $results;
865 + return $out;
1092 866 }
1093 867
1094 - /**
1095 - * Gets prev/next navigation for an article.
1096 - *
1097 - * @param int $post_id Post ID.
1098 - * @return array
1099 - */
1100 868 public function get_article_navigation(int $post_id): array
1101 869 {
1102 - $categories = wp_get_post_terms($post_id, self::TAXONOMY, ['fields' => 'ids']);
870 + $cats = wp_get_post_terms($post_id, self::TAXONOMY, ['fields' => 'ids']);
871 + if (empty($cats) || is_wp_error($cats)) return ['prev' => null, 'next' => null];
1103 872
1104 - if (empty($categories) || is_wp_error($categories)) {
1105 - return ['prev' => null, 'next' => null];
1106 - }
1107 -
1108 - $all_docs = get_posts([
1109 - 'post_type' => self::POST_TYPE,
1110 - 'post_status' => 'publish',
873 + $all = get_posts([
874 + 'post_type' => self::POST_TYPE,
875 + 'post_status' => 'publish',
1111 876 'posts_per_page' => -1,
1112 - 'tax_query' => [
1113 - [
1114 - 'taxonomy' => self::TAXONOMY,
1115 - 'field' => 'term_id',
1116 - 'terms' => $categories[0],
1117 - ],
1118 - ],
1119 - 'orderby' => 'menu_order title',
1120 - 'order' => 'ASC',
1121 - 'fields' => 'ids',
877 + 'tax_query' => [['taxonomy' => self::TAXONOMY, 'field' => 'term_id', 'terms' => $cats[0]]],
878 + 'orderby' => 'menu_order title',
879 + 'order' => 'ASC',
880 + 'fields' => 'ids',
1122 881 ]);
1123 882
1124 - $current_index = array_search($post_id, $all_docs);
1125 - $prev = null;
1126 - $next = null;
883 + $idx = array_search($post_id, $all);
884 + $prev = $next = null;
1127 885
1128 - if ($current_index !== false) {
1129 - if ($current_index > 0) {
1130 - $prev_id = $all_docs[$current_index - 1];
1131 - $prev = [
1132 - 'id' => $prev_id,
1133 - 'title' => get_the_title($prev_id),
1134 - 'url' => get_permalink($prev_id),
1135 - ];
886 + if ($idx !== false) {
887 + if ($idx > 0) {
888 + $pid = $all[$idx - 1];
889 + $prev = ['id' => $pid, 'title' => get_the_title($pid), 'url' => get_permalink($pid)];
1136 890 }
1137 - if ($current_index < count($all_docs) - 1) {
1138 - $next_id = $all_docs[$current_index + 1];
1139 - $next = [
1140 - 'id' => $next_id,
1141 - 'title' => get_the_title($next_id),
1142 - 'url' => get_permalink($next_id),
1143 - ];
891 + if ($idx < count($all) - 1) {
892 + $nid = $all[$idx + 1];
893 + $next = ['id' => $nid, 'title' => get_the_title($nid), 'url' => get_permalink($nid)];
1144 894 }
1145 895 }
1146 896
1147 897 return ['prev' => $prev, 'next' => $next];
@@ -1146,100 +896,115 @@
1146 896
1147 897 return ['prev' => $prev, 'next' => $next];
1148 898 }
1149 899
900 + /* ═══════════════════════════════════════════
901 + ADMIN PAGE
902 + ═══════════════════════════════════════════ */
903 +
1150 904 /**
1151 - * Renders admin page.
1152 - *
1153 - * @return void
905 + * Highlight King Addons parent menu when editing docs.
1154 906 */
907 + public function fix_admin_parent_file(string $parent_file): string
908 + {
909 + $screen = get_current_screen();
910 + if ($screen && ($screen->post_type === self::POST_TYPE || in_array($screen->taxonomy, [self::TAXONOMY, self::TAG_TAXONOMY], true))) {
911 + return 'king-addons';
912 + }
913 + return $parent_file;
914 + }
915 +
916 + /**
917 + * Highlight correct submenu item when editing docs.
918 + */
919 + public function fix_admin_submenu_file(?string $submenu_file): ?string
920 + {
921 + $screen = get_current_screen();
922 + if (!$screen) return $submenu_file;
923 +
924 + // Keep the single “Builder” page highlighted for any Docs KB admin screens.
925 + if (
926 + ($screen->post_type === self::POST_TYPE)
927 + || (!empty($screen->taxonomy) && in_array($screen->taxonomy, [self::TAXONOMY, self::TAG_TAXONOMY], true))
928 + ) {
929 + return 'king-addons-docs-kb';
930 + }
931 + return $submenu_file;
932 + }
933 +
1155 934 public function render_admin_page(): void
1156 935 {
1157 - if (!current_user_can('manage_options')) {
1158 - return;
1159 - }
936 + if (!current_user_can('manage_options')) return;
1160 937
1161 938 $this->options = $this->get_options();
1162 - $options = $this->options;
1163 - $is_premium = $this->is_premium();
939 + $options = $this->options;
940 + $is_premium = $this->is_premium();
1164 941
1165 942 include __DIR__ . '/templates/admin-page.php';
1166 943 }
1167 944
1168 - /**
1169 - * Handles save settings.
1170 - *
1171 - * @return void
1172 - */
1173 945 public function handle_save_settings(): void
1174 946 {
1175 - if (!current_user_can('manage_options')) {
1176 - wp_die('Unauthorized');
1177 - }
1178 -
947 + if (!current_user_can('manage_options')) wp_die('Unauthorized');
1179 948 check_admin_referer('king_addons_docs_kb_save', 'king_docs_kb_nonce');
1180 949
1181 950 $old_slug = $this->options['docs_slug'] ?? 'docs';
1182 - $options = [];
951 + $o = [];
1183 952
1184 953 // General
1185 - $options['enabled'] = !empty($_POST['enabled']);
1186 - $options['docs_slug'] = sanitize_title($_POST['docs_slug'] ?? 'docs');
1187 - $options['main_page_id'] = intval($_POST['main_page_id'] ?? 0);
1188 - $options['docs_per_page'] = max(1, intval($_POST['docs_per_page'] ?? 10));
954 + $o['enabled'] = !empty($_POST['enabled']);
955 + $o['docs_slug'] = sanitize_title($_POST['docs_slug'] ?? 'docs');
956 + $o['main_page_id'] = intval($_POST['main_page_id'] ?? 0);
957 + $o['docs_per_page'] = max(1, intval($_POST['docs_per_page'] ?? 12));
958 + $o['archive_title'] = sanitize_text_field($_POST['archive_title'] ?? '');
959 + $o['archive_subtitle'] = sanitize_text_field($_POST['archive_subtitle'] ?? '');
1189 960
1190 961 // Layout
1191 - $options['layout'] = in_array($_POST['layout'] ?? 'card', ['box', 'card', 'modern'])
1192 - ? sanitize_text_field($_POST['layout'])
1193 - : 'card';
1194 - $options['columns'] = max(1, min(4, intval($_POST['columns'] ?? 3)));
1195 - $options['show_article_count'] = !empty($_POST['show_article_count']);
1196 - $options['show_category_icon'] = !empty($_POST['show_category_icon']);
962 + $valid_layouts = ['glass-card', 'glass-list', 'glass-grid'];
963 + $o['layout'] = in_array($_POST['layout'] ?? 'glass-card', $valid_layouts) ? sanitize_text_field($_POST['layout']) : 'glass-card';
964 + $o['columns'] = max(1, min(4, intval($_POST['columns'] ?? 3)));
965 + $o['show_article_count'] = !empty($_POST['show_article_count']);
966 + $o['show_category_icon'] = !empty($_POST['show_category_icon']);
967 + $o['dark_mode'] = in_array($_POST['dark_mode'] ?? 'auto', ['light', 'dark', 'auto']) ? sanitize_text_field($_POST['dark_mode']) : 'auto';
1197 968
1198 - // Single Article
1199 - $options['toc_enabled'] = !empty($_POST['toc_enabled']);
1200 - $options['toc_sticky'] = !empty($_POST['toc_sticky']);
1201 - $options['toc_headings'] = sanitize_text_field($_POST['toc_headings'] ?? 'h2,h3');
1202 - $options['sidebar_enabled'] = !empty($_POST['sidebar_enabled']);
1203 - $options['navigation_enabled'] = !empty($_POST['navigation_enabled']);
1204 - $options['print_button'] = !empty($_POST['print_button']);
969 + // Single
970 + $o['toc_enabled'] = !empty($_POST['toc_enabled']);
971 + $o['toc_sticky'] = !empty($_POST['toc_sticky']);
972 + $o['toc_headings'] = sanitize_text_field($_POST['toc_headings'] ?? 'h2,h3');
973 + $o['sidebar_enabled'] = !empty($_POST['sidebar_enabled']);
974 + $o['navigation_enabled'] = !empty($_POST['navigation_enabled']);
975 + $o['print_button'] = !empty($_POST['print_button']);
976 + $o['reading_time'] = !empty($_POST['reading_time']);
977 + $o['social_share'] = !empty($_POST['social_share']);
978 + $o['reactions_enabled'] = !empty($_POST['reactions_enabled']);
1205 979
1206 980 // Search
1207 - $options['search_enabled'] = !empty($_POST['search_enabled']);
1208 - $options['search_placeholder'] = sanitize_text_field($_POST['search_placeholder'] ?? '');
1209 - $options['search_min_chars'] = max(1, intval($_POST['search_min_chars'] ?? 2));
981 + $o['search_enabled'] = !empty($_POST['search_enabled']);
982 + $o['search_placeholder'] = sanitize_text_field($_POST['search_placeholder'] ?? '');
983 + $o['search_min_chars'] = max(1, intval($_POST['search_min_chars'] ?? 2));
1210 984
1211 - // Pro: Multiple KBs
1212 - $options['multiple_kb_enabled'] = !empty($_POST['multiple_kb_enabled']);
985 + // Pro
986 + $o['multiple_kb_enabled'] = !empty($_POST['multiple_kb_enabled']);
987 + $o['internal_docs_enabled'] = !empty($_POST['internal_docs_enabled']);
988 + $o['internal_docs_roles'] = isset($_POST['internal_docs_roles']) ? array_map('sanitize_text_field', (array)$_POST['internal_docs_roles']) : ['administrator'];
989 + $o['feedback_enabled'] = !empty($_POST['feedback_enabled']);
990 + $o['feedback_question'] = sanitize_text_field($_POST['feedback_question'] ?? '');
991 + $o['feedback_thanks'] = sanitize_text_field($_POST['feedback_thanks'] ?? '');
992 + $o['empty_text'] = sanitize_text_field($_POST['empty_text'] ?? '');
993 + $o['related_enabled'] = !empty($_POST['related_enabled']);
994 + $o['related_count'] = max(1, intval($_POST['related_count'] ?? 3));
995 + $o['analytics_enabled'] = !empty($_POST['analytics_enabled']);
996 + $o['analytics_email_report'] = !empty($_POST['analytics_email_report']);
997 + $o['analytics_email'] = sanitize_email($_POST['analytics_email'] ?? '');
1213 998
1214 - // Pro: Internal docs
1215 - $options['internal_docs_enabled'] = !empty($_POST['internal_docs_enabled']);
1216 - $options['internal_docs_roles'] = isset($_POST['internal_docs_roles'])
1217 - ? array_map('sanitize_text_field', (array) $_POST['internal_docs_roles'])
1218 - : ['administrator'];
1219 -
1220 - // Pro: Feedback
1221 - $options['feedback_enabled'] = !empty($_POST['feedback_enabled']);
1222 - $options['feedback_question'] = sanitize_text_field($_POST['feedback_question'] ?? '');
1223 -
1224 - // Pro: Related
1225 - $options['related_enabled'] = !empty($_POST['related_enabled']);
1226 - $options['related_count'] = max(1, intval($_POST['related_count'] ?? 3));
1227 -
1228 - // Pro: Analytics
1229 - $options['analytics_enabled'] = !empty($_POST['analytics_enabled']);
1230 - $options['analytics_email_report'] = !empty($_POST['analytics_email_report']);
1231 - $options['analytics_email'] = sanitize_email($_POST['analytics_email'] ?? '');
1232 -
1233 999 // Colors
1234 - $options['primary_color'] = sanitize_hex_color($_POST['primary_color'] ?? '#0066ff');
1235 - $options['category_icon_color'] = sanitize_hex_color($_POST['category_icon_color'] ?? '#0066ff');
1236 - $options['link_color'] = sanitize_hex_color($_POST['link_color'] ?? '#0066ff');
1000 + $o['primary_color'] = sanitize_hex_color($_POST['primary_color'] ?? '#0071e3');
1001 + $o['category_icon_color'] = sanitize_hex_color($_POST['category_icon_color'] ?? '#0071e3');
1002 + $o['link_color'] = sanitize_hex_color($_POST['link_color'] ?? '#0071e3');
1237 1003
1238 - update_option(self::OPTION_NAME, $options);
1004 + update_option(self::OPTION_NAME, $o);
1239 1005
1240 - // Flush rewrite rules if slug changed
1241 - if ($old_slug !== $options['docs_slug']) {
1006 + if ($old_slug !== $o['docs_slug']) {
1242 1007 delete_option('king_addons_docs_kb_rewrite_flushed');
1243 1008 }
1244 1009
1245 1010 wp_redirect(admin_url('admin.php?page=king-addons-docs-kb&saved=1'));
@@ -1245,30 +1010,61 @@
1245 1010 wp_redirect(admin_url('admin.php?page=king-addons-docs-kb&saved=1'));
1246 1011 exit;
1247 1012 }
1248 1013
1249 - /**
1250 - * Gets icon SVG by name.
1251 - *
1252 - * @param string $name Icon name.
1253 - * @return string
1254 - */
1014 + /* ═══════════════════════════════════════════
1015 + ICONS
1016 + ═══════════════════════════════════════════ */
1017 +
1018 + /** @return array<string,string> */
1019 + public static function get_available_icons(): array
1020 + {
1021 + return [
1022 + 'book' => 'Book',
1023 + 'lightbulb' => 'Lightbulb',
1024 + 'gear' => 'Gear',
1025 + 'rocket' => 'Rocket',
1026 + 'star' => 'Star',
1027 + 'code' => 'Code',
1028 + 'help' => 'Help',
1029 + 'video' => 'Video',
1030 + 'layers' => 'Layers',
1031 + 'shield' => 'Shield',
1032 + 'palette' => 'Palette',
1033 + 'globe' => 'Globe',
1034 + ];
1035 + }
1036 +
1255 1037 public static function get_icon_svg(string $name): string
1256 1038 {
1257 1039 $icons = [
1258 - 'book' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>',
1259 - 'lightbulb' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18h6"/><path d="M10 22h4"/><path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0 0 18 8 6 6 0 0 0 6 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 0 1 8.91 14"/></svg>',
1260 - 'gear' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>',
1261 - 'rocket' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/><path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/></svg>',
1262 - 'star' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>',
1263 - 'code' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>',
1264 - 'help' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>',
1265 - 'video' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/></svg>',
1266 - 'search' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>',
1267 - 'folder' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>',
1268 - 'arrow-right' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>',
1269 - 'arrow-left' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>',
1270 - 'print' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg>',
1040 + 'book' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>',
1041 + 'book-open' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>',
1042 + 'home' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>',
1043 + 'calendar' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>',
1044 + 'clock' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>',
1045 + 'eye' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>',
1046 + 'list' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>',
1047 + 'file-text' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z"/><path d="M14 2v6h6"/><path d="M16 13H8"/><path d="M16 17H8"/><path d="M10 9H8"/></svg>',
1048 + 'printer' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg>',
1049 + 'lightbulb' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6"/><path d="M10 22h4"/><path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0 0 18 8 6 6 0 0 0 6 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 0 1 8.91 14"/></svg>',
1050 + 'gear' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M12 1v2m0 18v2M4.22 4.22l1.42 1.42m12.72 12.72 1.42 1.42M1 12h2m18 0h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>',
1051 + 'rocket' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/><path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/></svg>',
1052 + 'star' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>',
1053 + 'code' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>',
1054 + 'help' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>',
1055 + 'video' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2"/></svg>',
1056 + 'layers' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg>',
1057 + 'shield' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>',
1058 + 'palette' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="13.5" cy="6.5" r="0.5" fill="currentColor"/><circle cx="17.5" cy="10.5" r="0.5" fill="currentColor"/><circle cx="8.5" cy="7.5" r="0.5" fill="currentColor"/><circle cx="6.5" cy="12.5" r="0.5" fill="currentColor"/><path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z"/></svg>',
1059 + 'globe' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>',
1060 + 'search' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>',
1061 + 'folder' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>',
1062 + 'arrow-right' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>',
1063 + 'arrow-left' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>',
1064 + 'print' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg>',
1065 + 'share' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>',
1066 + 'link' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>',
1271 1067 ];
1272 1068
1273 1069 return $icons[$name] ?? $icons['book'];
1274 1070 }