, href="javascript:" en * src op ` en laat het commentaar open. * Een element zonder sluittag blijft zoals het was. * * @param string $html HTML. * @param array $bewaard Uit: plaatshouder => originele inhoud. * @return string HTML met plaatshouders. */ private static function ruw_uit( $html, array &$bewaard ) { // (?=[\s/>]): `` is geen . Zonder die eis zocht de scan een // `</title` die nooit kwam en stopte hij met beschermen voor de rest van de pagina. $open = '#<!--|<(' . implode( '|', self::DOM_RUW ) . ')(?=[\s/>])(?:[^>"\']++|"[^"]*+"|\'[^\']*+\')*+>#i'; $sleutel = 'aigeschikt_ruw_' . bin2hex( random_bytes( 8 ) ) . '_'; $uit = ''; $pos = 0; while ( preg_match( $open, $html, $m, PREG_OFFSET_CAPTURE, $pos ) ) { $eind_open = $m[0][1] + strlen( $m[0][0] ); if ( '<!--' === $m[0][0] ) { $dicht = strpos( $html, '-->', $eind_open ); $tot = false === $dicht ? strlen( $html ) : $dicht + 3; $uit .= substr( $html, $pos, $tot - $pos ); $pos = $tot; continue; } if ( ! preg_match( '#</' . $m[1][0] . '[\s/>]#i', $html, $s, PREG_OFFSET_CAPTURE, $eind_open ) ) { // Geen sluittag: dit element laten we staan, maar de rest van de pagina // blijft beschermd. $uit .= substr( $html, $pos, $eind_open - $pos ); $pos = $eind_open; continue; } $inhoud = substr( $html, $eind_open, $s[0][1] - $eind_open ); $uit .= substr( $html, $pos, $eind_open - $pos ); $pos = $s[0][1]; // Alleen wat libxml BESCHADIGT gaat opzij; een plaatshouder op iets anders // verandert de tekst van de ouder (een <svg><title>, een leeg <script src>), // en dan landt een edit die op die tekst mikt niet meer. Gemeten per // element (08-10-2026): in script/style verdwijnt `</` en wordt elk // niet-ASCII-teken een entity (`ë` → `ë`), die de browser daar NIET // decodeert — JSON-LD en JS-strings gaan dan stuk. In textarea/title breekt // alleen een `<` iets; de entities die libxml daar schrijft, decodeert de // browser gewoon terug. $scriptachtig = in_array( strtolower( $m[1][0] ), array( 'script', 'style' ), true ); $beschadigd = $scriptachtig ? (bool) preg_match( '#</|[\x80-\xFF]#', $inhoud ) : false !== strpos( $inhoud, '<' ); if ( ! $beschadigd ) { $uit .= $inhoud; continue; } $naam = $sleutel . count( $bewaard ); $bewaard[ $naam ] = $inhoud; $uit .= $naam; } return $uit . substr( $html, $pos ); } /** * De nodes waarover nth telt: eerst mag_doel (een <title> of <script> met * dezelfde tekst schuift de telling niet op), en bij een `find` alleen de * BINNENSTE treffer — `<p><strong>X</strong></p>` is één treffer, niet twee. * Een voorouder met dezelfde genormaliseerde tekst ontleent zijn treffer * volledig aan die afstammeling. Bij xpath/selector zijn geneste treffers * echte, aparte doelen en blijven ze staan (audit 07-10-2026, B2). * * @param DOMNodeList $nodes Resultaat van query(). * @param bool $find Kwam de lijst uit een `find`? * @return DOMNode[] */ private static function kandidaten( DOMNodeList $nodes, $find ) { $lijst = array(); foreach ( $nodes as $n ) { if ( self::mag_doel( $n ) ) { $lijst[ spl_object_id( $n ) ] = $n; } } if ( $find ) { $voorouders = array(); foreach ( $lijst as $n ) { for ( $p = $n->parentNode; $p instanceof DOMElement; $p = $p->parentNode ) { $voorouders[ spl_object_id( $p ) ] = true; } } $lijst = array_diff_key( $lijst, $voorouders ); } return array_values( $lijst ); } /** * Bepaal de doel-nodes voor een edit. * * @param DOMXPath $xpath XPath. * @param array $edit Edit. * @return DOMNodeList|null */ private static function query( DOMXPath $xpath, array $edit ) { if ( ! empty( $edit['xpath'] ) ) { return @$xpath->query( $edit['xpath'] ); } if ( ! empty( $edit['selector'] ) ) { $x = self::css_to_xpath( $edit['selector'] ); return $x ? @$xpath->query( $x ) : null; } if ( ! empty( $edit['find'] ) ) { // normalize-space(.) vergelijkt met genormaliseerde tekst, dus de literal hoort // dat ook te zijn — anders matcht een alinea met een regelafbreking nooit. $lit = self::xpath_literal( self::norm_text( $edit['find'] ) ); // Tweede predicaat: sla elk element over dat een BLOK-afstammeling met dezelfde // tekst heeft. Zonder dat matcht ook elke wrapper eromheen — een page-builder // nest een <p> vaak drie divs diep — en die staat in documentvolgorde vóór de // <p> zelf. nth=0 wees dan de wrapper aan terwijl de blueprint alléén bladeren // telt (`has_block_child` in inc/blueprint-extract.php), en een html-edit ving // daar de complete widget-inhoud mee in plaats van de alinea. $blok = array(); foreach ( self::$block_tags as $tag ) { $blok[] = 'self::' . $tag; } $q = '//*[not(self::script) and not(self::style)][normalize-space(.)=' . $lit . ']' . '[not(.//*[' . implode( ' or ', $blok ) . '][normalize-space(.)=' . $lit . '])]'; return @$xpath->query( $q ); } return null; } /** * Pas één edit toe op één node. */ private static function apply_one( DOMDocument $dom, DOMNode $node, array $edit, $type ) { $value = isset( $edit['value'] ) ? (string) $edit['value'] : ''; if ( 'attr' === $type ) { $attr = isset( $edit['attr'] ) ? $edit['attr'] : ''; if ( in_array( $attr, self::ATTR_TOEGESTAAN, true ) && $node instanceof DOMElement ) { $node->setAttribute( $attr, $value ); } return; } if ( 'html' === $type ) { $tmp = new DOMDocument( '1.0', 'UTF-8' ); libxml_use_internal_errors( true ); $tmp->loadHTML( '<?xml encoding="UTF-8"><body>' . wp_kses_post( $value ) . '</body>', LIBXML_NOERROR | LIBXML_NOWARNING ); libxml_clear_errors(); $body = $tmp->getElementsByTagName( 'body' )->item( 0 ); while ( $node->firstChild ) { $node->removeChild( $node->firstChild ); } if ( $body ) { foreach ( iterator_to_array( $body->childNodes ) as $child ) { $node->appendChild( $dom->importNode( $child, true ) ); } } return; } // type=text: vervang inhoud door één tekstnode (element + classes blijven intact). while ( $node->firstChild ) { $node->removeChild( $node->firstChild ); } $node->appendChild( $dom->createTextNode( $value ) ); } /* ------------------------------------------------------------------ */ /* Mini CSS → XPath (alleen simpele compound selectors, geen combinators) */ /* ------------------------------------------------------------------ */ private static function css_to_xpath( $sel ) { $sel = trim( $sel ); if ( '' === $sel || preg_match( '/[\s>,~+]/', $sel ) ) { return null; // combinators/lijsten → gebruik xpath i.p.v. selector. } $tag = '*'; if ( preg_match( '/^([a-zA-Z][\w-]*)/', $sel, $m ) ) { $tag = $m[1]; $sel = substr( $sel, strlen( $m[1] ) ); } $predicates = array(); if ( preg_match_all( '/([.#])([\w-]+)/', $sel, $mm, PREG_SET_ORDER ) ) { foreach ( $mm as $token ) { if ( '#' === $token[1] ) { $predicates[] = "@id='" . $token[2] . "'"; } else { $predicates[] = "contains(concat(' ',normalize-space(@class),' '),' " . $token[2] . " ')"; } } } $pred = $predicates ? '[' . implode( ' and ', $predicates ) . ']' : ''; return '//' . $tag . $pred; } /** * Veilig XPath-string-literal maken (ook met aanhalingstekens). */ private static function xpath_literal( $value ) { if ( false === strpos( $value, "'" ) ) { return "'" . $value . "'"; } if ( false === strpos( $value, '"' ) ) { return '"' . $value . '"'; } return "concat('" . str_replace( "'", "',\"'\",'", $value ) . "')"; } /* ------------------------------------------------------------------ */ /* REST: /edits/{id} */ /* ------------------------------------------------------------------ */ public static function register_routes() { register_rest_route( AIGESCHIKT_REST_NS, '/edits/(?P<id>\d+)', array( // Per OBJECT (0.8.8): wie de post mag bewerken, mag zijn overlay zien en zetten. array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( __CLASS__, 'rest_get' ), 'permission_callback' => 'aigeschikt_can_edit_post', ), array( 'methods' => WP_REST_Server::EDITABLE, 'callback' => array( __CLASS__, 'rest_put' ), 'permission_callback' => 'aigeschikt_can_edit_post_editor', ), array( 'methods' => WP_REST_Server::DELETABLE, 'callback' => array( __CLASS__, 'rest_delete' ), 'permission_callback' => 'aigeschikt_can_edit_post_editor', ), ) ); register_rest_route( AIGESCHIKT_REST_NS, '/url-edits', array( // Site-breed (ook de homepage): Editor-niveau (0.8.8). array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( __CLASS__, 'rest_url_get' ), 'permission_callback' => 'aigeschikt_can_site_write', 'args' => array( 'path' => array( 'required' => true ) ), ), array( 'methods' => WP_REST_Server::EDITABLE, 'callback' => array( __CLASS__, 'rest_url_put' ), 'permission_callback' => 'aigeschikt_can_site_write', ), array( 'methods' => WP_REST_Server::DELETABLE, 'callback' => array( __CLASS__, 'rest_url_delete' ), 'permission_callback' => 'aigeschikt_can_site_write', 'args' => array( 'path' => array( 'required' => true ) ), ), ) ); } public static function rest_get( WP_REST_Request $request ) { $id = (int) $request['id']; if ( ! get_post( $id ) ) { return new WP_Error( 'aigeschikt_not_found', 'Onbekende post.', array( 'status' => 404 ) ); } return rest_ensure_response( array( 'post_id' => $id, 'url' => get_permalink( $id ), 'edits' => self::get_edits( $id ), ) ); } public static function rest_put( WP_REST_Request $request ) { $id = (int) $request['id']; if ( ! get_post( $id ) ) { return new WP_Error( 'aigeschikt_not_found', 'Onbekende post.', array( 'status' => 404 ) ); } $body = $request->get_json_params(); $edits = isset( $body['edits'] ) ? $body['edits'] : array(); $rapport = self::sanitize_edits_met_rapport( $edits ); $saved = self::set_edits( $id, $rapport['edits'] ); AIGeschikt_Cache::purge_post( $id ); return rest_ensure_response( array( 'post_id' => $id, 'edits' => $saved, 'count' => count( $saved ), // Een geweigerde edit is nooit stil (audit 17-09-2026). 'geweigerd' => $rapport['geweigerd'], 'redenen' => $rapport['redenen'], ) ); } public static function rest_delete( WP_REST_Request $request ) { $id = (int) $request['id']; delete_post_meta( $id, self::META_KEY ); AIGeschikt_Cache::purge_post( $id ); return rest_ensure_response( array( 'post_id' => $id, 'edits' => array(), 'count' => 0 ) ); } /* ------------------------------------------------------------------ */ /* REST: /url-edits (pad in param/body) */ /* ------------------------------------------------------------------ */ public static function rest_url_get( WP_REST_Request $request ) { $path = self::normalize_param_path( $request->get_param( 'path' ) ); return rest_ensure_response( array( 'path' => $path, 'edits' => self::get_url_edits( $path ) ) ); } public static function rest_url_put( WP_REST_Request $request ) { $body = $request->get_json_params(); $path = self::normalize_param_path( isset( $body['path'] ) ? $body['path'] : '' ); if ( '' === $path ) { return new WP_Error( 'aigeschikt_missing', 'Veld "path" ontbreekt.', array( 'status' => 400 ) ); } $edits = isset( $body['edits'] ) ? $body['edits'] : array(); $rapport = self::sanitize_edits_met_rapport( $edits ); $saved = self::set_url_edits( $path, $rapport['edits'] ); AIGeschikt_Cache::purge_url( $path ); return rest_ensure_response( array( 'path' => $path, 'edits' => $saved, 'count' => count( $saved ), 'geweigerd' => $rapport['geweigerd'], 'redenen' => $rapport['redenen'], ) ); } public static function rest_url_delete( WP_REST_Request $request ) { $path = self::normalize_param_path( $request->get_param( 'path' ) ); self::set_url_edits( $path, array() ); AIGeschikt_Cache::purge_url( $path ); return rest_ensure_response( array( 'path' => $path, 'edits' => array(), 'count' => 0 ) ); } /** * Normaliseer een pad-param (accepteert volledige URL of pad). * * @param mixed $path Input. * @return string */ private static function normalize_param_path( $path ) { $path = is_string( $path ) ? trim( $path ) : ''; if ( '' === $path ) { return ''; } $parsed = wp_parse_url( $path, PHP_URL_PATH ); if ( $parsed ) { $path = $parsed; } if ( '' === $path || '/' === $path ) { return '/'; } if ( '/' !== $path[0] ) { $path = '/' . $path; } return untrailingslashit( $path ); } } AIGeschikt_Rewrite_Engine::init();