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/Core/SampleDocBuilder.php +398 -22 4.6.14.9.2 View file →
@@ -28,12 +28,20 @@
28 28 'faq' => [ 'post_type' => 'betterdocs_faq', 'taxonomy' => 'betterdocs_faq_category' ],
29 29 'product_faq' => [ 'post_type' => 'betterdocs_faq', 'taxonomy' => 'betterdocs_product_faq_category' ],
30 30 ];
31 31
32 + /** Ordering rank so an intro then a quickstart always lead their category. */
33 + const TYPE_RANK = [ 'intro' => 0, 'quickstart' => 1 ];
34 +
32 35 /**
33 36 * Create terms + posts from the (already sanitized) categories array.
34 37 *
35 - * @param array $categories [{ name, description, articles:[{title,content_html,excerpt}] }]
38 + * Two passes so the AI's intra-KB cross-links resolve to real permalinks: pass 1
39 + * creates every term + post (capturing each article's declared slug → post id);
40 + * pass 2 rewrites the `#bd-link--slug` sentinels in each body to the sibling's real
41 + * permalink (neutralizing any that never got created) and writes the final blocks.
42 + *
43 + * @param array $categories [{ name, slug?, description, articles:[{title,slug?,type?,content_html,excerpt}] }]
36 44 * @param string $content_type 'docs' | 'faq'
37 45 * @return array|WP_Error Summary on success.
38 46 */
39 47 public function build( array $categories, $content_type = 'docs' ) {
@@ -54,11 +62,30 @@
54 62 if ( $existing['categories'] > 0 || $existing['articles'] > 0 ) {
55 63 return array_merge( [ 'content_type' => $content_type, 'already_exists' => true ], $existing );
56 64 }
57 65
58 - $created_terms = [];
59 - $created_posts = [];
66 + $created_terms = [];
67 + $created_posts = [];
68 + $slug_to_id = []; // declared article slug → created post id (cross-link map)
69 + $pending = []; // [ post_id => raw content_html ] to finalize in pass 2
70 + $cat_posts = []; // term_id → [ post_id, … ] in intended reading order
71 + $order = 0; // global menu_order so intro/quickstart lead the whole KB
72 + $order_meta_key = $this->category_order_meta_key( $map['taxonomy'] );
60 73
74 + /**
75 + * Status sample content is created with. Draft by default so AI-generated docs
76 + * are never auto-published on a live site — the owner reviews each and publishes
77 + * it manually. Drafts are non-public/non-indexable but still show in the admin
78 + * dashboard (categories via hide_empty=false; docs via the edit_docs status set),
79 + * so they can be reviewed. Each post is flagged `_betterdocs_sample` (used by
80 + * undo). Filterable for sites that prefer to publish immediately.
81 + *
82 + * @param string $status Post status ('draft').
83 + * @param string $content_type docs|faq|product_faq
84 + */
85 + $post_status = apply_filters( 'betterdocs_sample_docs_post_status', 'draft', $content_type );
86 +
87 + // -- Pass 1: create terms + posts (bodies still carry link sentinels) --------
61 88 foreach ( $categories as $cat_index => $category ) {
62 89 if ( empty( $category['name'] ) ) {
63 90 continue;
64 91 }
@@ -69,9 +96,9 @@
69 96 }
70 97 $created_terms[] = $term_id;
71 98
72 99 $articles = isset( $category['articles'] ) && is_array( $category['articles'] ) ? $category['articles'] : [];
73 - foreach ( array_values( $articles ) as $order => $article ) {
100 + foreach ( $this->order_articles( $articles ) as $article ) {
74 101 $title = is_array( $article ) ? ( $article['title'] ?? '' ) : (string) $article;
75 102 if ( '' === trim( $title ) ) {
76 103 continue;
77 104 }
@@ -77,21 +104,29 @@
77 104 }
78 105
79 106 $content = is_array( $article ) ? ( $article['content_html'] ?? '' ) : '';
80 107 $excerpt = is_array( $article ) ? ( $article['excerpt'] ?? '' ) : '';
108 + $slug = is_array( $article ) && ! empty( $article['slug'] ) ? sanitize_title( $article['slug'] ) : '';
81 109
82 - $post_id = wp_insert_post(
83 - [
84 - 'post_type' => $map['post_type'],
85 - 'post_title' => wp_strip_all_tags( $title ),
86 - 'post_content' => $this->html_to_blocks( $content ),
87 - 'post_excerpt' => sanitize_text_field( $excerpt ),
88 - 'post_status' => 'publish',
89 - 'menu_order' => $order,
90 - ],
91 - true
92 - );
110 + $postarr = [
111 + 'post_type' => $map['post_type'],
112 + 'post_title' => wp_strip_all_tags( $title ),
113 + // Placeholder now; the real blocks are written in pass 2 once the
114 + // full slug → id map exists.
115 + 'post_content' => '',
116 + 'post_excerpt' => sanitize_text_field( $excerpt ),
117 + 'post_status' => $post_status,
118 + 'menu_order' => $order++,
119 + ];
120 + // Give drafts a real slug up front — WP only derives post_name on publish,
121 + // so without this a draft's get_permalink() (used to resolve cross-links)
122 + // would be an ugly ?p=ID URL.
123 + if ( '' !== $slug ) {
124 + $postarr['post_name'] = $slug;
125 + }
93 126
127 + $post_id = wp_insert_post( $postarr, true );
128 +
94 129 if ( is_wp_error( $post_id ) ) {
95 130 continue;
96 131 }
97 132
@@ -96,12 +131,77 @@
96 131 }
97 132
98 133 wp_set_object_terms( $post_id, [ (int) $term_id ], $map['taxonomy'] );
99 134 update_post_meta( $post_id, self::SAMPLE_META, 1 );
100 - $created_posts[] = $post_id;
135 +
136 + // Record which FAQ tab this belongs to, so it stays in that tab's
137 + // "Uncategorized" bucket if its group is ever deleted. FAQBuilder syncs this
138 + // on set_object_terms too, but stamping it here does not depend on that hook
139 + // being loaded — and a generated FAQ that ends up scope-less silently
140 + // defaults into the GENERAL tab, which is how store FAQs from the old
141 + // product generator ended up mixed in with general ones.
142 + if ( 'docs' !== $content_type ) {
143 + update_post_meta(
144 + $post_id,
145 + '_betterdocs_faq_scope',
146 + 'product_faq' === $content_type ? 'product' : 'general'
147 + );
148 + }
149 +
150 + if ( '' !== $slug && ! isset( $slug_to_id[ $slug ] ) ) {
151 + $slug_to_id[ $slug ] = $post_id;
152 + }
153 +
154 + $pending[ $post_id ] = (string) $content;
155 + $created_posts[] = $post_id;
156 + $cat_posts[ $term_id ][] = $post_id; // preserve intro/quickstart-first order
101 157 }
102 158 }
103 159
160 + // -- Pass 2: resolve cross-links, convert to blocks, write final content ------
161 + foreach ( $pending as $post_id => $raw ) {
162 + $resolved = $this->resolve_cross_links( $raw, $slug_to_id );
163 + wp_update_post(
164 + [
165 + 'ID' => $post_id,
166 + 'post_content' => $this->html_to_blocks( $resolved ),
167 + ]
168 + );
169 + }
170 +
171 + // -- Pass 3: pin category display order to the KB's designed reading order -----
172 + // Done LAST, after every term + post exists, because BetterDocs re-seeds
173 + // `doc_category_order` to max+1 via the created_doc_category hook as each later
174 + // category is created — which would otherwise bump the first category (Getting
175 + // Started) to the end.
176 + //
177 + // Order is 1-based on purpose: BetterDocs' default_term_order() (which runs on
178 + // the admin dashboard load) treats a term whose order fails `! get_term_meta()`
179 + // as "unordered" and reassigns it to max+1. A 0 value is falsy in PHP, so a
180 + // first category at 0 would get bumped to the end on the very next page load.
181 + // Starting at 1 keeps every value truthy and the intended order stable.
182 + foreach ( array_values( $created_terms ) as $position => $term_id ) {
183 + update_term_meta( $term_id, $order_meta_key, $position + 1 );
184 + }
185 +
186 + // Pin the WITHIN-category item order for EVERY content type. BetterDocs orders
187 + // items in a category by a term-meta id list (docs: `_docs_order`; FAQ &
188 + // product FAQ: `_betterdocs_faq_order`), and its insert hooks PREPEND each new
189 + // item — so left alone the order comes out reversed, AND for FAQs it is never
190 + // seeded at all, which is why publishing a drafted sample FAQ reshuffled the
191 + // list to the top. Overwrite it here with the intended generation order so the
192 + // list stays put through activation, exactly as docs do.
193 + $order_key = $this->within_category_order_meta_key( $map['taxonomy'] );
194 + if ( $order_key ) {
195 + foreach ( $cat_posts as $term_id => $post_ids ) {
196 + update_term_meta(
197 + $term_id,
198 + 'doc_category' === $map['taxonomy'] ? $this->docs_order_meta_key( $term_id ) : $order_key,
199 + implode( ',', array_map( 'intval', $post_ids ) )
200 + );
201 + }
202 + }
203 +
104 204 /**
105 205 * Fires after sample content is created (telemetry/integration hook).
106 206 *
107 207 * @param array $created_posts
@@ -119,8 +219,101 @@
119 219 ];
120 220 }
121 221
122 222 /**
223 + * Stable-sort a category's articles so an "intro" then a "quickstart" always lead,
224 + * with everything else keeping its given order.
225 + *
226 + * @return array
227 + */
228 + protected function order_articles( array $articles ) {
229 + $articles = array_values( $articles );
230 + $indexed = [];
231 + foreach ( $articles as $i => $article ) {
232 + $type = is_array( $article ) && ! empty( $article['type'] ) ? (string) $article['type'] : '';
233 + $rank = isset( self::TYPE_RANK[ $type ] ) ? self::TYPE_RANK[ $type ] : 2;
234 + $indexed[] = [ 'rank' => $rank, 'i' => $i, 'article' => $article ];
235 + }
236 + usort(
237 + $indexed,
238 + function ( $a, $b ) {
239 + return $a['rank'] === $b['rank'] ? ( $a['i'] <=> $b['i'] ) : ( $a['rank'] <=> $b['rank'] );
240 + }
241 + );
242 + return array_column( $indexed, 'article' );
243 + }
244 +
245 + /**
246 + * Rewrite `#bd-link--slug` cross-link sentinels to real permalinks. Slugs that were
247 + * never created (e.g. a skipped article) have their surrounding <a> stripped so no
248 + * dangling sentinel is ever published.
249 + *
250 + * @param string $html
251 + * @param array $slug_to_id declared slug → post id
252 + * @return string
253 + */
254 + protected function resolve_cross_links( $html, array $slug_to_id ) {
255 + $html = (string) $html;
256 + if ( false === strpos( $html, '#bd-link--' ) ) {
257 + return $html;
258 + }
259 +
260 + // Resolve known slugs to permalinks. The slug class stops at the closing quote,
261 + // so this matches the whole slug (no partial-prefix collisions).
262 + $html = preg_replace_callback(
263 + '/#bd-link--([a-z0-9\-]+)/i',
264 + function ( $m ) use ( $slug_to_id ) {
265 + $slug = strtolower( $m[1] );
266 + if ( isset( $slug_to_id[ $slug ] ) ) {
267 + $url = $this->permalink_for( $slug_to_id[ $slug ] );
268 + if ( $url ) {
269 + return esc_url( $url );
270 + }
271 + }
272 + // Leave the sentinel in place so the dangling-anchor sweep below removes it.
273 + return '#bd-link--' . $slug;
274 + },
275 + $html
276 + );
277 +
278 + // Strip any anchor whose href is still an unresolved sentinel, keeping its text.
279 + $html = preg_replace(
280 + '/<a\b[^>]*href=("|\')#bd-link--[a-z0-9\-]+\1[^>]*>(.*?)<\/a>/is',
281 + '$2',
282 + $html
283 + );
284 +
285 + return $html;
286 + }
287 +
288 + /**
289 + * Pretty permalink for a post that may still be a draft. get_permalink() returns
290 + * an ugly `?p=ID` URL for unpublished posts, so for cross-links we build the
291 + * would-be published permalink (stable through review → publish).
292 + *
293 + * @param int $post_id
294 + * @return string
295 + */
296 + protected function permalink_for( $post_id ) {
297 + $status = get_post_status( $post_id );
298 + if ( in_array( $status, [ 'publish', 'future', 'private' ], true ) ) {
299 + return (string) get_permalink( $post_id );
300 + }
301 +
302 + if ( ! function_exists( 'get_sample_permalink' ) ) {
303 + require_once ABSPATH . 'wp-admin/includes/post.php';
304 + }
305 + $sample = get_sample_permalink( $post_id );
306 + $post = get_post( $post_id );
307 + if ( is_array( $sample ) && ! empty( $sample[0] ) && $post instanceof \WP_Post ) {
308 + $name = '' !== $post->post_name ? $post->post_name : sanitize_title( $post->post_title );
309 + return str_replace( [ '%pagename%', '%postname%' ], $name, $sample[0] );
310 + }
311 +
312 + return (string) get_permalink( $post_id );
313 + }
314 +
315 + /**
123 316 * Remove exactly the sample content we created for a content type.
124 317 *
125 318 * @param string $content_type 'docs' | 'faq'
126 319 * @return array Counts removed.
@@ -226,8 +419,61 @@
226 419 ];
227 420 }
228 421
229 422 /**
423 + * Term-meta key BetterDocs orders categories by, per taxonomy. Docs use
424 + * `doc_category_order` (with the multilingual fallback BetterDocs itself uses);
425 + * FAQ and product-FAQ categories use `order`.
426 + *
427 + * @param string $taxonomy
428 + * @return string
429 + */
430 + protected function category_order_meta_key( $taxonomy ) {
431 + if ( 'doc_category' === $taxonomy ) {
432 + $helper = 'WPDeveloper\\BetterDocs\\Utils\\Helper';
433 + if ( class_exists( $helper ) && method_exists( $helper, 'get_meta_key_with_fallback' ) ) {
434 + return $helper::get_meta_key_with_fallback( 'doc_category_order' );
435 + }
436 + return 'doc_category_order';
437 + }
438 + return 'order';
439 + }
440 +
441 + /**
442 + * Term-meta key BetterDocs orders items *within* a category by, per taxonomy:
443 + * docs → `_docs_order` (multilingual-aware, see docs_order_meta_key()); FAQ and
444 + * product FAQ → `_betterdocs_faq_order`. Empty string if the taxonomy has no such
445 + * ordering meta.
446 + *
447 + * @param string $taxonomy
448 + * @return string
449 + */
450 + protected function within_category_order_meta_key( $taxonomy ) {
451 + if ( 'doc_category' === $taxonomy ) {
452 + return '_docs_order';
453 + }
454 + if ( in_array( $taxonomy, [ 'betterdocs_faq_category', 'betterdocs_product_faq_category' ], true ) ) {
455 + return '_betterdocs_faq_order';
456 + }
457 + return '';
458 + }
459 +
460 + /**
461 + * Term-meta key BetterDocs orders docs *within* a category by (`_docs_order`),
462 + * using the same multilingual fallback the plugin itself applies.
463 + *
464 + * @param int $term_id
465 + * @return string
466 + */
467 + protected function docs_order_meta_key( $term_id ) {
468 + $helper = 'WPDeveloper\\BetterDocs\\Utils\\Helper';
469 + if ( class_exists( $helper ) && method_exists( $helper, 'get_meta_key_with_fallback' ) ) {
470 + return $helper::get_meta_key_with_fallback( '_docs_order', $term_id );
471 + }
472 + return '_docs_order';
473 + }
474 +
475 + /**
230 476 * Create the category term (or reuse an existing one) and flag it if new.
231 477 *
232 478 * @return int|WP_Error
233 479 */
@@ -235,8 +481,11 @@
235 481 $name = sanitize_text_field( $category['name'] );
236 482 $existing = term_exists( $name, $taxonomy );
237 483
238 484 if ( $existing && ! empty( $existing['term_id'] ) ) {
485 + // Still (re)assign: a re-run reuses the existing group term, and returning early
486 + // left it assigned to nothing (so its FAQs showed on no product page at all).
487 + $this->route_product_assignment( (int) $existing['term_id'], $category, $taxonomy );
239 488 return (int) $existing['term_id'];
240 489 }
241 490
242 491 $inserted = wp_insert_term(
@@ -251,17 +500,136 @@
251 500
252 501 $term_id = (int) $inserted['term_id'];
253 502 update_term_meta( $term_id, self::SAMPLE_META, 1 );
254 503
255 - // Do NOT flag sample product-FAQ groups as "show on all products". A
256 - // product FAQ should only appear where the owner explicitly assigns it
257 - // (by product or category); auto-assigning leaked generated FAQs onto
258 - // every product page even when nothing was assigned.
504 + $this->route_product_assignment( $term_id, $category, $taxonomy );
259 505
260 506 return $term_id;
261 507 }
262 508
263 509 /**
510 + * Decide where a generated Product FAQ group is shown on the storefront.
511 + *
512 + * Two layers:
513 + * - Store-wide (Layer 1, `all_products`) → flag the group to show on EVERY product
514 + * page, once. This is the consolidated Payments/Shipping/Returns/Orders group.
515 + * - Per-category (Layer 2) → attach to the one product category it is about.
516 + *
517 + * A generated group with neither would be invisible on the storefront, so every
518 + * product FAQ group we create is routed to exactly one of the two.
519 + *
520 + * @param int $term_id
521 + * @param array $category The generated payload (carries `all_products` / `product_category`).
522 + * @param string $taxonomy
523 + */
524 + protected function route_product_assignment( $term_id, array $category, $taxonomy ) {
525 + if ( 'betterdocs_product_faq_category' !== $taxonomy ) {
526 + return;
527 + }
528 +
529 + if ( ! empty( $category['all_products'] ) ) {
530 + // Show on every product page — the dormant front-end mechanism in
531 + // WooProductFAQ::get_group_ids_for_product(). Reuse the constant, not the raw key.
532 + update_term_meta( $term_id, FAQBuilder::GROUP_ALL_PRODUCTS_META, true );
533 + // Make sure a re-run that switched a group to store-wide drops any stale
534 + // per-category assignment.
535 + delete_term_meta( $term_id, FAQBuilder::GROUP_PRODUCT_CATS_META );
536 + return;
537 + }
538 +
539 + $this->assign_product_category( $term_id, $category, $taxonomy );
540 + }
541 +
542 + /**
543 + * Attach a generated Product FAQ group to the WooCommerce product category it was
544 + * written about (matched by name, which is what the AI was given).
545 + *
546 + * @param int $term_id The FAQ group term.
547 + * @param array $category The generated category payload (carries `product_category`).
548 + * @param string $taxonomy The FAQ taxonomy being built.
549 + */
550 + protected function assign_product_category( $term_id, array $category, $taxonomy ) {
551 + if ( 'betterdocs_product_faq_category' !== $taxonomy || ! taxonomy_exists( 'product_cat' ) ) {
552 + return;
553 + }
554 +
555 + // The AI echoes back the exact product category name it was given; the group's own
556 + // name is the fallback (it's derived from that category, but the model may have
557 + // prettified it — "Tshirts" → "T-Shirts with Logo"), so try both.
558 + $candidates = [];
559 + foreach ( [ $category['product_category'] ?? '', $category['name'] ?? '' ] as $candidate ) {
560 + $candidate = trim( html_entity_decode( wp_strip_all_tags( (string) $candidate ), ENT_QUOTES, 'UTF-8' ) );
561 + if ( '' !== $candidate ) {
562 + $candidates[] = $candidate;
563 + }
564 + }
565 + if ( empty( $candidates ) ) {
566 + return;
567 + }
568 +
569 + $product_cat = null;
570 + foreach ( $candidates as $candidate ) {
571 + $product_cat = get_term_by( 'name', $candidate, 'product_cat' );
572 + if ( ! $product_cat ) {
573 + $product_cat = get_term_by( 'slug', sanitize_title( $candidate ), 'product_cat' );
574 + }
575 + if ( ! $product_cat ) {
576 + $product_cat = $this->match_product_cat_loosely( $candidate );
577 + }
578 + if ( $product_cat && ! is_wp_error( $product_cat ) ) {
579 + break;
580 + }
581 + }
582 +
583 + if ( ! $product_cat || is_wp_error( $product_cat ) ) {
584 + return;
585 + }
586 +
587 + update_term_meta( $term_id, '_betterdocs_faq_group_product_cats', [ (int) $product_cat->term_id ] );
588 + }
589 +
590 + /**
591 + * Last-resort product-category match for a group name the model reworded ("T-Shirts
592 + * with Logo" → "Tshirts", "Music & Audio" → "Music"). Compares on a letters-and-digits
593 + * only, lower-cased key, then falls back to containment. Returns the term or null.
594 + *
595 + * A wrong assignment would publish an FAQ on the wrong product pages, so only an
596 + * unambiguous single match is accepted.
597 + *
598 + * @return \WP_Term|null
599 + */
600 + protected function match_product_cat_loosely( $name ) {
601 + $terms = get_terms( [ 'taxonomy' => 'product_cat', 'hide_empty' => false ] );
602 + if ( is_wp_error( $terms ) || empty( $terms ) ) {
603 + return null;
604 + }
605 +
606 + $key = function ( $value ) {
607 + return preg_replace( '/[^a-z0-9]/', '', strtolower( (string) $value ) );
608 + };
609 +
610 + $needle = $key( $name );
611 + if ( '' === $needle ) {
612 + return null;
613 + }
614 +
615 + $matches = [];
616 + foreach ( $terms as $term ) {
617 + $hay = $key( $term->name );
618 + if ( '' === $hay ) {
619 + continue;
620 + }
621 + if ( $hay === $needle || false !== strpos( $needle, $hay ) || false !== strpos( $hay, $needle ) ) {
622 + $matches[] = $term;
623 + }
624 + }
625 +
626 + // Ambiguous (e.g. "Clothing" also matching "Clothing Accessories") — assign nothing
627 + // rather than the wrong category.
628 + return 1 === count( $matches ) ? $matches[0] : null;
629 + }
630 +
631 + /**
264 632 * Convert the proxy's content HTML into clean Gutenberg block markup.
265 633 * Each top-level element becomes its matching core block; unknown nodes
266 634 * fall back to a paragraph. Empty input yields an empty paragraph block.
267 635 *
@@ -267,10 +635,18 @@
267 635 *
268 636 * @return string
269 637 */
270 638 protected function html_to_blocks( $html ) {
271 - $html = trim( (string) $html );
272 - if ( '' === $html ) {
639 + // Sanitize our OWN input rather than trusting the caller. node_to_block() emits
640 + // $dom->saveHTML() verbatim for every known tag (p, h1-h6, ul, ol, blockquote,
641 + // pre), so any attribute on those elements — including an onclick — is copied
642 + // straight into post_content. Today the REST layer kses's the AI response before
643 + // it ever gets here, so nothing leaks; this makes that a property of the method
644 + // instead of a property of its one current caller. wp_kses_post() is idempotent,
645 + // so the existing double-sanitization costs only a pass over the string, and it
646 + // preserves the `#bd-link--slug` cross-link fragments the builder resolves.
647 + $html = wp_kses_post( trim( (string) $html ) );
648 + if ( '' === trim( $html ) ) {
273 649 return "<!-- wp:paragraph --><p></p><!-- /wp:paragraph -->";
274 650 }
275 651
276 652 if ( ! class_exists( '\DOMDocument' ) ) {