* exist. It loads automatically, cannot be deactivated from the admin (which is * deliberate: it cannot be switched off by accident), and to remove it you * delete the file. * * ── WHY THIS EXISTS ────────────────────────────────────────────────────────── * Polylang (free) keeps a post's language in a hidden `language` taxonomy that * is not registered with show_in_rest. So /wp/v2/posts exposes no `lang` and no * `translations`, and ?lang=xx does not filter the collection. A REST client can * therefore create a post that carries NO language — and Polylang's frontend * tax_query then excludes it from every listing while it stays readable at ?p=N. * Published into invisibility, silently. * * This bridge closes that hole by exposing Polylang's own public API: * * /wp/v2/posts "lang": "ar" read + write * "translations": { "ar": 12, "en": 13 } read + write * /wp/v2/categories same two fields read + write * /helix/v1/status public probe, so a client can confirm the bridge is live * * Everything is capability-checked and validated. If Polylang is absent the * fields are simply not registered — nothing fatals. */ if ( ! defined( 'ABSPATH' ) ) { exit; } /** Polylang's post API present? */ function helix_pll_posts_available() { return function_exists( 'pll_set_post_language' ) && function_exists( 'pll_get_post_language' ) && function_exists( 'pll_get_post_translations' ) && function_exists( 'pll_save_post_translations' ) && function_exists( 'pll_languages_list' ); } /** Polylang's term API present? */ function helix_pll_terms_available() { return function_exists( 'pll_set_term_language' ) && function_exists( 'pll_get_term_language' ) && function_exists( 'pll_get_term_translations' ) && function_exists( 'pll_save_term_translations' ) && function_exists( 'pll_languages_list' ); } /** The configured language slugs, or an empty array if Polylang is absent. */ function helix_pll_slugs() { if ( ! function_exists( 'pll_languages_list' ) ) { return array(); } $slugs = pll_languages_list( array( 'fields' => 'slug' ) ); return is_array( $slugs ) ? $slugs : array(); } /** * Validate a { language_slug => object_id } map. Returns a clean array of * slug => (int) id, or a WP_Error. * * $get_lang( $id ) reads an object's CURRENT language slug. * $can_edit( $id ) decides whether this user may link that object. */ function helix_pll_clean_group( $value, $get_lang, $can_edit ) { if ( ! is_array( $value ) || empty( $value ) ) { return new WP_Error( 'helix_bad_group', 'translations must be a non-empty object of language slug => id.', array( 'status' => 400 ) ); } $slugs = helix_pll_slugs(); $clean = array(); foreach ( $value as $slug => $id ) { $id = (int) $id; if ( ! in_array( $slug, $slugs, true ) ) { return new WP_Error( 'helix_bad_language', sprintf( 'Unknown language slug "%s".', $slug ), array( 'status' => 400 ) ); } if ( $id <= 0 ) { return new WP_Error( 'helix_bad_id', sprintf( 'Invalid id for language "%s".', $slug ), array( 'status' => 400 ) ); } if ( ! call_user_func( $can_edit, $id ) ) { return new WP_Error( 'helix_forbidden', sprintf( 'Not allowed to link object %d.', $id ), array( 'status' => 403 ) ); } // Each object must ALREADY carry the language it is being linked as. // pll_save_*_translations misbehaves quietly otherwise — which is exactly // the silent breakage this bridge exists to prevent, so it is an error // rather than something to paper over. if ( call_user_func( $get_lang, $id ) !== $slug ) { return new WP_Error( 'helix_language_mismatch', sprintf( 'Object %d is not in language "%s" — set its language before linking.', $id, $s array( 'status' => 400 ) ); } $clean[ $slug ] = $id; } return $clean; } // ── posts ──────────────────────────────────────────────────────────────────── function helix_pll_get_post_lang( $arr ) { $lang = pll_get_post_language( (int) $arr['id'], 'slug' ); return $lang ? $lang : null; } function helix_pll_update_post_lang( $value, $post ) { if ( null === $value || '' === $value ) { return; } if ( ! is_string( $value ) ) { return new WP_Error( 'helix_bad_language', 'lang must be a language slug string.', array( 'status' => 40 } if ( ! current_user_can( 'edit_post', $post->ID ) ) { return new WP_Error( 'helix_forbidden', 'Not allowed to set the language of this post.', array( 'status' } if ( ! in_array( $value, helix_pll_slugs(), true ) ) { return new WP_Error( 'helix_bad_language', sprintf( 'Unknown language slug "%s".', $value ), array( 'status' => 400 ) ); } pll_set_post_language( $post->ID, $value ); } function helix_pll_get_post_translations( $arr ) { $t = pll_get_post_translations( (int) $arr['id'] ); return is_array( $t ) ? array_map( 'intval', $t ) : array(); } function helix_pll_update_post_translations( $value, $post ) { if ( null === $value || '' === $value ) { return; } if ( ! current_user_can( 'edit_post', $post->ID ) ) { return new WP_Error( 'helix_forbidden', 'Not allowed to link translations for this post.', array( 'statu } $clean = helix_pll_clean_group( $value, function ( $id ) { return pll_get_post_language( $id, 'slug' ); }, function ( $id ) { return get_post( $id ) && current_user_can( 'edit_post', $id ); } ); if ( is_wp_error( $clean ) ) { return $clean; } pll_save_post_translations( $clean ); } // ── categories ─────────────────────────────────────────────────────────────── function helix_pll_get_term_lang( $arr ) { $lang = pll_get_term_language( (int) $arr['id'], 'slug' ); return $lang ? $lang : null; } function helix_pll_update_term_lang( $value, $term ) { if ( null === $value || '' === $value ) { return; } if ( ! is_string( $value ) ) { return new WP_Error( 'helix_bad_language', 'lang must be a language slug string.', array( 'status' => 40 } if ( ! current_user_can( 'manage_categories' ) ) { return new WP_Error( 'helix_forbidden', 'Not allowed to set the language of this category.', array( 'sta); } if ( ! in_array( $value, helix_pll_slugs(), true ) ) { return new WP_Error( 'helix_bad_language', sprintf( 'Unknown language slug "%s".', $value ), array( 'status' => 400 ) ); } pll_set_term_language( $term->term_id, $value ); } function helix_pll_get_term_translations( $arr ) { $t = pll_get_term_translations( (int) $arr['id'] ); return is_array( $t ) ? array_map( 'intval', $t ) : array(); } function helix_pll_update_term_translations( $value, $term ) { if ( null === $value || '' === $value ) { return; } if ( ! current_user_can( 'manage_categories' ) ) { return new WP_Error( 'helix_forbidden', 'Not allowed to link category translations.', array( 'status' => } $clean = helix_pll_clean_group( $value, function ( $id ) { return pll_get_term_language( $id, 'slug' ); }, function ( $id ) { $term = get_term( $id, 'category' ); return $term && ! is_wp_error( $term ); } ); if ( is_wp_error( $clean ) ) { return $clean; } pll_save_term_translations( $clean ); } // ── registration ───────────────────────────────────────────────────────────── add_action( 'rest_api_init', 'helix_pll_rest_init' ); function helix_pll_rest_init() { // Public probe. Lets a client confirm the bridge is installed BEFORE it tries // to rely on it, so "bridge missing" is a clear message rather than a post // that silently lands with no language. Exposes nothing that the existing // /pll/v1/languages endpoint does not already expose publicly. register_rest_route( 'helix/v1', '/status', array( 'methods' => 'GET', 'permission_callback' => '__return_true', 'callback' => 'helix_pll_status', ) ); $schema_lang = array( 'description' => 'Polylang language slug.', 'type' => array( 'string', 'null' ), 'context' => array( 'view', 'edit' ), ); $schema_group = array( 'description' => 'Translation group as { language slug: object id }.', 'type' => 'object', 'context' => array( 'view', 'edit' ), ); // ORDER MATTERS. "lang" is registered before "translations" so that, within a // single request, the language is assigned before the link is attempted — // WP_REST_Controller::update_additional_fields_for_object() runs these in // registration order, and helix_pll_clean_group() validates the language of // every member before linking. if ( helix_pll_posts_available() ) { register_rest_field( 'post', 'lang', array( 'get_callback' => 'helix_pll_get_post_lang', 'update_callback' => 'helix_pll_update_post_lang', 'schema' => $schema_lang, ) ); register_rest_field( 'post', 'translations', array( 'get_callback' => 'helix_pll_get_post_translations', 'update_callback' => 'helix_pll_update_post_translations', 'schema' => $schema_group, ) ); } if ( helix_pll_terms_available() ) { register_rest_field( 'category', 'lang', array( 'get_callback' => 'helix_pll_get_term_lang', 'update_callback' => 'helix_pll_update_term_lang', 'schema' => $schema_lang, ) ); register_rest_field( 'category', 'translations', array( 'get_callback' => 'helix_pll_get_term_translations', 'update_callback' => 'helix_pll_update_term_translations', 'schema' => $schema_group, ) ); } } function helix_pll_status() { return rest_ensure_response( array( 'ok' => true, 'plugin' => 'helix-polylang-rest', 'version' => '1.0.0', 'posts' => helix_pll_posts_available(), 'terms' => helix_pll_terms_available(), 'languages' => helix_pll_slugs(), 'default' => function_exists( 'pll_default_language' ) ? pll_default_language( 'slug' ) : null ) ); }