| @@ -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' ) ) { |