Skip to content

Commit

Permalink
Update WP_Interactivity_API
Browse files Browse the repository at this point in the history
  • Loading branch information
luisherranz committed Feb 5, 2024
1 parent 264d0fb commit 41ccbc1
Show file tree
Hide file tree
Showing 2 changed files with 306 additions and 40 deletions.
324 changes: 284 additions & 40 deletions src/wp-includes/interactivity-api/class-wp-interactivity-api.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,19 @@ final class WP_Interactivity_API {
* @var array
*/
private static $directive_processors = array(
'data-wp-interactive' => 'data_wp_interactive_processor',
'data-wp-context' => 'data_wp_context_processor',
'data-wp-bind' => 'data_wp_bind_processor',
'data-wp-class' => 'data_wp_class_processor',
'data-wp-style' => 'data_wp_style_processor',
'data-wp-text' => 'data_wp_text_processor',
'data-wp-interactive' => 'data_wp_interactive_processor',
'data-wp-router-region' => 'data_wp_router_region_processor',
'data-wp-context' => 'data_wp_context_processor',
'data-wp-bind' => 'data_wp_bind_processor',
'data-wp-class' => 'data_wp_class_processor',
'data-wp-style' => 'data_wp_style_processor',
'data-wp-text' => 'data_wp_text_processor',
/*
* `data-wp-each` needs to be processed in the last place because it moves
* the cursor to the end of the processed items to prevent them to be
* processed twice.
*/
'data-wp-each' => 'data_wp_each_processor',
);

/**
Expand All @@ -51,6 +58,21 @@ final class WP_Interactivity_API {
*/
private $config_data = array();

/**
* Flag that indicates whether the `data-wp-router-region` directive has
* been found in the HTML and processed.
*
* The value is saved in a private property of the WP_Interactivity_API
* instance instead of using a static variable inside the processor
* function, which would hold the same value for all instances
* independently of whether they have processed any
* `data-wp-router-region` directive or not.
*
* @since 6.5.0
* @var bool
*/
private $has_processed_router_region = false;

/**
* Gets and/or sets the initial state of an Interactivity API store for a
* given namespace.
Expand Down Expand Up @@ -189,16 +211,35 @@ public function add_hooks() {
* @return string The processed HTML content. It returns the original content when the HTML contains unbalanced tags.
*/
public function process_directives( string $html ): string {
$p = new WP_Interactivity_API_Directives_Processor( $html );
$tag_stack = array();
$namespace_stack = array();
$context_stack = array();
$unbalanced = false;
$namespace_stack = array();
$result = $this->process_directives_args( $html, $context_stack, $namespace_stack );
return null === $result ? $html : $result;
}

/**
* Processes the interactivity directives contained within the HTML content
* and updates the markup accordingly.
*
* It needs the context and namespace stacks to be passed by reference and
* it returns null if the HTML contains unbalanced tags.
*
* @since 6.5.0
*
* @param string $html The HTML content to process.
* @param array $context_stack The reference to the array used to keep track of contexts during processing.
* @param array $namespace_stack The reference to the array used to manage namespaces during processing.
* @return string|null The processed HTML content. It returns null when the HTML contains unbalanced tags.
*/
private function process_directives_args( string $html, array &$context_stack, array &$namespace_stack ) {
$p = new WP_Interactivity_API_Directives_Processor( $html );
$tag_stack = array();
$unbalanced = false;

$directive_processor_prefixes = array_keys( self::$directive_processors );
$directive_processor_prefixes_reversed = array_reverse( $directive_processor_prefixes );

while ( $p->next_tag( array( 'tag_closers' => 'visit' ) ) && false === $unbalanced ) {
while ( $p->next_tag( array( 'tag_closers' => 'visit' ) ) ) {
$tag_name = $p->get_tag();

if ( 'SVG' === $tag_name || 'MATH' === $tag_name ) {
Expand All @@ -221,37 +262,41 @@ public function process_directives( string $html ): string {
} else {
// Remove the last tag from the stack.
array_pop( $tag_stack );

}
} else {
if ( 0 !== count( $p->get_attribute_names_with_prefix( 'data-wp-each-child' ) ) ) {
/*
* If the matching opening tag didn't have any directives, it can skip
* the processing.
* If the tag has a `data-wp-each-child` directive, jump to its closer
* tag because those tags have already been processed.
*/
if ( 0 === count( $directives_prefixes ) ) {
continue;
$p->next_balanced_tag_closer_tag();
continue;
} else {
$directives_prefixes = array();

// Checks if there is is a server directive processor registered for each directive.
foreach ( $p->get_attribute_names_with_prefix( 'data-wp-' ) as $attribute_name ) {
list( $directive_prefix ) = $this->extract_prefix_and_suffix( $attribute_name );
if ( array_key_exists( $directive_prefix, self::$directive_processors ) ) {
$directives_prefixes[] = $directive_prefix;
}
}
}
} else {
$directives_prefixes = array();

foreach ( $p->get_attribute_names_with_prefix( 'data-wp-' ) as $attribute_name ) {

/*
* Extracts the directive prefix to see if there is a server directive
* processor registered for that directive.
* If this tag will visit its closer tag, it adds it to the tag stack
* so it can process its closing tag and check for unbalanced tags.
*/
list( $directive_prefix ) = $this->extract_prefix_and_suffix( $attribute_name );
if ( array_key_exists( $directive_prefix, self::$directive_processors ) ) {
$directives_prefixes[] = $directive_prefix;
if ( $p->has_and_visits_its_closer_tag() ) {
$tag_stack[] = array( $tag_name, $directives_prefixes );
}
}

/*
* If this tag will visit its closer tag, it adds it to the tag stack
* so it can process its closing tag and check for unbalanced tags.
*/
if ( $p->has_and_visits_its_closer_tag() ) {
$tag_stack[] = array( $tag_name, $directives_prefixes );
}
}
/*
* If the matching opener tag didn't have any directives, it can skip the
* processing.
*/
if ( 0 === count( $directives_prefixes ) ) {
continue;
}

/*
Expand All @@ -273,17 +318,17 @@ public function process_directives( string $html ): string {
: array( $this, self::$directive_processors[ $directive_prefix ] );
call_user_func_array(
$func,
array( $p, &$context_stack, &$namespace_stack )
array( $p, &$context_stack, &$namespace_stack, &$tag_stack )
);
}
}

/*
* It returns the original content if the HTML is unbalanced because
* unbalanced HTML is not safe to process. In that case, the Interactivity
* API runtime will update the HTML on the client side during the hydration.
* It returns null if the HTML is unbalanced because unbalanced HTML is
* not safe to process. In that case, the Interactivity API runtime will
* update the HTML on the client side during the hydration.
*/
return $unbalanced || 0 < count( $tag_stack ) ? $html : $p->get_updated_html();
return $unbalanced || 0 < count( $tag_stack ) ? null : $p->get_updated_html();
}

/**
Expand Down Expand Up @@ -400,6 +445,23 @@ private function extract_directive_value( $directive_value, $default_namespace =
return array( $default_namespace, $directive_value );
}

/**
* Transforms a kebab-case string to camelCase.
*
* @param string $str The kebab-case string to transform to camelCase.
* @return string The transformed camelCase string.
*/
private function kebab_to_camel_case( string $str ): string {
return lcfirst(
preg_replace_callback(
'/(-)([a-z])/',
function ( $matches ) {
return strtoupper( $matches[2] );
},
strtolower( preg_replace( '/-+$/', '', $str ) )
)
);
}

/**
* Processes the `data-wp-interactive` directive.
Expand Down Expand Up @@ -527,7 +589,6 @@ private function data_wp_bind_processor( WP_Interactivity_API_Directives_Process
}
}


/**
* Processes the `data-wp-class` directive.
*
Expand Down Expand Up @@ -683,4 +744,187 @@ private function data_wp_text_processor( WP_Interactivity_API_Directives_Process
}
}
}

/**
* Processes the `data-wp-router-region` directive.
*
* It renders in the footer a set of HTML elements to notify users about
* client-side navigations. More concretely, the elements added are 1) a
* top loading bar to visually inform that a navigation is in progress
* and 2) an `aria-live` region for accessible navigation announcements.
*
* @since 6.5.0
*
* @param WP_Interactivity_API_Directives_Processor $p The directives processor instance.
*/
private function data_wp_router_region_processor( WP_Interactivity_API_Directives_Processor $p ) {
if ( ! $p->is_tag_closer() && ! $this->has_processed_router_region ) {
$this->has_processed_router_region = true;

// Initialize the `core/router` store.
$this->state(
'core/router',
array(
'navigation' => array(
'message' => '',
'hasStarted' => false,
'hasFinished' => false,
'texts' => array(
'loading' => __( 'Loading page, please wait.' ),
'loaded' => __( 'Page Loaded.' ),
),
),
)
);

$callback = static function () {
echo <<<HTML
<style id="wp-interactivity-router_animations">
.wp-interactivity-router_loading-bar {
position: fixed;
top: 0;
left: 0;
margin: 0;
padding: 0;
width: 100vw;
max-width: 100vw !important;
height: 4px;
background-color: var(--wp--preset--color--primary, #000);
opacity: 0
}
.wp-interactivity-router_loading-bar.start-animation {
animation: wp-interactivity-router_loading-bar-start-animation 30s cubic-bezier(0.03, 0.5, 0, 1) forwards
}
.wp-interactivity-router_loading-bar.finish-animation {
animation: wp-interactivity-router_loading-bar-finish-animation 300ms ease-in
}
@keyframes wp-interactivity-router_loading-bar-start-animation {
0% { transform: scaleX(0); transform-origin: 0% 0%; opacity: 1 }
100% { transform: scaleX(1); transform-origin: 0% 0%; opacity: 1 }
}
@keyframes wp-interactivity-router_loading-bar-finish-animation {
0% { opacity: 1 }
50% { opacity: 1 }
100% { opacity: 0 }
}
</style>
<div
class="wp-interactivity-router_loading-bar"
data-wp-interactive='{"namespace":"core/router"}'
data-wp-class--start-animation="state.navigation.hasStarted"
data-wp-class--finish-animation="state.navigation.hasFinished"
></div>
<div
class="screen-reader-text"
aria-live="polite"
data-wp-interactive='{"namespace":"core/router"}'
data-wp-text="state.navigation.message"
></div>
HTML;
};

add_action( 'wp_footer', $callback );
}
}

/**
* Processes the `data-wp-each` directive.
*
* This directive gets an array passed as reference and iterates over it
* generating new content for each item based on the inner markup of the
* `template` tag.
*
* @since 6.5.0
*
* @param WP_Interactivity_API_Directives_Processor $p The directives processor instance.
* @param array $context_stack The reference to the context stack.
* @param array $namespace_stack The reference to the store namespace stack.
* @param array $tag_stack The reference to the tag stack.
*/
private function data_wp_each_processor( WP_Interactivity_API_Directives_Processor $p, array &$context_stack, array &$namespace_stack, array &$tag_stack ) {
if ( ! $p->is_tag_closer() && 'TEMPLATE' === $p->get_tag() ) {
$attribute_name = $p->get_attribute_names_with_prefix( 'data-wp-each' )[0];
$extracted_suffix = $this->extract_prefix_and_suffix( $attribute_name );
$item_name = isset( $extracted_suffix[1] ) ? $this->kebab_to_camel_case( $extracted_suffix[1] ) : 'item';
$attribute_value = $p->get_attribute( $attribute_name );
$result = $this->evaluate( $attribute_value, end( $namespace_stack ), end( $context_stack ) );

// Gets the content between the template tags and leaves the cursor in the closer tag.
$inner_content = $p->get_content_between_balanced_template_tags();

// Checks if there is a manual server-side directive processing.
$template_end = 'data-wp-each: template end';
$p->set_bookmark( $template_end );
$p->next_tag();
$manual_sdp = $p->get_attribute( 'data-wp-each-child' );
$p->seek( $template_end ); // Rewinds to the template closer tag.
$p->release_bookmark( $template_end );

/*
* It doesn't process in these situations:
* - Manual server-side directive processing.
* - Empty or non-array values.
* - Associative arrays because those are deserialized as objects in JS.
* - Templates that contain top-level texts because those texts can't be
* identified and removed in the client.
*/
if (
$manual_sdp ||
empty( $result ) ||
! is_array( $result ) ||
! array_is_list( $result ) ||
! str_starts_with( trim( $inner_content ), '<' ) ||
! str_ends_with( trim( $inner_content ), '>' )
) {
array_pop( $tag_stack );
return;
}

// Extracts the namespace from the directive attribute value.
$namespace_value = end( $namespace_stack );
list( $namespace_value ) = is_string( $attribute_value ) && ! empty( $attribute_value )
? $this->extract_directive_value( $attribute_value, $namespace_value )
: array( $namespace_value, null );

// Processes the inner content for each item of the array.
$processed_content = '';
foreach ( $result as $item ) {
// Creates a new context that includes the current item of the array.
array_push(
$context_stack,
array_replace_recursive(
end( $context_stack ) !== false ? end( $context_stack ) : array(),
array( $namespace_value => array( $item_name => $item ) )
)
);

// Processes the inner content with the new context.
$processed_item = $this->process_directives_args( $inner_content, $context_stack, $namespace_stack );

if ( null === $processed_item ) {
// If the HTML is unbalanced, stop processing it.
array_pop( $context_stack );
return;
}

// Adds the `data-wp-each-child` to each top-level tag.
$i = new WP_Interactivity_API_Directives_Processor( $processed_item );
while ( $i->next_tag() ) {
$i->set_attribute( 'data-wp-each-child', true );
$i->next_balanced_tag_closer_tag();
}
$processed_content .= $i->get_updated_html();

// Removes the current context from the stack.
array_pop( $context_stack );
}

// Appends the processed content after the tag closer of the template.
$p->append_content_after_template_tag_closer( $processed_content );

// Pops the last tag because it skipped the closing tag of the template tag.
array_pop( $tag_stack );
}
}
}
Loading

0 comments on commit 41ccbc1

Please sign in to comment.