post_id van de vertaalgroep (incl. de post zelf). Leeg als de site * geen Polylang heeft. * * @param int $post_id Post. * @return array */ function aigeschikt_post_translations( int $post_id ): array { if ( function_exists( 'pll_get_post_translations' ) ) { $t = pll_get_post_translations( $post_id ); return is_array( $t ) ? array_map( 'intval', $t ) : array(); } return array(); } /* --------------------------------------------------------------------- */ /* REST: POST /aigeschikt/v1/blog-multilingual */ /* --------------------------------------------------------------------- */ add_action( 'rest_api_init', 'aigeschikt_register_ml_routes' ); function aigeschikt_register_ml_routes() { register_rest_route( AIGESCHIKT_REST_NS, '/blog-multilingual', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => 'aigeschikt_rest_blog_multilingual', 'permission_callback' => 'aigeschikt_can_create_posts', ) ); } /** * POST /aigeschikt/v1/blog-multilingual * Body: { "status":"publish", "posts": { "nl": {title,content,slug?}, "en": {...}, "de": {...} }, * "link_to": { "nl": 123 } } * `content` is HTML/blocks (het platform converteert markdown vooraf). * `link_to` (optioneel): koppel de nieuwe post(en) als vertaling aan een BESTAANDE * post — de "Push DE alsnog"-flow nadat NL eerder alleen live ging. * * Maakt per taal een post, zet de taal en koppelt ze als vertalingen. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ function aigeschikt_rest_blog_multilingual( WP_REST_Request $request ) { if ( ! function_exists( 'pll_set_post_language' ) ) { return new WP_Error( 'aigeschikt_no_polylang', 'Polylang is niet actief op deze site.', array( 'status' => 400 ) ); } $body = $request->get_json_params(); $posts = ( isset( $body['posts'] ) && is_array( $body['posts'] ) ) ? $body['posts'] : array(); $status = ( isset( $body['status'] ) && 'publish' === $body['status'] ) ? 'publish' : 'draft'; $link = ( isset( $body['link_to'] ) && is_array( $body['link_to'] ) ) ? $body['link_to'] : array(); if ( empty( $posts ) ) { return new WP_Error( 'aigeschikt_missing', 'Veld "posts" ontbreekt.', array( 'status' => 400 ) ); } // wp_insert_post zelf toetst geen capability; een koppelaccount dat geen posts // mag publiceren krijgt hier dezelfde grens als in WordPress zelf (0.8.8). if ( 'publish' === $status && ! current_user_can( 'publish_posts' ) ) { return new WP_Error( 'aigeschikt_geen_publiceerrecht', 'Het koppelaccount mag geen berichten publiceren.', array( 'status' => 403 ) ); } // `link_to` trekt een BESTAANDE post in onze vertaalgroep en wijzigt die dus: // edit_post op elk doel (audit 07-10-2026, B1). Vóór het aanmaken getoetst — een // weigering ná wp_insert_post laat het platform opnieuw proberen: dubbele posts. foreach ( $link as $existing_id ) { if ( (int) $existing_id > 0 && ! current_user_can( 'edit_post', (int) $existing_id ) ) { return new WP_Error( 'aigeschikt_forbidden', 'Onvoldoende rechten voor de post in link_to.', array( 'status' => 403 ) ); } } $ids = array(); foreach ( $posts as $lang => $p ) { $lang = sanitize_key( $lang ); $title = isset( $p['title'] ) ? wp_strip_all_tags( (string) $p['title'] ) : ''; $html = isset( $p['content'] ) ? (string) $p['content'] : ''; if ( '' === $title && '' === $html ) { continue; } // De archief-teaser. Zonder `post_excerpt` leidt WordPress er zelf een af uit // de eerste ~55 woorden, en dat is bij answer-first content exact de // `Kort antwoord`-alinea: het /blog/-archief draagt dan N complete antwoorden // plus de linkkracht van een navigatiepagina en verdringt zijn eigen post uit // de SERP (gemeten op alarmed.nl, augustus 2026). De server stuurt de teaser // sinds 28-08 mee; tot deze versie las deze functie het veld niet en viel elke // connector-/Polylang-blog stil terug op het oude gedrag. $excerpt = isset( $p['excerpt'] ) ? wp_strip_all_tags( (string) $p['excerpt'] ) : ''; $velden = array( 'post_type' => 'post', 'post_status' => $status, 'post_title' => $title, 'post_content' => $html, 'post_name' => isset( $p['slug'] ) ? sanitize_title( (string) $p['slug'] ) : '', ); // Alleen zetten als er iets te zetten is: een LEGE post_excerpt meesturen laat // WordPress gewoon terugvallen op de afgeleide teaser, en zou een fix // suggereren die er niet is. if ( '' !== trim( $excerpt ) ) { $velden['post_excerpt'] = $excerpt; } $post_id = wp_insert_post( wp_slash( $velden ), true ); // wp_insert_post() unslashes: without wp_slash() backslashes in title/content are lost. if ( is_wp_error( $post_id ) ) { continue; } if ( defined( 'AIGESCHIKT_BLOG_MARKER' ) ) { update_post_meta( $post_id, AIGESCHIKT_BLOG_MARKER, '1' ); } // De SEO-description, per taal. Zonder deze sleutel heeft Rank Math niets // en valt het terug op de post_excerpt hierboven — de H2-koppenlijst, die // daarmee de SERP-omschrijving werd van élke blog die wij publiceerden // (gemeten 15-09-2026 op alarmed.nl). Hier schrijven we server-side, dus // de REST-registratie uit inc/seo-meta.php is niet nodig; de SLEUTEL komt // er wel vandaan, zodat er één definitie van "waar hoort dit veld" is. // Leeg of geen SEO-plugin ⇒ niets schrijven: een lege description is // slechter dan geen, en een sleutel die niemand rendert is een waarde die // stil blijft liggen. $desc = isset( $p['meta_description'] ) ? wp_strip_all_tags( (string) $p['meta_description'] ) : ''; $desc_key = function_exists( 'aigeschikt_seo_description_key' ) ? aigeschikt_seo_description_key() : ''; if ( '' !== trim( $desc ) && '' !== $desc_key ) { update_post_meta( $post_id, $desc_key, $desc ); } pll_set_post_language( $post_id, $lang ); $ids[ $lang ] = (int) $post_id; } // link_to: bestaande posts (bv. de al-gepubliceerde NL) in de vertaalgroep opnemen. // De generieke edit_posts-check op de route autoriseert niet om een WILLEKEURIGE // bestaande post (van een andere auteur) in een vertaalgroep te trekken — dat // vraagt een object-check op DIE post (gemeld door de review 22-09-2026, zelfde // klasse als de eerdere Contributor-fix voor overlays/schema, 0.8.8). foreach ( $link as $lang => $existing_id ) { $lang = sanitize_key( $lang ); $existing_id = (int) $existing_id; if ( $existing_id > 0 && 'post' === get_post_type( $existing_id ) && ! isset( $ids[ $lang ] ) && current_user_can( 'edit_post', $existing_id ) ) { $ids[ $lang ] = $existing_id; } } if ( count( $ids ) > 1 && function_exists( 'pll_save_post_translations' ) ) { pll_save_post_translations( $ids ); } // Nieuwe posts raken ook bestaande gecachete pagina's (blogindex, archieven). // De core-hooks dekken de bekende cache-plugins al; dit zorgt dat óók // FlyingPress en een eigen server-cache (aigeschikt_purged_url) meekrijgen // dat er iets is veranderd. if ( class_exists( 'AIGeschikt_Cache' ) ) { foreach ( $ids as $new_id ) { AIGeschikt_Cache::purge_post( (int) $new_id ); } } // Een post-purge raakt de permalink en de voorpagina, niet /post-sitemap.xml. // Zonder deze regel bleef een nieuwe post buiten een gecachete sitemap staan. // // De try/catch is niet decoratief: op dit punt zijn de posts AL aangemaakt. Een // throw uit de sitemap-verversing zou deze request een 500 geven, waarna het // platform de publicatie als mislukt leest en 'm opnieuw kan doen — dubbele // posts, terwijl er niets mis was met de publicatie zelf. if ( class_exists( 'AIGeschikt_Sitemap' ) && $ids ) { try { AIGeschikt_Sitemap::refresh(); } catch ( \Throwable $e ) { // Bewust stil richting de klant, wel zichtbaar voor wie meekijkt. if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { error_log( 'AIGeschikt: sitemap-verversing faalde: ' . $e->getMessage() ); // phpcs:ignore } } } return rest_ensure_response( array( 'ok' => true, 'ids' => $ids ) ); }