]*href=("|\')#bd-link--[a-z0-9\-]+\1[^>]*>(.*?)<\/a>/is',
'$2',
$html
);
return $html;
}
/**
* Pretty permalink for a post that may still be a draft. get_permalink() returns
* an ugly `?p=ID` URL for unpublished posts, so for cross-links we build the
* would-be published permalink (stable through review → publish).
*
* @param int $post_id
* @return string
*/
protected function permalink_for( $post_id ) {
$status = get_post_status( $post_id );
if ( in_array( $status, [ 'publish', 'future', 'private' ], true ) ) {
return (string) get_permalink( $post_id );
}
if ( ! function_exists( 'get_sample_permalink' ) ) {
require_once ABSPATH . 'wp-admin/includes/post.php';
}
$sample = get_sample_permalink( $post_id );
$post = get_post( $post_id );
if ( is_array( $sample ) && ! empty( $sample[0] ) && $post instanceof \WP_Post ) {
$name = '' !== $post->post_name ? $post->post_name : sanitize_title( $post->post_title );
return str_replace( [ '%pagename%', '%postname%' ], $name, $sample[0] );
}
return (string) get_permalink( $post_id );
}
/**
* Remove exactly the sample content we created for a content type.
*
* @param string $content_type 'docs' | 'faq'
* @return array Counts removed.
*/
public function undo( $content_type = 'docs' ) {
$map = $this->map( $content_type );
if ( null === $map ) {
return [ 'categories' => 0, 'articles' => 0 ];
}
// 1. Delete flagged posts. Scope by taxonomy as well as post type: General
// FAQs and Product FAQs share the `betterdocs_faq` post type, so the
// taxonomy is what keeps "undo" from removing the other scope's samples.
$posts = get_posts(
[
'post_type' => $map['post_type'],
'post_status' => 'any',
'posts_per_page' => -1,
'fields' => 'ids',
'meta_key' => self::SAMPLE_META, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
'meta_value' => 1, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
'tax_query' => [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
[
'taxonomy' => $map['taxonomy'],
'operator' => 'EXISTS',
],
],
]
);
foreach ( $posts as $post_id ) {
wp_delete_post( $post_id, true );
}
// 2. Delete flagged terms.
$terms = get_terms(
[
'taxonomy' => $map['taxonomy'],
'hide_empty' => false,
'fields' => 'ids',
'meta_key' => self::SAMPLE_META, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
'meta_value' => 1, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
]
);
$term_count = 0;
if ( ! is_wp_error( $terms ) ) {
foreach ( $terms as $term_id ) {
wp_delete_term( $term_id, $map['taxonomy'] );
$term_count++;
}
}
do_action( 'betterdocs_sample_docs_removed', $content_type );
return [
'content_type' => $content_type,
'categories' => $term_count,
'articles' => count( $posts ),
];
}
/* --------------------------------------------------------------------- */
/**
* Collect the sample content already created for a content type, so build()
* stays idempotent against duplicate insert calls.
*
* @return array { categories, articles, term_ids, post_ids }
*/
protected function existing_sample( array $map ) {
$post_ids = get_posts(
[
'post_type' => $map['post_type'],
'post_status' => 'any',
'posts_per_page' => -1,
'fields' => 'ids',
'meta_key' => self::SAMPLE_META, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
'meta_value' => 1, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
'tax_query' => [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
[
'taxonomy' => $map['taxonomy'],
'operator' => 'EXISTS',
],
],
]
);
$term_ids = get_terms(
[
'taxonomy' => $map['taxonomy'],
'hide_empty' => false,
'fields' => 'ids',
'meta_key' => self::SAMPLE_META, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
'meta_value' => 1, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
]
);
$term_ids = is_wp_error( $term_ids ) ? [] : $term_ids;
return [
'categories' => count( $term_ids ),
'articles' => count( $post_ids ),
'term_ids' => array_map( 'intval', $term_ids ),
'post_ids' => array_map( 'intval', $post_ids ),
];
}
/**
* Term-meta key BetterDocs orders categories by, per taxonomy. Docs use
* `doc_category_order` (with the multilingual fallback BetterDocs itself uses);
* FAQ and product-FAQ categories use `order`.
*
* @param string $taxonomy
* @return string
*/
protected function category_order_meta_key( $taxonomy ) {
if ( 'doc_category' === $taxonomy ) {
$helper = 'WPDeveloper\\BetterDocs\\Utils\\Helper';
if ( class_exists( $helper ) && method_exists( $helper, 'get_meta_key_with_fallback' ) ) {
return $helper::get_meta_key_with_fallback( 'doc_category_order' );
}
return 'doc_category_order';
}
return 'order';
}
/**
* Term-meta key BetterDocs orders items *within* a category by, per taxonomy:
* docs → `_docs_order` (multilingual-aware, see docs_order_meta_key()); FAQ and
* product FAQ → `_betterdocs_faq_order`. Empty string if the taxonomy has no such
* ordering meta.
*
* @param string $taxonomy
* @return string
*/
protected function within_category_order_meta_key( $taxonomy ) {
if ( 'doc_category' === $taxonomy ) {
return '_docs_order';
}
if ( in_array( $taxonomy, [ 'betterdocs_faq_category', 'betterdocs_product_faq_category' ], true ) ) {
return '_betterdocs_faq_order';
}
return '';
}
/**
* Term-meta key BetterDocs orders docs *within* a category by (`_docs_order`),
* using the same multilingual fallback the plugin itself applies.
*
* @param int $term_id
* @return string
*/
protected function docs_order_meta_key( $term_id ) {
$helper = 'WPDeveloper\\BetterDocs\\Utils\\Helper';
if ( class_exists( $helper ) && method_exists( $helper, 'get_meta_key_with_fallback' ) ) {
return $helper::get_meta_key_with_fallback( '_docs_order', $term_id );
}
return '_docs_order';
}
/**
* Create the category term (or reuse an existing one) and flag it if new.
*
* @return int|WP_Error
*/
protected function ensure_term( array $category, $taxonomy ) {
$name = sanitize_text_field( $category['name'] );
$existing = term_exists( $name, $taxonomy );
if ( $existing && ! empty( $existing['term_id'] ) ) {
// Still (re)assign: a re-run reuses the existing group term, and returning early
// left it assigned to nothing (so its FAQs showed on no product page at all).
$this->route_product_assignment( (int) $existing['term_id'], $category, $taxonomy );
return (int) $existing['term_id'];
}
$inserted = wp_insert_term(
$name,
$taxonomy,
[ 'description' => sanitize_text_field( $category['description'] ?? '' ) ]
);
if ( is_wp_error( $inserted ) ) {
return $inserted;
}
$term_id = (int) $inserted['term_id'];
update_term_meta( $term_id, self::SAMPLE_META, 1 );
$this->route_product_assignment( $term_id, $category, $taxonomy );
return $term_id;
}
/**
* Decide where a generated Product FAQ group is shown on the storefront.
*
* Two layers:
* - Store-wide (Layer 1, `all_products`) → flag the group to show on EVERY product
* page, once. This is the consolidated Payments/Shipping/Returns/Orders group.
* - Per-category (Layer 2) → attach to the one product category it is about.
*
* A generated group with neither would be invisible on the storefront, so every
* product FAQ group we create is routed to exactly one of the two.
*
* @param int $term_id
* @param array $category The generated payload (carries `all_products` / `product_category`).
* @param string $taxonomy
*/
protected function route_product_assignment( $term_id, array $category, $taxonomy ) {
if ( 'betterdocs_product_faq_category' !== $taxonomy ) {
return;
}
if ( ! empty( $category['all_products'] ) ) {
// Show on every product page — the dormant front-end mechanism in
// WooProductFAQ::get_group_ids_for_product(). Reuse the constant, not the raw key.
update_term_meta( $term_id, FAQBuilder::GROUP_ALL_PRODUCTS_META, true );
// Make sure a re-run that switched a group to store-wide drops any stale
// per-category assignment.
delete_term_meta( $term_id, FAQBuilder::GROUP_PRODUCT_CATS_META );
return;
}
$this->assign_product_category( $term_id, $category, $taxonomy );
}
/**
* Attach a generated Product FAQ group to the WooCommerce product category it was
* written about (matched by name, which is what the AI was given).
*
* @param int $term_id The FAQ group term.
* @param array $category The generated category payload (carries `product_category`).
* @param string $taxonomy The FAQ taxonomy being built.
*/
protected function assign_product_category( $term_id, array $category, $taxonomy ) {
if ( 'betterdocs_product_faq_category' !== $taxonomy || ! taxonomy_exists( 'product_cat' ) ) {
return;
}
// The AI echoes back the exact product category name it was given; the group's own
// name is the fallback (it's derived from that category, but the model may have
// prettified it — "Tshirts" → "T-Shirts with Logo"), so try both.
$candidates = [];
foreach ( [ $category['product_category'] ?? '', $category['name'] ?? '' ] as $candidate ) {
$candidate = trim( html_entity_decode( wp_strip_all_tags( (string) $candidate ), ENT_QUOTES, 'UTF-8' ) );
if ( '' !== $candidate ) {
$candidates[] = $candidate;
}
}
if ( empty( $candidates ) ) {
return;
}
$product_cat = null;
foreach ( $candidates as $candidate ) {
$product_cat = get_term_by( 'name', $candidate, 'product_cat' );
if ( ! $product_cat ) {
$product_cat = get_term_by( 'slug', sanitize_title( $candidate ), 'product_cat' );
}
if ( ! $product_cat ) {
$product_cat = $this->match_product_cat_loosely( $candidate );
}
if ( $product_cat && ! is_wp_error( $product_cat ) ) {
break;
}
}
if ( ! $product_cat || is_wp_error( $product_cat ) ) {
return;
}
update_term_meta( $term_id, '_betterdocs_faq_group_product_cats', [ (int) $product_cat->term_id ] );
}
/**
* Last-resort product-category match for a group name the model reworded ("T-Shirts
* with Logo" → "Tshirts", "Music & Audio" → "Music"). Compares on a letters-and-digits
* only, lower-cased key, then falls back to containment. Returns the term or null.
*
* A wrong assignment would publish an FAQ on the wrong product pages, so only an
* unambiguous single match is accepted.
*
* @return \WP_Term|null
*/
protected function match_product_cat_loosely( $name ) {
$terms = get_terms( [ 'taxonomy' => 'product_cat', 'hide_empty' => false ] );
if ( is_wp_error( $terms ) || empty( $terms ) ) {
return null;
}
$key = function ( $value ) {
return preg_replace( '/[^a-z0-9]/', '', strtolower( (string) $value ) );
};
$needle = $key( $name );
if ( '' === $needle ) {
return null;
}
$matches = [];
foreach ( $terms as $term ) {
$hay = $key( $term->name );
if ( '' === $hay ) {
continue;
}
if ( $hay === $needle || false !== strpos( $needle, $hay ) || false !== strpos( $hay, $needle ) ) {
$matches[] = $term;
}
}
// Ambiguous (e.g. "Clothing" also matching "Clothing Accessories") — assign nothing
// rather than the wrong category.
return 1 === count( $matches ) ? $matches[0] : null;
}
/**
* Convert the proxy's content HTML into clean Gutenberg block markup.
* Each top-level element becomes its matching core block; unknown nodes
* fall back to a paragraph. Empty input yields an empty paragraph block.
*
* @return string
*/
protected function html_to_blocks( $html ) {
// Sanitize our OWN input rather than trusting the caller. node_to_block() emits
// $dom->saveHTML() verbatim for every known tag (p, h1-h6, ul, ol, blockquote,
// pre), so any attribute on those elements — including an onclick — is copied
// straight into post_content. Today the REST layer kses's the AI response before
// it ever gets here, so nothing leaks; this makes that a property of the method
// instead of a property of its one current caller. wp_kses_post() is idempotent,
// so the existing double-sanitization costs only a pass over the string, and it
// preserves the `#bd-link--slug` cross-link fragments the builder resolves.
$html = wp_kses_post( trim( (string) $html ) );
if ( '' === trim( $html ) ) {
return "";
}
if ( ! class_exists( '\DOMDocument' ) ) {
return wp_kses_post( $html );
}
$dom = new \DOMDocument();
libxml_use_internal_errors( true );
$dom->loadHTML( '' . $html . '
', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD );
libxml_clear_errors();
$root = $dom->getElementById( 'bd-root' );
if ( ! $root ) {
return wp_kses_post( $html );
}
$blocks = '';
foreach ( $root->childNodes as $node ) {
$blocks .= $this->node_to_block( $node, $dom );
}
return '' !== trim( $blocks ) ? $blocks : wp_kses_post( $html );
}
/**
* Map a single DOM node to a core block string.
*
* @return string
*/
protected function node_to_block( \DOMNode $node, \DOMDocument $dom ) {
if ( XML_TEXT_NODE === $node->nodeType ) {
$text = trim( $node->textContent );
return '' === $text ? '' : "" . esc_html( $text ) . "
";
}
if ( XML_ELEMENT_NODE !== $node->nodeType ) {
return '';
}
$tag = strtolower( $node->nodeName );
$html = $dom->saveHTML( $node );
switch ( $tag ) {
case 'h1':
case 'h2':
case 'h3':
case 'h4':
case 'h5':
case 'h6':
$level = (int) substr( $tag, 1 );
return "{$html}";
case 'ul':
return "{$html}";
case 'ol':
return "{$html}";
case 'blockquote':
return "{$html}";
case 'pre':
return "{$html}";
case 'p':
return "{$html}";
default:
return "" . wp_kses_post( $node->textContent ) . "
";
}
}
/**
* @return array|null
*/
protected function map( $content_type ) {
return isset( self::MAP[ $content_type ] ) ? self::MAP[ $content_type ] : null;
}
}