. * .id, a menu at menu.advanced.menuId…, categories of a blog module are terms). * Other blocks: a known key at the top level of the attributes. */ class Block_Refs { /** * Keys under which complete module attributes are embedded (global layouts, presets). */ const ATTR_ROOTS = array( 'localAttrs', 'attrs', 'styleAttrs', 'renderAttrs' ); /** * Keys under "advanced" of blog and portfolio modules whose values are term IDs. */ const CATEGORY_KEYS = array( 'categories', 'includedCategories', 'includeCategories', 'includedProjectCategories', 'includeProjectCategories' ); /** * Fields of Divi display conditions and what their IDs point to. */ const CONDITION_FIELDS = array( 'pages' => 'post', 'products' => 'post', 'categories' => 'term', 'tags' => 'term', ); /** * Nesting limit of the scanner. */ const MAX_DEPTH = 64; /** * All scalar values of a JSON text with their path and position. * * @param string $json JSON text. * @return array|null * Null when the text is no valid JSON: then nothing is touched. */ public static function leaves( string $json ): ?array { $leaves = array(); $pos = 0; try { self::value( $json, $pos, array(), $leaves ); self::space( $json, $pos ); } catch ( \UnexpectedValueException $e ) { return null; } return strlen( $json ) === $pos ? $leaves : null; } /** * Reads one value. * * @param string $s Text. * @param int $pos Position, moved past the value. * @param array $path Path of the value. * @param array $leaves Collected scalar values. * @throws \UnexpectedValueException On invalid JSON. */ private static function value( string $s, int &$pos, array $path, array &$leaves ): void { if ( count( $path ) > self::MAX_DEPTH ) { throw new \UnexpectedValueException( 'depth' ); } self::space( $s, $pos ); $c = $s[ $pos ] ?? ''; if ( '{' === $c ) { ++$pos; self::space( $s, $pos ); if ( '}' === ( $s[ $pos ] ?? '' ) ) { ++$pos; return; } while ( true ) { self::space( $s, $pos ); if ( '"' !== ( $s[ $pos ] ?? '' ) ) { throw new \UnexpectedValueException( 'key' ); } $start = $pos + 1; self::string_end( $s, $pos ); $key = json_decode( '"' . substr( $s, $start, $pos - 1 - $start ) . '"' ); if ( ! is_string( $key ) ) { throw new \UnexpectedValueException( 'key' ); } self::space( $s, $pos ); if ( ':' !== ( $s[ $pos ] ?? '' ) ) { throw new \UnexpectedValueException( 'colon' ); } ++$pos; self::value( $s, $pos, array_merge( $path, array( $key ) ), $leaves ); self::space( $s, $pos ); $c = $s[ $pos ] ?? ''; ++$pos; if ( '}' === $c ) { return; } if ( ',' !== $c ) { throw new \UnexpectedValueException( 'object' ); } } } if ( '[' === $c ) { ++$pos; self::space( $s, $pos ); if ( ']' === ( $s[ $pos ] ?? '' ) ) { ++$pos; return; } for ( $i = 0; ; $i++ ) { self::value( $s, $pos, array_merge( $path, array( $i ) ), $leaves ); self::space( $s, $pos ); $c = $s[ $pos ] ?? ''; ++$pos; if ( ']' === $c ) { return; } if ( ',' !== $c ) { throw new \UnexpectedValueException( 'array' ); } } } if ( '"' === $c ) { $start = $pos; self::string_end( $s, $pos ); $leaves[] = array( 'path' => $path, 'start' => $start, 'end' => $pos, 'quoted' => true, 'value' => substr( $s, $start + 1, $pos - $start - 2 ), ); return; } if ( preg_match( '/\G(?:-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|true|false|null)/', $s, $m, 0, $pos ) ) { $leaves[] = array( 'path' => $path, 'start' => $pos, 'end' => $pos + strlen( $m[0] ), 'quoted' => false, 'value' => $m[0], ); $pos += strlen( $m[0] ); return; } throw new \UnexpectedValueException( 'value' ); } /** * Moves past a string that starts at $pos (on the opening quote). * * @param string $s Text. * @param int $pos Position. * @throws \UnexpectedValueException On an unterminated string. */ private static function string_end( string $s, int &$pos ): void { $len = strlen( $s ); for ( $i = $pos + 1; $i < $len; $i++ ) { if ( '\\' === $s[ $i ] ) { ++$i; } elseif ( '"' === $s[ $i ] ) { $pos = $i + 1; return; } } throw new \UnexpectedValueException( 'string' ); } /** * Skips whitespace. * * @param string $s Text. * @param int $pos Position. */ private static function space( string $s, int &$pos ): void { $pos += strspn( $s, " \t\r\n", $pos ); } /** * What a value at a path points to. * * @param string $block Block name ("divi/image", "core/image", "image"). * @param array $path Path of the value inside the block attributes. * @param callable $sibling Value of another path (raw, unquoted) or null. * @return array{0: string, 1: string}|null [ 'post'|'term', 'id'|'ids' ] or null. */ public static function rule( string $block, array $path, callable $sibling ): ?array { if ( 0 === strpos( $block, 'divi/' ) ) { return self::divi_rule( $path, $sibling ); } if ( ! $path ) { return null; } $key = $path[0]; if ( 1 === count( $path ) && in_array( $key, Exporter::block_ref_keys(), true ) ) { // Navigation links point to terms when their kind says so. $nav = in_array( $block, array( 'navigation-link', 'navigation-submenu', 'core/navigation-link', 'core/navigation-submenu' ), true ); return array( $nav && 'taxonomy' === $sibling( array( 'kind' ) ) ? 'term' : 'post', 'id' ); } if ( in_array( $key, Exporter::list_ref_keys(), true ) ) { return self::list_rule( 'post', array_slice( $path, 1 ) ); } return null; } /** * Divi 5 module attributes (also embedded in global layouts and presets). * * @param array $path Path. * @param callable $sibling Value of another path. * @return array{0: string, 1: string}|null */ public static function divi_rule( array $path, callable $sibling ): ?array { // Embedded module attributes: count from the last such key. $prefix = array(); foreach ( $path as $index => $segment ) { if ( in_array( $segment, self::ATTR_ROOTS, true ) ) { $prefix = array_slice( $path, 0, $index + 1 ); } } $path = array_slice( $path, count( $prefix ) ); $n = count( $path ); if ( 0 === $n ) { return null; } // Global modules point to a Divi Library item. if ( 1 === $n && ! $prefix && in_array( $path[0], array( 'globalModule', 'globalParent' ), true ) ) { return array( 'post', 'id' ); } // Image ID and gallery: image.innerContent...id, image.advanced.galleryIds... if ( 'image' === $path[0] && $n >= 5 ) { if ( 5 === $n && 'innerContent' === $path[1] && 'id' === $path[4] ) { return array( 'post', 'id' ); } if ( 'advanced' === $path[1] && 'galleryIds' === $path[2] ) { return self::list_rule( 'post', array_slice( $path, 5 ) ); } } // Menu module: menu.advanced.menuId.. is a menu (term). if ( 5 === $n && 'menu' === $path[0] && 'advanced' === $path[1] && 'menuId' === $path[2] ) { return array( 'term', 'id' ); } // Loop: …advanced.loop...…. $k = array_search( 'loop', $path, true ); if ( false !== $k && $k >= 1 && 'advanced' === $path[ $k - 1 ] && $n > $k + 3 ) { $field = $path[ $k + 3 ]; $rest = array_slice( $path, $k + 4 ); if ( in_array( $field, array( 'includeSpecificPosts', 'excludeSpecificPosts' ), true ) ) { return self::list_rule( 'post', $rest ); } if ( in_array( $field, array( 'includePostWithSpecificTerms', 'excludePostWithSpecificTerms' ), true ) ) { // Groups of { categoryId, selectedOptions: [ { value, label } ] }. if ( 4 === count( $rest ) && is_int( $rest[0] ) && 'selectedOptions' === $rest[1] && is_int( $rest[2] ) && 'value' === $rest[3] ) { return array( 'term', 'id' ); } return self::list_rule( 'term', $rest ); } // A loop over menu items keeps the menus in subTypes. if ( 'subTypes' === $field && 'menus' === $sibling( array_merge( $prefix, array_slice( $path, 0, $k + 3 ), array( 'queryType' ) ) ) ) { return self::list_rule( 'term', $rest ); } } // Categories of blog and portfolio modules: …advanced.... foreach ( $path as $j => $segment ) { if ( 'advanced' === $segment && isset( $path[ $j + 1 ] ) && in_array( $path[ $j + 1 ], self::CATEGORY_KEYS, true ) ) { return self::list_rule( 'term', array_slice( $path, $j + 4 ) ); } } // Display conditions: …decoration.conditions....conditionSettings.. $j = array_search( 'conditions', $path, true ); if ( false !== $j && $j >= 1 && 'decoration' === $path[ $j - 1 ] && $n >= $j + 7 && is_int( $path[ $j + 3 ] ) && 'conditionSettings' === $path[ $j + 4 ] ) { $field = $path[ $j + 5 ]; if ( is_string( $field ) && isset( self::CONDITION_FIELDS[ $field ] ) ) { return self::list_rule( self::CONDITION_FIELDS[ $field ], array_slice( $path, $j + 6 ) ); } } return null; } /** * A list in the forms "1,2", [1, 2] and [ { value: 1 } ]. * * @param string $kind 'post' or 'term'. * @param array $rest Path after the field. * @return array{0: string, 1: string}|null */ private static function list_rule( string $kind, array $rest ): ?array { $count = count( $rest ); if ( 0 === $count ) { return array( $kind, 'ids' ); } if ( 1 === $count && is_int( $rest[0] ) ) { return array( $kind, 'id' ); } if ( 2 === $count && is_int( $rest[0] ) && 'value' === $rest[1] ) { return array( $kind, 'id' ); } return null; } }