' );
+ * false === $processor->next_tag();
+ * WP_HTML_Processor::ERROR_UNSUPPORTED === $processor->get_last_error();
+ *
+ * @since 6.4.0
+ *
+ * @see self::ERROR_UNSUPPORTED
+ * @see self::ERROR_EXCEEDED_MAX_BOOKMARKS
+ *
+ * @return string|null The last error, if one exists, otherwise null.
+ */
+ public function get_last_error() {
+ return $this->last_error;
+ }
+
+ /**
+ * Finds the next tag matching the $query.
+ *
+ * @todo Support matching the class name and tag name.
+ *
+ * @since 6.4.0
+ *
+ * @throws Exception When unable to allocate a bookmark for the next token in the input HTML document.
+ *
+ * @param array|string|null $query {
+ * Optional. Which tag name to find, having which class, etc. Default is to find any tag.
+ *
+ * @type string|null $tag_name Which tag to find, or `null` for "any tag."
+ * @type int|null $match_offset Find the Nth tag matching all search criteria.
+ * 1 for "first" tag, 3 for "third," etc.
+ * Defaults to first tag.
+ * @type string|null $class_name Tag must contain this whole class name to match.
+ * @type string[] $breadcrumbs DOM sub-path at which element is found, e.g. `array( 'FIGURE', 'IMG' )`.
+ * May also contain the wildcard `*` which matches a single element, e.g. `array( 'SECTION', '*' )`.
+ * }
+ * @return bool Whether a tag was matched.
+ */
+ public function next_tag( $query = null ) {
+ if ( null === $query ) {
+ while ( $this->step() ) {
+ if ( ! $this->is_tag_closer() ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ if ( is_string( $query ) ) {
+ $query = array( 'breadcrumbs' => array( $query ) );
+ }
+
+ if ( ! is_array( $query ) ) {
+ _doing_it_wrong(
+ __METHOD__,
+ __( 'Please pass a query array to this function.' ),
+ '6.4.0'
+ );
+ return false;
+ }
+
+ if ( ! ( array_key_exists( 'breadcrumbs', $query ) && is_array( $query['breadcrumbs'] ) ) ) {
+ while ( $this->step() ) {
+ if ( ! $this->is_tag_closer() ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ if ( isset( $query['tag_closers'] ) && 'visit' === $query['tag_closers'] ) {
+ _doing_it_wrong(
+ __METHOD__,
+ __( 'Cannot visit tag closers in HTML Processor.' ),
+ '6.4.0'
+ );
+ return false;
+ }
+
+ $breadcrumbs = $query['breadcrumbs'];
+ $match_offset = isset( $query['match_offset'] ) ? (int) $query['match_offset'] : 1;
+
+ while ( $match_offset > 0 && $this->step() ) {
+ if ( $this->matches_breadcrumbs( $breadcrumbs ) && 0 === --$match_offset ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Indicates if the currently-matched tag matches the given breadcrumbs.
+ *
+ * A "*" represents a single tag wildcard, where any tag matches, but not no tags.
+ *
+ * At some point this function _may_ support a `**` syntax for matching any number
+ * of unspecified tags in the breadcrumb stack. This has been intentionally left
+ * out, however, to keep this function simple and to avoid introducing backtracking,
+ * which could open up surprising performance breakdowns.
+ *
+ * Example:
+ *
+ * $processor = WP_HTML_Processor::create_fragment( '' );
+ * $processor->next_tag( 'img' );
+ * true === $processor->matches_breadcrumbs( array( 'figure', 'img' ) );
+ * true === $processor->matches_breadcrumbs( array( 'span', 'figure', 'img' ) );
+ * false === $processor->matches_breadcrumbs( array( 'span', 'img' ) );
+ * true === $processor->matches_breadcrumbs( array( 'span', '*', 'img' ) );
+ *
+ * @since 6.4.0
+ *
+ * @param string[] $breadcrumbs DOM sub-path at which element is found, e.g. `array( 'FIGURE', 'IMG' )`.
+ * May also contain the wildcard `*` which matches a single element, e.g. `array( 'SECTION', '*' )`.
+ * @return bool Whether the currently-matched tag is found at the given nested structure.
+ */
+ public function matches_breadcrumbs( $breadcrumbs ) {
+ if ( ! $this->get_tag() ) {
+ return false;
+ }
+
+ // Everything matches when there are zero constraints.
+ if ( 0 === count( $breadcrumbs ) ) {
+ return true;
+ }
+
+ // Start at the last crumb.
+ $crumb = end( $breadcrumbs );
+
+ if ( '*' !== $crumb && $this->get_tag() !== strtoupper( $crumb ) ) {
+ return false;
+ }
+
+ foreach ( $this->state->stack_of_open_elements->walk_up() as $node ) {
+ $crumb = strtoupper( current( $breadcrumbs ) );
+
+ if ( '*' !== $crumb && $node->node_name !== $crumb ) {
+ return false;
+ }
+
+ if ( false === prev( $breadcrumbs ) ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Steps through the HTML document and stop at the next tag, if any.
+ *
+ * @since 6.4.0
+ *
+ * @throws Exception When unable to allocate a bookmark for the next token in the input HTML document.
+ *
+ * @see self::PROCESS_NEXT_NODE
+ * @see self::REPROCESS_CURRENT_NODE
+ *
+ * @param string $node_to_process Whether to parse the next node or reprocess the current node.
+ * @return bool Whether a tag was matched.
+ */
+ public function step( $node_to_process = self::PROCESS_NEXT_NODE ) {
+ // Refuse to proceed if there was a previous error.
+ if ( null !== $this->last_error ) {
+ return false;
+ }
+
+ if ( self::PROCESS_NEXT_NODE === $node_to_process ) {
+ /*
+ * Void elements still hop onto the stack of open elements even though
+ * there's no corresponding closing tag. This is important for managing
+ * stack-based operations such as "navigate to parent node" or checking
+ * on an element's breadcrumbs.
+ *
+ * When moving on to the next node, therefore, if the bottom-most element
+ * on the stack is a void element, it must be closed.
+ *
+ * @todo Once self-closing foreign elements and BGSOUND are supported,
+ * they must also be implicitly closed here too. BGSOUND is
+ * special since it's only self-closing if the self-closing flag
+ * is provided in the opening tag, otherwise it expects a tag closer.
+ */
+ $top_node = $this->state->stack_of_open_elements->current_node();
+ if ( $top_node && self::is_void( $top_node->node_name ) ) {
+ $this->state->stack_of_open_elements->pop();
+ }
+
+ parent::next_tag( self::VISIT_EVERYTHING );
+ }
+
+ // Finish stepping when there are no more tokens in the document.
+ if ( null === $this->get_tag() ) {
+ return false;
+ }
+
+ $this->state->current_token = new WP_HTML_Token(
+ $this->bookmark_tag(),
+ $this->get_tag(),
+ $this->is_tag_closer(),
+ $this->release_internal_bookmark_on_destruct
+ );
+
+ try {
+ switch ( $this->state->insertion_mode ) {
+ case WP_HTML_Processor_State::INSERTION_MODE_IN_BODY:
+ return $this->step_in_body();
+
+ default:
+ $this->last_error = self::ERROR_UNSUPPORTED;
+ throw new WP_HTML_Unsupported_Exception( "No support for parsing in the '{$this->state->insertion_mode}' state." );
+ }
+ } catch ( WP_HTML_Unsupported_Exception $e ) {
+ /*
+ * Exceptions are used in this class to escape deep call stacks that
+ * otherwise might involve messier calling and return conventions.
+ */
+ return false;
+ }
+ }
+
+ /**
+ * Computes the HTML breadcrumbs for the currently-matched node, if matched.
+ *
+ * Breadcrumbs start at the outermost parent and descend toward the matched element.
+ * They always include the entire path from the root HTML node to the matched element.
+ *
+ * @todo It could be more efficient to expose a generator-based version of this function
+ * to avoid creating the array copy on tag iteration. If this is done, it would likely
+ * be more useful to walk up the stack when yielding instead of starting at the top.
+ *
+ * Example
+ *
+ * $processor = WP_HTML_Processor::create_fragment( '
' );
+ * $processor->next_tag( 'IMG' );
+ * $processor->get_breadcrumbs() === array( 'HTML', 'BODY', 'P', 'STRONG', 'EM', 'IMG' );
+ *
+ * @since 6.4.0
+ *
+ * @return string[]|null Array of tag names representing path to matched node, if matched, otherwise NULL.
+ */
+ public function get_breadcrumbs() {
+ if ( ! $this->get_tag() ) {
+ return null;
+ }
+
+ $breadcrumbs = array();
+ foreach ( $this->state->stack_of_open_elements->walk_down() as $stack_item ) {
+ $breadcrumbs[] = $stack_item->node_name;
+ }
+
+ return $breadcrumbs;
+ }
+
+ /**
+ * Parses next element in the 'in body' insertion mode.
+ *
+ * This internal function performs the 'in body' insertion mode
+ * logic for the generalized WP_HTML_Processor::step() function.
+ *
+ * @since 6.4.0
+ *
+ * @throws WP_HTML_Unsupported_Exception When encountering unsupported HTML input.
+ *
+ * @see https://html.spec.whatwg.org/#parsing-main-inbody
+ * @see WP_HTML_Processor::step
+ *
+ * @return bool Whether an element was found.
+ */
+ private function step_in_body() {
+ $tag_name = $this->get_tag();
+ $op_sigil = $this->is_tag_closer() ? '-' : '+';
+ $op = "{$op_sigil}{$tag_name}";
+
+ switch ( $op ) {
+ /*
+ * > A start tag whose tag name is "button"
+ */
+ case '+BUTTON':
+ if ( $this->state->stack_of_open_elements->has_element_in_scope( 'BUTTON' ) ) {
+ // @todo Indicate a parse error once it's possible. This error does not impact the logic here.
+ $this->generate_implied_end_tags();
+ $this->state->stack_of_open_elements->pop_until( 'BUTTON' );
+ }
+
+ $this->reconstruct_active_formatting_elements();
+ $this->insert_html_element( $this->state->current_token );
+ $this->state->frameset_ok = false;
+
+ return true;
+
+ /*
+ * > A start tag whose tag name is one of: "address", "article", "aside",
+ * > "blockquote", "center", "details", "dialog", "dir", "div", "dl",
+ * > "fieldset", "figcaption", "figure", "footer", "header", "hgroup",
+ * > "main", "menu", "nav", "ol", "p", "search", "section", "summary", "ul"
+ */
+ case '+ADDRESS':
+ case '+ARTICLE':
+ case '+ASIDE':
+ case '+BLOCKQUOTE':
+ case '+CENTER':
+ case '+DETAILS':
+ case '+DIALOG':
+ case '+DIR':
+ case '+DIV':
+ case '+DL':
+ case '+FIELDSET':
+ case '+FIGCAPTION':
+ case '+FIGURE':
+ case '+FOOTER':
+ case '+HEADER':
+ case '+HGROUP':
+ case '+MAIN':
+ case '+MENU':
+ case '+NAV':
+ case '+OL':
+ case '+P':
+ case '+SEARCH':
+ case '+SECTION':
+ case '+SUMMARY':
+ case '+UL':
+ if ( $this->state->stack_of_open_elements->has_p_in_button_scope() ) {
+ $this->close_a_p_element();
+ }
+
+ $this->insert_html_element( $this->state->current_token );
+ return true;
+
+ /*
+ * > An end tag whose tag name is one of: "address", "article", "aside", "blockquote",
+ * > "button", "center", "details", "dialog", "dir", "div", "dl", "fieldset",
+ * > "figcaption", "figure", "footer", "header", "hgroup", "listing", "main",
+ * > "menu", "nav", "ol", "pre", "search", "section", "summary", "ul"
+ */
+ case '-ADDRESS':
+ case '-ARTICLE':
+ case '-ASIDE':
+ case '-BLOCKQUOTE':
+ case '-BUTTON':
+ case '-CENTER':
+ case '-DETAILS':
+ case '-DIALOG':
+ case '-DIR':
+ case '-DIV':
+ case '-DL':
+ case '-FIELDSET':
+ case '-FIGCAPTION':
+ case '-FIGURE':
+ case '-FOOTER':
+ case '-HEADER':
+ case '-HGROUP':
+ case '-MAIN':
+ case '-MENU':
+ case '-NAV':
+ case '-OL':
+ case '-SEARCH':
+ case '-SECTION':
+ case '-SUMMARY':
+ case '-UL':
+ if ( ! $this->state->stack_of_open_elements->has_element_in_scope( $tag_name ) ) {
+ // @todo Report parse error.
+ // Ignore the token.
+ return $this->step();
+ }
+
+ $this->generate_implied_end_tags();
+ if ( $this->state->stack_of_open_elements->current_node()->node_name !== $tag_name ) {
+ // @todo Record parse error: this error doesn't impact parsing.
+ }
+ $this->state->stack_of_open_elements->pop_until( $tag_name );
+ return true;
+
+ /*
+ * > A start tag whose tag name is one of: "h1", "h2", "h3", "h4", "h5", "h6"
+ */
+ case '+H1':
+ case '+H2':
+ case '+H3':
+ case '+H4':
+ case '+H5':
+ case '+H6':
+ if ( $this->state->stack_of_open_elements->has_p_in_button_scope() ) {
+ $this->close_a_p_element();
+ }
+
+ if (
+ in_array(
+ $this->state->stack_of_open_elements->current_node()->node_name,
+ array( 'H1', 'H2', 'H3', 'H4', 'H5', 'H6' ),
+ true
+ )
+ ) {
+ // @todo Indicate a parse error once it's possible.
+ $this->state->stack_of_open_elements->pop();
+ }
+
+ $this->insert_html_element( $this->state->current_token );
+ return true;
+
+ /*
+ * > An end tag whose tag name is one of: "h1", "h2", "h3", "h4", "h5", "h6"
+ */
+ case '-H1':
+ case '-H2':
+ case '-H3':
+ case '-H4':
+ case '-H5':
+ case '-H6':
+ if ( ! $this->state->stack_of_open_elements->has_element_in_scope( '(internal: H1 through H6 - do not use)' ) ) {
+ /*
+ * This is a parse error; ignore the token.
+ *
+ * @todo Indicate a parse error once it's possible.
+ */
+ return $this->step();
+ }
+
+ $this->generate_implied_end_tags();
+
+ if ( $this->state->stack_of_open_elements->current_node()->node_name !== $tag_name ) {
+ // @todo Record parse error: this error doesn't impact parsing.
+ }
+
+ $this->state->stack_of_open_elements->pop_until( '(internal: H1 through H6 - do not use)' );
+ return true;
+
+ /*
+ * > A start tag whose tag name is "li"
+ * > A start tag whose tag name is one of: "dd", "dt"
+ */
+ case '+DD':
+ case '+DT':
+ case '+LI':
+ $this->state->frameset_ok = false;
+ $node = $this->state->stack_of_open_elements->current_node();
+ $is_li = 'LI' === $tag_name;
+
+ in_body_list_loop:
+ /*
+ * The logic for LI and DT/DD is the same except for one point: LI elements _only_
+ * close other LI elements, but a DT or DD element closes _any_ open DT or DD element.
+ */
+ if ( $is_li ? 'LI' === $node->node_name : ( 'DD' === $node->node_name || 'DT' === $node->node_name ) ) {
+ $node_name = $is_li ? 'LI' : $node->node_name;
+ $this->generate_implied_end_tags( $node_name );
+ if ( $node_name !== $this->state->stack_of_open_elements->current_node()->node_name ) {
+ // @todo Indicate a parse error once it's possible. This error does not impact the logic here.
+ }
+
+ $this->state->stack_of_open_elements->pop_until( $node_name );
+ goto in_body_list_done;
+ }
+
+ if (
+ 'ADDRESS' !== $node->node_name &&
+ 'DIV' !== $node->node_name &&
+ 'P' !== $node->node_name &&
+ $this->is_special( $node->node_name )
+ ) {
+ /*
+ * > If node is in the special category, but is not an address, div,
+ * > or p element, then jump to the step labeled done below.
+ */
+ goto in_body_list_done;
+ } else {
+ /*
+ * > Otherwise, set node to the previous entry in the stack of open elements
+ * > and return to the step labeled loop.
+ */
+ foreach ( $this->state->stack_of_open_elements->walk_up( $node ) as $item ) {
+ $node = $item;
+ break;
+ }
+ goto in_body_list_loop;
+ }
+
+ in_body_list_done:
+ if ( $this->state->stack_of_open_elements->has_p_in_button_scope() ) {
+ $this->close_a_p_element();
+ }
+
+ $this->insert_html_element( $this->state->current_token );
+ return true;
+
+ /*
+ * > An end tag whose tag name is "li"
+ * > An end tag whose tag name is one of: "dd", "dt"
+ */
+ case '-DD':
+ case '-DT':
+ case '-LI':
+ if (
+ /*
+ * An end tag whose tag name is "li":
+ * If the stack of open elements does not have an li element in list item scope,
+ * then this is a parse error; ignore the token.
+ */
+ (
+ 'LI' === $tag_name &&
+ ! $this->state->stack_of_open_elements->has_element_in_list_item_scope( 'LI' )
+ ) ||
+ /*
+ * An end tag whose tag name is one of: "dd", "dt":
+ * If the stack of open elements does not have an element in scope that is an
+ * HTML element with the same tag name as that of the token, then this is a
+ * parse error; ignore the token.
+ */
+ (
+ 'LI' !== $tag_name &&
+ ! $this->state->stack_of_open_elements->has_element_in_scope( $tag_name )
+ )
+ ) {
+ /*
+ * This is a parse error, ignore the token.
+ *
+ * @todo Indicate a parse error once it's possible.
+ */
+ return $this->step();
+ }
+
+ $this->generate_implied_end_tags( $tag_name );
+
+ if ( $tag_name !== $this->state->stack_of_open_elements->current_node()->node_name ) {
+ // @todo Indicate a parse error once it's possible. This error does not impact the logic here.
+ }
+
+ $this->state->stack_of_open_elements->pop_until( $tag_name );
+ return true;
+
+ /*
+ * > An end tag whose tag name is "p"
+ */
+ case '-P':
+ if ( ! $this->state->stack_of_open_elements->has_p_in_button_scope() ) {
+ $this->insert_html_element( $this->state->current_token );
+ }
+
+ $this->close_a_p_element();
+ return true;
+
+ // > A start tag whose tag name is "a"
+ case '+A':
+ foreach ( $this->state->active_formatting_elements->walk_up() as $item ) {
+ switch ( $item->node_name ) {
+ case 'marker':
+ break;
+
+ case 'A':
+ $this->run_adoption_agency_algorithm();
+ $this->state->active_formatting_elements->remove_node( $item );
+ $this->state->stack_of_open_elements->remove_node( $item );
+ break;
+ }
+ }
+
+ $this->reconstruct_active_formatting_elements();
+ $this->insert_html_element( $this->state->current_token );
+ $this->state->active_formatting_elements->push( $this->state->current_token );
+ return true;
+
+ /*
+ * > A start tag whose tag name is one of: "b", "big", "code", "em", "font", "i",
+ * > "s", "small", "strike", "strong", "tt", "u"
+ */
+ case '+B':
+ case '+BIG':
+ case '+CODE':
+ case '+EM':
+ case '+FONT':
+ case '+I':
+ case '+S':
+ case '+SMALL':
+ case '+STRIKE':
+ case '+STRONG':
+ case '+TT':
+ case '+U':
+ $this->reconstruct_active_formatting_elements();
+ $this->insert_html_element( $this->state->current_token );
+ $this->state->active_formatting_elements->push( $this->state->current_token );
+ return true;
+
+ /*
+ * > An end tag whose tag name is one of: "a", "b", "big", "code", "em", "font", "i",
+ * > "nobr", "s", "small", "strike", "strong", "tt", "u"
+ */
+ case '-A':
+ case '-B':
+ case '-BIG':
+ case '-CODE':
+ case '-EM':
+ case '-FONT':
+ case '-I':
+ case '-S':
+ case '-SMALL':
+ case '-STRIKE':
+ case '-STRONG':
+ case '-TT':
+ case '-U':
+ $this->run_adoption_agency_algorithm();
+ return true;
+
+ /*
+ * > A start tag whose tag name is one of: "area", "br", "embed", "img", "keygen", "wbr"
+ */
+ case '+IMG':
+ $this->reconstruct_active_formatting_elements();
+ $this->insert_html_element( $this->state->current_token );
+ return true;
+ }
+
+ /*
+ * These tags require special handling in the 'in body' insertion mode
+ * but that handling hasn't yet been implemented.
+ *
+ * As the rules for each tag are implemented, the corresponding tag
+ * name should be removed from this list. An accompanying test should
+ * help ensure this list is maintained.
+ *
+ * @see Tests_HtmlApi_WpHtmlProcessor::test_step_in_body_fails_on_unsupported_tags
+ *
+ * Since this switch structure throws a WP_HTML_Unsupported_Exception, it's
+ * possible to handle "any other start tag" and "any other end tag" below,
+ * as that guarantees execution doesn't proceed for the unimplemented tags.
+ *
+ * @see https://html.spec.whatwg.org/multipage/parsing.html#parsing-main-inbody
+ */
+ switch ( $tag_name ) {
+ case 'APPLET':
+ case 'AREA':
+ case 'BASE':
+ case 'BASEFONT':
+ case 'BGSOUND':
+ case 'BODY':
+ case 'BR':
+ case 'CAPTION':
+ case 'COL':
+ case 'COLGROUP':
+ case 'DD':
+ case 'DT':
+ case 'EMBED':
+ case 'FORM':
+ case 'FRAME':
+ case 'FRAMESET':
+ case 'HEAD':
+ case 'HR':
+ case 'HTML':
+ case 'IFRAME':
+ case 'INPUT':
+ case 'KEYGEN':
+ case 'LI':
+ case 'LINK':
+ case 'LISTING':
+ case 'MARQUEE':
+ case 'MATH':
+ case 'META':
+ case 'NOBR':
+ case 'NOEMBED':
+ case 'NOFRAMES':
+ case 'NOSCRIPT':
+ case 'OBJECT':
+ case 'OL':
+ case 'OPTGROUP':
+ case 'OPTION':
+ case 'PARAM':
+ case 'PLAINTEXT':
+ case 'PRE':
+ case 'RB':
+ case 'RP':
+ case 'RT':
+ case 'RTC':
+ case 'SARCASM':
+ case 'SCRIPT':
+ case 'SELECT':
+ case 'SOURCE':
+ case 'STYLE':
+ case 'SVG':
+ case 'TABLE':
+ case 'TBODY':
+ case 'TD':
+ case 'TEMPLATE':
+ case 'TEXTAREA':
+ case 'TFOOT':
+ case 'TH':
+ case 'THEAD':
+ case 'TITLE':
+ case 'TR':
+ case 'TRACK':
+ case 'UL':
+ case 'WBR':
+ case 'XMP':
+ $this->last_error = self::ERROR_UNSUPPORTED;
+ throw new WP_HTML_Unsupported_Exception( "Cannot process {$tag_name} element." );
+ }
+
+ if ( ! $this->is_tag_closer() ) {
+ /*
+ * > Any other start tag
+ */
+ $this->reconstruct_active_formatting_elements();
+ $this->insert_html_element( $this->state->current_token );
+ return true;
+ } else {
+ /*
+ * > Any other end tag
+ */
+
+ /*
+ * Find the corresponding tag opener in the stack of open elements, if
+ * it exists before reaching a special element, which provides a kind
+ * of boundary in the stack. For example, a `` should not
+ * close anything beyond its containing `P` or `DIV` element.
+ */
+ foreach ( $this->state->stack_of_open_elements->walk_up() as $node ) {
+ if ( $tag_name === $node->node_name ) {
+ break;
+ }
+
+ if ( self::is_special( $node->node_name ) ) {
+ // This is a parse error, ignore the token.
+ return $this->step();
+ }
+ }
+
+ $this->generate_implied_end_tags( $tag_name );
+ if ( $node !== $this->state->stack_of_open_elements->current_node() ) {
+ // @todo Record parse error: this error doesn't impact parsing.
+ }
+
+ foreach ( $this->state->stack_of_open_elements->walk_up() as $item ) {
+ $this->state->stack_of_open_elements->pop();
+ if ( $node === $item ) {
+ return true;
+ }
+ }
+ }
+ }
+
+ /*
+ * Internal helpers
+ */
+
+ /**
+ * Creates a new bookmark for the currently-matched tag and returns the generated name.
+ *
+ * @since 6.4.0
+ *
+ * @throws Exception When unable to allocate requested bookmark.
+ *
+ * @return string|false Name of created bookmark, or false if unable to create.
+ */
+ private function bookmark_tag() {
+ if ( ! $this->get_tag() ) {
+ return false;
+ }
+
+ if ( ! parent::set_bookmark( ++$this->bookmark_counter ) ) {
+ $this->last_error = self::ERROR_EXCEEDED_MAX_BOOKMARKS;
+ throw new Exception( 'could not allocate bookmark' );
+ }
+
+ return "{$this->bookmark_counter}";
+ }
+
+ /*
+ * HTML semantic overrides for Tag Processor
+ */
+
+ /**
+ * Returns the uppercase name of the matched tag.
+ *
+ * The semantic rules for HTML specify that certain tags be reprocessed
+ * with a different tag name. Because of this, the tag name presented
+ * by the HTML Processor may differ from the one reported by the HTML
+ * Tag Processor, which doesn't apply these semantic rules.
+ *
+ * Example:
+ *
+ * $processor = new WP_HTML_Tag_Processor( 'Test
' );
+ * $processor->next_tag() === true;
+ * $processor->get_tag() === 'DIV';
+ *
+ * $processor->next_tag() === false;
+ * $processor->get_tag() === null;
+ *
+ * @since 6.4.0
+ *
+ * @return string|null Name of currently matched tag in input HTML, or `null` if none found.
+ */
+ public function get_tag() {
+ if ( null !== $this->last_error ) {
+ return null;
+ }
+
+ $tag_name = parent::get_tag();
+
+ switch ( $tag_name ) {
+ case 'IMAGE':
+ /*
+ * > A start tag whose tag name is "image"
+ * > Change the token's tag name to "img" and reprocess it. (Don't ask.)
+ */
+ return 'IMG';
+
+ default:
+ return $tag_name;
+ }
+ }
+
+ /**
+ * Removes a bookmark that is no longer needed.
+ *
+ * Releasing a bookmark frees up the small
+ * performance overhead it requires.
+ *
+ * @since 6.4.0
+ *
+ * @param string $bookmark_name Name of the bookmark to remove.
+ * @return bool Whether the bookmark already existed before removal.
+ */
+ public function release_bookmark( $bookmark_name ) {
+ return parent::release_bookmark( "_{$bookmark_name}" );
+ }
+
+ /**
+ * Moves the internal cursor in the HTML Processor to a given bookmark's location.
+ *
+ * In order to prevent accidental infinite loops, there's a
+ * maximum limit on the number of times seek() can be called.
+ *
+ * @throws Exception When unable to allocate a bookmark for the next token in the input HTML document.
+ *
+ * @since 6.4.0
+ *
+ * @param string $bookmark_name Jump to the place in the document identified by this bookmark name.
+ * @return bool Whether the internal cursor was successfully moved to the bookmark's location.
+ */
+ public function seek( $bookmark_name ) {
+ $actual_bookmark_name = "_{$bookmark_name}";
+ $processor_started_at = $this->state->current_token
+ ? $this->bookmarks[ $this->state->current_token->bookmark_name ]->start
+ : 0;
+ $bookmark_starts_at = $this->bookmarks[ $actual_bookmark_name ]->start;
+ $direction = $bookmark_starts_at > $processor_started_at ? 'forward' : 'backward';
+
+ switch ( $direction ) {
+ case 'forward':
+ // When moving forwards, re-parse the document until reaching the same location as the original bookmark.
+ while ( $this->step() ) {
+ if ( $bookmark_starts_at === $this->bookmarks[ $this->state->current_token->bookmark_name ]->start ) {
+ return true;
+ }
+ }
+
+ return false;
+
+ case 'backward':
+ /*
+ * When moving backwards, clear out all existing stack entries which appear after the destination
+ * bookmark. These could be stored for later retrieval, but doing so would require additional
+ * memory overhead and also demand that references and bookmarks are updated as the document
+ * changes. In time this could be a valuable optimization, but it's okay to give up that
+ * optimization in exchange for more CPU time to recompute the stack, to re-parse the
+ * document that may have already been parsed once.
+ */
+ foreach ( $this->state->stack_of_open_elements->walk_up() as $item ) {
+ if ( $bookmark_starts_at >= $this->bookmarks[ $item->bookmark_name ]->start ) {
+ break;
+ }
+
+ $this->state->stack_of_open_elements->remove_node( $item );
+ }
+
+ foreach ( $this->state->active_formatting_elements->walk_up() as $item ) {
+ if ( $bookmark_starts_at >= $this->bookmarks[ $item->bookmark_name ]->start ) {
+ break;
+ }
+
+ $this->state->active_formatting_elements->remove_node( $item );
+ }
+
+ return parent::seek( $actual_bookmark_name );
+ }
+ }
+
+ /**
+ * Sets a bookmark in the HTML document.
+ *
+ * Bookmarks represent specific places or tokens in the HTML
+ * document, such as a tag opener or closer. When applying
+ * edits to a document, such as setting an attribute, the
+ * text offsets of that token may shift; the bookmark is
+ * kept updated with those shifts and remains stable unless
+ * the entire span of text in which the token sits is removed.
+ *
+ * Release bookmarks when they are no longer needed.
+ *
+ * Example:
+ *
+ * Surprising fact you may not know!
+ * ^ ^
+ * \-|-- this `H2` opener bookmark tracks the token
+ *
+ * Surprising fact you may no…
+ * ^ ^
+ * \-|-- it shifts with edits
+ *
+ * Bookmarks provide the ability to seek to a previously-scanned
+ * place in the HTML document. This avoids the need to re-scan
+ * the entire document.
+ *
+ * Example:
+ *
+ *
+ * ^^^^
+ * want to note this last item
+ *
+ * $p = new WP_HTML_Tag_Processor( $html );
+ * $in_list = false;
+ * while ( $p->next_tag( array( 'tag_closers' => $in_list ? 'visit' : 'skip' ) ) ) {
+ * if ( 'UL' === $p->get_tag() ) {
+ * if ( $p->is_tag_closer() ) {
+ * $in_list = false;
+ * $p->set_bookmark( 'resume' );
+ * if ( $p->seek( 'last-li' ) ) {
+ * $p->add_class( 'last-li' );
+ * }
+ * $p->seek( 'resume' );
+ * $p->release_bookmark( 'last-li' );
+ * $p->release_bookmark( 'resume' );
+ * } else {
+ * $in_list = true;
+ * }
+ * }
+ *
+ * if ( 'LI' === $p->get_tag() ) {
+ * $p->set_bookmark( 'last-li' );
+ * }
+ * }
+ *
+ * Bookmarks intentionally hide the internal string offsets
+ * to which they refer. They are maintained internally as
+ * updates are applied to the HTML document and therefore
+ * retain their "position" - the location to which they
+ * originally pointed. The inability to use bookmarks with
+ * functions like `substr` is therefore intentional to guard
+ * against accidentally breaking the HTML.
+ *
+ * Because bookmarks allocate memory and require processing
+ * for every applied update, they are limited and require
+ * a name. They should not be created with programmatically-made
+ * names, such as "li_{$index}" with some loop. As a general
+ * rule they should only be created with string-literal names
+ * like "start-of-section" or "last-paragraph".
+ *
+ * Bookmarks are a powerful tool to enable complicated behavior.
+ * Consider double-checking that you need this tool if you are
+ * reaching for it, as inappropriate use could lead to broken
+ * HTML structure or unwanted processing overhead.
+ *
+ * @since 6.4.0
+ *
+ * @param string $bookmark_name Identifies this particular bookmark.
+ * @return bool Whether the bookmark was successfully created.
+ */
+ public function set_bookmark( $bookmark_name ) {
+ return parent::set_bookmark( "_{$bookmark_name}" );
+ }
+
+ /*
+ * HTML Parsing Algorithms
+ */
+
+ /**
+ * Closes a P element.
+ *
+ * @since 6.4.0
+ *
+ * @throws WP_HTML_Unsupported_Exception When encountering unsupported HTML input.
+ *
+ * @see https://html.spec.whatwg.org/#close-a-p-element
+ */
+ private function close_a_p_element() {
+ $this->generate_implied_end_tags( 'P' );
+ $this->state->stack_of_open_elements->pop_until( 'P' );
+ }
+
+ /**
+ * Closes elements that have implied end tags.
+ *
+ * @since 6.4.0
+ *
+ * @see https://html.spec.whatwg.org/#generate-implied-end-tags
+ *
+ * @param string|null $except_for_this_element Perform as if this element doesn't exist in the stack of open elements.
+ */
+ private function generate_implied_end_tags( $except_for_this_element = null ) {
+ $elements_with_implied_end_tags = array(
+ 'DD',
+ 'DT',
+ 'LI',
+ 'P',
+ );
+
+ $current_node = $this->state->stack_of_open_elements->current_node();
+ while (
+ $current_node && $current_node->node_name !== $except_for_this_element &&
+ in_array( $this->state->stack_of_open_elements->current_node(), $elements_with_implied_end_tags, true )
+ ) {
+ $this->state->stack_of_open_elements->pop();
+ }
+ }
+
+ /**
+ * Closes elements that have implied end tags, thoroughly.
+ *
+ * See the HTML specification for an explanation why this is
+ * different from generating end tags in the normal sense.
+ *
+ * @since 6.4.0
+ *
+ * @see WP_HTML_Processor::generate_implied_end_tags
+ * @see https://html.spec.whatwg.org/#generate-implied-end-tags
+ */
+ private function generate_implied_end_tags_thoroughly() {
+ $elements_with_implied_end_tags = array(
+ 'DD',
+ 'DT',
+ 'LI',
+ 'P',
+ );
+
+ while ( in_array( $this->state->stack_of_open_elements->current_node(), $elements_with_implied_end_tags, true ) ) {
+ $this->state->stack_of_open_elements->pop();
+ }
+ }
+
+ /**
+ * Reconstructs the active formatting elements.
+ *
+ * > This has the effect of reopening all the formatting elements that were opened
+ * > in the current body, cell, or caption (whichever is youngest) that haven't
+ * > been explicitly closed.
+ *
+ * @since 6.4.0
+ *
+ * @throws WP_HTML_Unsupported_Exception When encountering unsupported HTML input.
+ *
+ * @see https://html.spec.whatwg.org/#reconstruct-the-active-formatting-elements
+ *
+ * @return bool Whether any formatting elements needed to be reconstructed.
+ */
+ private function reconstruct_active_formatting_elements() {
+ /*
+ * > If there are no entries in the list of active formatting elements, then there is nothing
+ * > to reconstruct; stop this algorithm.
+ */
+ if ( 0 === $this->state->active_formatting_elements->count() ) {
+ return false;
+ }
+
+ $last_entry = $this->state->active_formatting_elements->current_node();
+ if (
+
+ /*
+ * > If the last (most recently added) entry in the list of active formatting elements is a marker;
+ * > stop this algorithm.
+ */
+ 'marker' === $last_entry->node_name ||
+
+ /*
+ * > If the last (most recently added) entry in the list of active formatting elements is an
+ * > element that is in the stack of open elements, then there is nothing to reconstruct;
+ * > stop this algorithm.
+ */
+ $this->state->stack_of_open_elements->contains_node( $last_entry )
+ ) {
+ return false;
+ }
+
+ $this->last_error = self::ERROR_UNSUPPORTED;
+ throw new WP_HTML_Unsupported_Exception( 'Cannot reconstruct active formatting elements when advancing and rewinding is required.' );
+ }
+
+ /**
+ * Runs the adoption agency algorithm.
+ *
+ * @since 6.4.0
+ *
+ * @throws WP_HTML_Unsupported_Exception When encountering unsupported HTML input.
+ *
+ * @see https://html.spec.whatwg.org/#adoption-agency-algorithm
+ */
+ private function run_adoption_agency_algorithm() {
+ $budget = 1000;
+ $subject = $this->get_tag();
+ $current_node = $this->state->stack_of_open_elements->current_node();
+
+ if (
+ // > If the current node is an HTML element whose tag name is subject
+ $current_node && $subject === $current_node->node_name &&
+ // > the current node is not in the list of active formatting elements
+ ! $this->state->active_formatting_elements->contains_node( $current_node )
+ ) {
+ $this->state->stack_of_open_elements->pop();
+ return;
+ }
+
+ $outer_loop_counter = 0;
+ while ( $budget-- > 0 ) {
+ if ( $outer_loop_counter++ >= 8 ) {
+ return;
+ }
+
+ /*
+ * > Let formatting element be the last element in the list of active formatting elements that:
+ * > - is between the end of the list and the last marker in the list,
+ * > if any, or the start of the list otherwise,
+ * > - and has the tag name subject.
+ */
+ $formatting_element = null;
+ foreach ( $this->state->active_formatting_elements->walk_up() as $item ) {
+ if ( 'marker' === $item->node_name ) {
+ break;
+ }
+
+ if ( $subject === $item->node_name ) {
+ $formatting_element = $item;
+ break;
+ }
+ }
+
+ // > If there is no such element, then return and instead act as described in the "any other end tag" entry above.
+ if ( null === $formatting_element ) {
+ $this->last_error = self::ERROR_UNSUPPORTED;
+ throw new WP_HTML_Unsupported_Exception( 'Cannot run adoption agency when "any other end tag" is required.' );
+ }
+
+ // > If formatting element is not in the stack of open elements, then this is a parse error; remove the element from the list, and return.
+ if ( ! $this->state->stack_of_open_elements->contains_node( $formatting_element ) ) {
+ $this->state->active_formatting_elements->remove_node( $formatting_element );
+ return;
+ }
+
+ // > If formatting element is in the stack of open elements, but the element is not in scope, then this is a parse error; return.
+ if ( ! $this->state->stack_of_open_elements->has_element_in_scope( $formatting_element->node_name ) ) {
+ return;
+ }
+
+ /*
+ * > Let furthest block be the topmost node in the stack of open elements that is lower in the stack
+ * > than formatting element, and is an element in the special category. There might not be one.
+ */
+ $is_above_formatting_element = true;
+ $furthest_block = null;
+ foreach ( $this->state->stack_of_open_elements->walk_down() as $item ) {
+ if ( $is_above_formatting_element && $formatting_element->bookmark_name !== $item->bookmark_name ) {
+ continue;
+ }
+
+ if ( $is_above_formatting_element ) {
+ $is_above_formatting_element = false;
+ continue;
+ }
+
+ if ( self::is_special( $item->node_name ) ) {
+ $furthest_block = $item;
+ break;
+ }
+ }
+
+ /*
+ * > If there is no furthest block, then the UA must first pop all the nodes from the bottom of the
+ * > stack of open elements, from the current node up to and including formatting element, then
+ * > remove formatting element from the list of active formatting elements, and finally return.
+ */
+ if ( null === $furthest_block ) {
+ foreach ( $this->state->stack_of_open_elements->walk_up() as $item ) {
+ $this->state->stack_of_open_elements->pop();
+
+ if ( $formatting_element->bookmark_name === $item->bookmark_name ) {
+ $this->state->active_formatting_elements->remove_node( $formatting_element );
+ return;
+ }
+ }
+ }
+
+ $this->last_error = self::ERROR_UNSUPPORTED;
+ throw new WP_HTML_Unsupported_Exception( 'Cannot extract common ancestor in adoption agency algorithm.' );
+ }
+
+ $this->last_error = self::ERROR_UNSUPPORTED;
+ throw new WP_HTML_Unsupported_Exception( 'Cannot run adoption agency when looping required.' );
+ }
+
+ /**
+ * Inserts an HTML element on the stack of open elements.
+ *
+ * @since 6.4.0
+ *
+ * @see https://html.spec.whatwg.org/#insert-a-foreign-element
+ *
+ * @param WP_HTML_Token $token Name of bookmark pointing to element in original input HTML.
+ */
+ private function insert_html_element( $token ) {
+ $this->state->stack_of_open_elements->push( $token );
+ }
+
+ /*
+ * HTML Specification Helpers
+ */
+
+ /**
+ * Returns whether an element of a given name is in the HTML special category.
+ *
+ * @since 6.4.0
+ *
+ * @see https://html.spec.whatwg.org/#special
+ *
+ * @param string $tag_name Name of element to check.
+ * @return bool Whether the element of the given name is in the special category.
+ */
+ public static function is_special( $tag_name ) {
+ $tag_name = strtoupper( $tag_name );
+
+ return (
+ 'ADDRESS' === $tag_name ||
+ 'APPLET' === $tag_name ||
+ 'AREA' === $tag_name ||
+ 'ARTICLE' === $tag_name ||
+ 'ASIDE' === $tag_name ||
+ 'BASE' === $tag_name ||
+ 'BASEFONT' === $tag_name ||
+ 'BGSOUND' === $tag_name ||
+ 'BLOCKQUOTE' === $tag_name ||
+ 'BODY' === $tag_name ||
+ 'BR' === $tag_name ||
+ 'BUTTON' === $tag_name ||
+ 'CAPTION' === $tag_name ||
+ 'CENTER' === $tag_name ||
+ 'COL' === $tag_name ||
+ 'COLGROUP' === $tag_name ||
+ 'DD' === $tag_name ||
+ 'DETAILS' === $tag_name ||
+ 'DIR' === $tag_name ||
+ 'DIV' === $tag_name ||
+ 'DL' === $tag_name ||
+ 'DT' === $tag_name ||
+ 'EMBED' === $tag_name ||
+ 'FIELDSET' === $tag_name ||
+ 'FIGCAPTION' === $tag_name ||
+ 'FIGURE' === $tag_name ||
+ 'FOOTER' === $tag_name ||
+ 'FORM' === $tag_name ||
+ 'FRAME' === $tag_name ||
+ 'FRAMESET' === $tag_name ||
+ 'H1' === $tag_name ||
+ 'H2' === $tag_name ||
+ 'H3' === $tag_name ||
+ 'H4' === $tag_name ||
+ 'H5' === $tag_name ||
+ 'H6' === $tag_name ||
+ 'HEAD' === $tag_name ||
+ 'HEADER' === $tag_name ||
+ 'HGROUP' === $tag_name ||
+ 'HR' === $tag_name ||
+ 'HTML' === $tag_name ||
+ 'IFRAME' === $tag_name ||
+ 'IMG' === $tag_name ||
+ 'INPUT' === $tag_name ||
+ 'KEYGEN' === $tag_name ||
+ 'LI' === $tag_name ||
+ 'LINK' === $tag_name ||
+ 'LISTING' === $tag_name ||
+ 'MAIN' === $tag_name ||
+ 'MARQUEE' === $tag_name ||
+ 'MENU' === $tag_name ||
+ 'META' === $tag_name ||
+ 'NAV' === $tag_name ||
+ 'NOEMBED' === $tag_name ||
+ 'NOFRAMES' === $tag_name ||
+ 'NOSCRIPT' === $tag_name ||
+ 'OBJECT' === $tag_name ||
+ 'OL' === $tag_name ||
+ 'P' === $tag_name ||
+ 'PARAM' === $tag_name ||
+ 'PLAINTEXT' === $tag_name ||
+ 'PRE' === $tag_name ||
+ 'SCRIPT' === $tag_name ||
+ 'SEARCH' === $tag_name ||
+ 'SECTION' === $tag_name ||
+ 'SELECT' === $tag_name ||
+ 'SOURCE' === $tag_name ||
+ 'STYLE' === $tag_name ||
+ 'SUMMARY' === $tag_name ||
+ 'TABLE' === $tag_name ||
+ 'TBODY' === $tag_name ||
+ 'TD' === $tag_name ||
+ 'TEMPLATE' === $tag_name ||
+ 'TEXTAREA' === $tag_name ||
+ 'TFOOT' === $tag_name ||
+ 'TH' === $tag_name ||
+ 'THEAD' === $tag_name ||
+ 'TITLE' === $tag_name ||
+ 'TR' === $tag_name ||
+ 'TRACK' === $tag_name ||
+ 'UL' === $tag_name ||
+ 'WBR' === $tag_name ||
+ 'XMP' === $tag_name ||
+
+ // MathML.
+ 'MI' === $tag_name ||
+ 'MO' === $tag_name ||
+ 'MN' === $tag_name ||
+ 'MS' === $tag_name ||
+ 'MTEXT' === $tag_name ||
+ 'ANNOTATION-XML' === $tag_name ||
+
+ // SVG.
+ 'FOREIGNOBJECT' === $tag_name ||
+ 'DESC' === $tag_name ||
+ 'TITLE' === $tag_name
+ );
+ }
+
+ /**
+ * Returns whether a given element is an HTML Void Element
+ *
+ * > area, base, br, col, embed, hr, img, input, link, meta, source, track, wbr
+ *
+ * @since 6.4.0
+ *
+ * @see https://html.spec.whatwg.org/#void-elements
+ *
+ * @param string $tag_name Name of HTML tag to check.
+ * @return bool Whether the given tag is an HTML Void Element.
+ */
+ public static function is_void( $tag_name ) {
+ $tag_name = strtoupper( $tag_name );
+
+ return (
+ 'AREA' === $tag_name ||
+ 'BASE' === $tag_name ||
+ 'BR' === $tag_name ||
+ 'COL' === $tag_name ||
+ 'EMBED' === $tag_name ||
+ 'HR' === $tag_name ||
+ 'IMG' === $tag_name ||
+ 'INPUT' === $tag_name ||
+ 'LINK' === $tag_name ||
+ 'META' === $tag_name ||
+ 'SOURCE' === $tag_name ||
+ 'TRACK' === $tag_name ||
+ 'WBR' === $tag_name
+ );
+ }
+
+ /*
+ * Constants that would pollute the top of the class if they were found there.
+ */
+
+ /**
+ * Indicates that the next HTML token should be parsed and processed.
+ *
+ * @since 6.4.0
+ *
+ * @var string
+ */
+ const PROCESS_NEXT_NODE = 'process-next-node';
+
+ /**
+ * Indicates that the current HTML token should be reprocessed in the newly-selected insertion mode.
+ *
+ * @since 6.4.0
+ *
+ * @var string
+ */
+ const REPROCESS_CURRENT_NODE = 'reprocess-current-node';
+
+ /**
+ * Indicates that the parser encountered unsupported markup and has bailed.
+ *
+ * @since 6.4.0
+ *
+ * @var string
+ */
+ const ERROR_UNSUPPORTED = 'unsupported';
+
+ /**
+ * Indicates that the parser encountered more HTML tokens than it
+ * was able to process and has bailed.
+ *
+ * @since 6.4.0
+ *
+ * @var string
+ */
+ const ERROR_EXCEEDED_MAX_BOOKMARKS = 'exceeded-max-bookmarks';
+
+ /**
+ * Unlock code that must be passed into the constructor to create this class.
+ *
+ * This class extends the WP_HTML_Tag_Processor, which has a public class
+ * constructor. Therefore, it's not possible to have a private constructor here.
+ *
+ * This unlock code is used to ensure that anyone calling the constructor is
+ * doing so with a full understanding that it's intended to be a private API.
+ *
+ * @access private
+ */
+ const CONSTRUCTOR_UNLOCK_CODE = 'Use WP_HTML_Processor::create_fragment() instead of calling the class constructor directly.';
+}
diff --git a/lib/compat/wordpress-6.5/html-api/class-gutenberg-html-processor-state-6-5.php b/lib/compat/wordpress-6.5/html-api/class-gutenberg-html-processor-state-6-5.php
new file mode 100644
index 00000000000000..890e19fb5da4d1
--- /dev/null
+++ b/lib/compat/wordpress-6.5/html-api/class-gutenberg-html-processor-state-6-5.php
@@ -0,0 +1,143 @@
+ The frameset-ok flag is set to "ok" when the parser is created. It is set to "not ok" after certain tokens are seen.
+ *
+ * @since 6.4.0
+ *
+ * @see https://html.spec.whatwg.org/#frameset-ok-flag
+ *
+ * @var bool
+ */
+ public $frameset_ok = true;
+
+ /**
+ * Constructor - creates a new and empty state value.
+ *
+ * @since 6.4.0
+ *
+ * @see WP_HTML_Processor
+ */
+ public function __construct() {
+ $this->stack_of_open_elements = new Gutenberg_HTML_Open_Elements_6_5();
+ $this->active_formatting_elements = new WP_HTML_Active_Formatting_Elements();
+ }
+}
diff --git a/lib/compat/wordpress-6.5/html-api/class-gutenberg-html-tag-processor-6-5.php b/lib/compat/wordpress-6.5/html-api/class-gutenberg-html-tag-processor-6-5.php
index f14bc15adf9999..de3823d2b2703b 100644
--- a/lib/compat/wordpress-6.5/html-api/class-gutenberg-html-tag-processor-6-5.php
+++ b/lib/compat/wordpress-6.5/html-api/class-gutenberg-html-tag-processor-6-5.php
@@ -15,9 +15,6 @@
* - Prune the whitespace when removing classes/attributes: e.g. "a b c" -> "c" not " c".
* This would increase the size of the changes for some operations but leave more
* natural-looking output HTML.
- * - Decode HTML character references within class names when matching. E.g. match having
- * class `1<"2` needs to recognize `class="1<"2"`. Currently the Tag Processor
- * will fail to find the right tag if the class name is encoded as such.
* - Properly decode HTML character references in `get_attribute()`. PHP's
* `html_entity_decode()` is wrong in a couple ways: it doesn't account for the
* no-ambiguous-ampersand rule, and it improperly handles the way semicolons may
@@ -107,6 +104,56 @@
* given, it will return `true` (the only way to set `false` for an
* attribute is to remove it).
*
+ * #### When matching fails
+ *
+ * When `next_tag()` returns `false` it could mean different things:
+ *
+ * - The requested tag wasn't found in the input document.
+ * - The input document ended in the middle of an HTML syntax element.
+ *
+ * When a document ends in the middle of a syntax element it will pause
+ * the processor. This is to make it possible in the future to extend the
+ * input document and proceed - an important requirement for chunked
+ * streaming parsing of a document.
+ *
+ * Example:
+ *
+ * $processor = new WP_HTML_Tag_Processor( 'This ` inside an HTML comment.
+ * - STYLE content is raw text.
+ * - TITLE content is plain text but character references are decoded.
+ * - TEXTAREA content is plain text but character references are decoded.
+ * - XMP (deprecated) content is raw text.
+ *
* ### Modifying HTML attributes for a found tag
*
* Once you've found the start of an opening tag you can modify
@@ -241,9 +288,39 @@
* double-quoted strings, meaning that attributes on input with single-quoted or
* unquoted values will appear in the output with double-quotes.
*
+ * ### Scripting Flag
+ *
+ * The Tag Processor parses HTML with the "scripting flag" disabled. This means
+ * that it doesn't run any scripts while parsing the page. In a browser with
+ * JavaScript enabled, for example, the script can change the parse of the
+ * document as it loads. On the server, however, evaluating JavaScript is not
+ * only impractical, but also unwanted.
+ *
+ * Practically this means that the Tag Processor will descend into NOSCRIPT
+ * elements and process its child tags. Were the scripting flag enabled, such
+ * as in a typical browser, the contents of NOSCRIPT are skipped entirely.
+ *
+ * This allows the HTML API to process the content that will be presented in
+ * a browser when scripting is disabled, but it offers a different view of a
+ * page than most browser sessions will experience. E.g. the tags inside the
+ * NOSCRIPT disappear.
+ *
+ * ### Text Encoding
+ *
+ * The Tag Processor assumes that the input HTML document is encoded with a
+ * text encoding compatible with 7-bit ASCII's '<', '>', '&', ';', '/', '=',
+ * "'", '"', 'a' - 'z', 'A' - 'Z', and the whitespace characters ' ', tab,
+ * carriage-return, newline, and form-feed.
+ *
+ * In practice, this includes almost every single-byte encoding as well as
+ * UTF-8. Notably, however, it does not include UTF-16. If providing input
+ * that's incompatible, then convert the encoding beforehand.
+ *
* @since 6.2.0
* @since 6.2.1 Fix: Support for various invalid comments; attribute updates are case-insensitive.
* @since 6.3.2 Fix: Skip HTML-like content inside rawtext elements such as STYLE.
+ * @since 6.5.0 Pauses processor when input ends in an incomplete syntax token.
+ * Introduces "special" elements which act like void elements, e.g. STYLE.
*/
class Gutenberg_HTML_Tag_Processor_6_5 {
/**
@@ -316,6 +393,27 @@ class Gutenberg_HTML_Tag_Processor_6_5 {
*/
private $stop_on_tag_closers;
+ /**
+ * Specifies mode of operation of the parser at any given time.
+ *
+ * | State | Meaning |
+ * | --------------|----------------------------------------------------------------------|
+ * | *Ready* | The parser is ready to run. |
+ * | *Complete* | There is nothing left to parse. |
+ * | *Incomplete* | The HTML ended in the middle of a token; nothing more can be parsed. |
+ * | *Matched tag* | Found an HTML tag; it's possible to modify its attributes. |
+ *
+ * @since 6.5.0
+ *
+ * @see WP_HTML_Tag_Processor::STATE_READY
+ * @see WP_HTML_Tag_Processor::STATE_COMPLETE
+ * @see WP_HTML_Tag_Processor::STATE_INCOMPLETE
+ * @see WP_HTML_Tag_Processor::STATE_MATCHED_TAG
+ *
+ * @var string
+ */
+ private $parser_state = self::STATE_READY;
+
/**
* How many bytes from the original HTML document have been read and parsed.
*
@@ -544,6 +642,7 @@ public function __construct( $html ) {
* Finds the next tag matching the $query.
*
* @since 6.2.0
+ * @since 6.5.0 No longer processes incomplete tokens at end of document; pauses the processor at start of token.
*
* @param array|string|null $query {
* Optional. Which tag name to find, having which class, etc. Default is to find any tag.
@@ -562,90 +661,177 @@ public function next_tag( $query = null ) {
$already_found = 0;
do {
- if ( $this->bytes_already_parsed >= strlen( $this->html ) ) {
- return false;
- }
-
- // Find the next tag if it exists.
- if ( false === $this->parse_next_tag() ) {
- $this->bytes_already_parsed = strlen( $this->html );
-
+ if ( false === $this->next_token() ) {
return false;
}
- // Parse all of its attributes.
- while ( $this->parse_next_attribute() ) {
+ if ( self::STATE_MATCHED_TAG !== $this->parser_state ) {
continue;
}
- // Ensure that the tag closes before the end of the document.
- if ( $this->bytes_already_parsed >= strlen( $this->html ) ) {
- return false;
+ if ( $this->matches() ) {
+ ++$already_found;
}
+ } while ( $already_found < $this->sought_match_offset );
- $tag_ends_at = strpos( $this->html, '>', $this->bytes_already_parsed );
- if ( false === $tag_ends_at ) {
- return false;
- }
- $this->token_length = $tag_ends_at - $this->token_starts_at;
- $this->bytes_already_parsed = $tag_ends_at;
+ return true;
+ }
- // Finally, check if the parsed tag and its attributes match the search query.
- if ( $this->matches() ) {
- ++$already_found;
+ /**
+ * Finds the next token in the HTML document.
+ *
+ * An HTML document can be viewed as a stream of tokens,
+ * where tokens are things like HTML tags, HTML comments,
+ * text nodes, etc. This method finds the next token in
+ * the HTML document and returns whether it found one.
+ *
+ * If it starts parsing a token and reaches the end of the
+ * document then it will seek to the start of the last
+ * token and pause, returning `false` to indicate that it
+ * failed to find a complete token.
+ *
+ * Possible token types, based on the HTML specification:
+ *
+ * - an HTML tag, whether opening, closing, or void.
+ * - a text node - the plaintext inside tags.
+ * - an HTML comment.
+ * - a DOCTYPE declaration.
+ * - a processing instruction, e.g. ``.
+ *
+ * The Tag Processor currently only supports the tag token.
+ *
+ * @since 6.5.0
+ *
+ * @return bool Whether a token was parsed.
+ */
+ public function next_token() {
+ $this->get_updated_html();
+ $was_at = $this->bytes_already_parsed;
+
+ // Don't proceed if there's nothing more to scan.
+ if (
+ self::STATE_COMPLETE === $this->parser_state ||
+ self::STATE_INCOMPLETE === $this->parser_state
+ ) {
+ return false;
+ }
+
+ /*
+ * The next step in the parsing loop determines the parsing state;
+ * clear it so that state doesn't linger from the previous step.
+ */
+ $this->parser_state = self::STATE_READY;
+
+ if ( $this->bytes_already_parsed >= strlen( $this->html ) ) {
+ $this->parser_state = self::STATE_COMPLETE;
+ return false;
+ }
+
+ // Find the next tag if it exists.
+ if ( false === $this->parse_next_tag() ) {
+ if ( self::STATE_INCOMPLETE === $this->parser_state ) {
+ $this->bytes_already_parsed = $was_at;
}
- /*
- * For non-DATA sections which might contain text that looks like HTML tags but
- * isn't, scan with the appropriate alternative mode. Looking at the first letter
- * of the tag name as a pre-check avoids a string allocation when it's not needed.
- */
- $t = $this->html[ $this->tag_name_starts_at ];
- if (
- ! $this->is_closing_tag &&
+ return false;
+ }
+
+ // Parse all of its attributes.
+ while ( $this->parse_next_attribute() ) {
+ continue;
+ }
+
+ // Ensure that the tag closes before the end of the document.
+ if (
+ self::STATE_INCOMPLETE === $this->parser_state ||
+ $this->bytes_already_parsed >= strlen( $this->html )
+ ) {
+ // Does this appropriately clear state (parsed attributes)?
+ $this->parser_state = self::STATE_INCOMPLETE;
+ $this->bytes_already_parsed = $was_at;
+
+ return false;
+ }
+
+ $tag_ends_at = strpos( $this->html, '>', $this->bytes_already_parsed );
+ if ( false === $tag_ends_at ) {
+ $this->parser_state = self::STATE_INCOMPLETE;
+ $this->bytes_already_parsed = $was_at;
+
+ return false;
+ }
+ $this->parser_state = self::STATE_MATCHED_TAG;
+ $this->token_length = $tag_ends_at - $this->token_starts_at;
+ $this->bytes_already_parsed = $tag_ends_at;
+
+ /*
+ * For non-DATA sections which might contain text that looks like HTML tags but
+ * isn't, scan with the appropriate alternative mode. Looking at the first letter
+ * of the tag name as a pre-check avoids a string allocation when it's not needed.
+ */
+ $t = $this->html[ $this->tag_name_starts_at ];
+ if (
+ ! $this->is_closing_tag &&
+ (
+ 'i' === $t || 'I' === $t ||
+ 'n' === $t || 'N' === $t ||
+ 's' === $t || 'S' === $t ||
+ 't' === $t || 'T' === $t ||
+ 'x' === $t || 'X' === $t
+ )
+ ) {
+ $tag_name = $this->get_tag();
+
+ if ( 'SCRIPT' === $tag_name && ! $this->skip_script_data() ) {
+ $this->parser_state = self::STATE_INCOMPLETE;
+ $this->bytes_already_parsed = $was_at;
+
+ return false;
+ } elseif (
+ ( 'TEXTAREA' === $tag_name || 'TITLE' === $tag_name ) &&
+ ! $this->skip_rcdata( $tag_name )
+ ) {
+ $this->parser_state = self::STATE_INCOMPLETE;
+ $this->bytes_already_parsed = $was_at;
+
+ return false;
+ } elseif (
(
- 'i' === $t || 'I' === $t ||
- 'n' === $t || 'N' === $t ||
- 's' === $t || 'S' === $t ||
- 't' === $t || 'T' === $t
- ) ) {
- $tag_name = $this->get_tag();
-
- if ( 'SCRIPT' === $tag_name && ! $this->skip_script_data() ) {
- $this->bytes_already_parsed = strlen( $this->html );
- return false;
- } elseif (
- ( 'TEXTAREA' === $tag_name || 'TITLE' === $tag_name ) &&
- ! $this->skip_rcdata( $tag_name )
- ) {
- $this->bytes_already_parsed = strlen( $this->html );
- return false;
- } elseif (
- (
- 'IFRAME' === $tag_name ||
- 'NOEMBED' === $tag_name ||
- 'NOFRAMES' === $tag_name ||
- 'NOSCRIPT' === $tag_name ||
- 'STYLE' === $tag_name
- ) &&
- ! $this->skip_rawtext( $tag_name )
- ) {
- /*
- * "XMP" should be here too but its rules are more complicated and require the
- * complexity of the HTML Processor (it needs to close out any open P element,
- * meaning it can't be skipped here or else the HTML Processor will lose its
- * place). For now, it can be ignored as it's a rare HTML tag in practice and
- * any normative HTML should be using PRE instead.
- */
- $this->bytes_already_parsed = strlen( $this->html );
- return false;
- }
+ 'IFRAME' === $tag_name ||
+ 'NOEMBED' === $tag_name ||
+ 'NOFRAMES' === $tag_name ||
+ 'STYLE' === $tag_name ||
+ 'XMP' === $tag_name
+ ) &&
+ ! $this->skip_rawtext( $tag_name )
+ ) {
+ $this->parser_state = self::STATE_INCOMPLETE;
+ $this->bytes_already_parsed = $was_at;
+
+ return false;
}
- } while ( $already_found < $this->sought_match_offset );
+ }
return true;
}
+ /**
+ * Whether the processor paused because the input HTML document ended
+ * in the middle of a syntax element, such as in the middle of a tag.
+ *
+ * Example:
+ *
+ * $processor = new WP_HTML_Tag_Processor( '
' === $html[ $at + 1 ] ) {
@@ -1276,6 +1502,8 @@ private function parse_next_tag() {
if ( '?' === $html[ $at + 1 ] ) {
$closer_at = strpos( $html, '>', $at + 2 );
if ( false === $closer_at ) {
+ $this->parser_state = self::STATE_INCOMPLETE;
+
return false;
}
@@ -1290,8 +1518,15 @@ private function parse_next_tag() {
* See https://html.spec.whatwg.org/#parse-error-invalid-first-character-of-tag-name
*/
if ( $this->is_closing_tag ) {
+ // No chance of finding a closer.
+ if ( $at + 3 > $doc_length ) {
+ return false;
+ }
+
$closer_at = strpos( $html, '>', $at + 3 );
if ( false === $closer_at ) {
+ $this->parser_state = self::STATE_INCOMPLETE;
+
return false;
}
@@ -1316,6 +1551,8 @@ private function parse_next_attribute() {
// Skip whitespace and slashes.
$this->bytes_already_parsed += strspn( $this->html, " \t\f\r\n/", $this->bytes_already_parsed );
if ( $this->bytes_already_parsed >= strlen( $this->html ) ) {
+ $this->parser_state = self::STATE_INCOMPLETE;
+
return false;
}
@@ -1338,11 +1575,15 @@ private function parse_next_attribute() {
$attribute_name = substr( $this->html, $attribute_start, $name_length );
$this->bytes_already_parsed += $name_length;
if ( $this->bytes_already_parsed >= strlen( $this->html ) ) {
+ $this->parser_state = self::STATE_INCOMPLETE;
+
return false;
}
$this->skip_whitespace();
if ( $this->bytes_already_parsed >= strlen( $this->html ) ) {
+ $this->parser_state = self::STATE_INCOMPLETE;
+
return false;
}
@@ -1351,6 +1592,8 @@ private function parse_next_attribute() {
++$this->bytes_already_parsed;
$this->skip_whitespace();
if ( $this->bytes_already_parsed >= strlen( $this->html ) ) {
+ $this->parser_state = self::STATE_INCOMPLETE;
+
return false;
}
@@ -1377,6 +1620,8 @@ private function parse_next_attribute() {
}
if ( $attribute_end >= strlen( $this->html ) ) {
+ $this->parser_state = self::STATE_INCOMPLETE;
+
return false;
}
@@ -1443,7 +1688,6 @@ private function skip_whitespace() {
* @since 6.2.0
*/
private function after_tag() {
- $this->get_updated_html();
$this->token_starts_at = null;
$this->token_length = null;
$this->tag_name_starts_at = null;
@@ -1786,6 +2030,10 @@ private static function sort_start_ascending( $a, $b ) {
* @return string|boolean|null Value of enqueued update if present, otherwise false.
*/
private function get_enqueued_attribute_value( $comparable_name ) {
+ if ( self::STATE_MATCHED_TAG !== $this->parser_state ) {
+ return false;
+ }
+
if ( ! isset( $this->lexical_updates[ $comparable_name ] ) ) {
return false;
}
@@ -1853,7 +2101,7 @@ private function get_enqueued_attribute_value( $comparable_name ) {
* @return string|true|null Value of attribute or `null` if not available. Boolean attributes return `true`.
*/
public function get_attribute( $name ) {
- if ( null === $this->tag_name_starts_at ) {
+ if ( self::STATE_MATCHED_TAG !== $this->parser_state ) {
return null;
}
@@ -1933,7 +2181,10 @@ public function get_attribute( $name ) {
* @return array|null List of attribute names, or `null` when no tag opener is matched.
*/
public function get_attribute_names_with_prefix( $prefix ) {
- if ( $this->is_closing_tag || null === $this->tag_name_starts_at ) {
+ if (
+ self::STATE_MATCHED_TAG !== $this->parser_state ||
+ $this->is_closing_tag
+ ) {
return null;
}
@@ -1965,7 +2216,7 @@ public function get_attribute_names_with_prefix( $prefix ) {
* @return string|null Name of currently matched tag in input HTML, or `null` if none found.
*/
public function get_tag() {
- if ( null === $this->tag_name_starts_at ) {
+ if ( self::STATE_MATCHED_TAG !== $this->parser_state ) {
return null;
}
@@ -1992,7 +2243,7 @@ public function get_tag() {
* @return bool Whether the currently matched tag contains the self-closing flag.
*/
public function has_self_closing_flag() {
- if ( ! $this->tag_name_starts_at ) {
+ if ( self::STATE_MATCHED_TAG !== $this->parser_state ) {
return false;
}
@@ -2024,7 +2275,10 @@ public function has_self_closing_flag() {
* @return bool Whether the current tag is a tag closer.
*/
public function is_tag_closer() {
- return $this->is_closing_tag;
+ return (
+ self::STATE_MATCHED_TAG === $this->parser_state &&
+ $this->is_closing_tag
+ );
}
/**
@@ -2044,7 +2298,10 @@ public function is_tag_closer() {
* @return bool Whether an attribute value was set.
*/
public function set_attribute( $name, $value ) {
- if ( $this->is_closing_tag || null === $this->tag_name_starts_at ) {
+ if (
+ self::STATE_MATCHED_TAG !== $this->parser_state ||
+ $this->is_closing_tag
+ ) {
return false;
}
@@ -2177,7 +2434,10 @@ public function set_attribute( $name, $value ) {
* @return bool Whether an attribute was removed.
*/
public function remove_attribute( $name ) {
- if ( $this->is_closing_tag ) {
+ if (
+ self::STATE_MATCHED_TAG !== $this->parser_state ||
+ $this->is_closing_tag
+ ) {
return false;
}
@@ -2254,13 +2514,14 @@ public function remove_attribute( $name ) {
* @return bool Whether the class was set to be added.
*/
public function add_class( $class_name ) {
- if ( $this->is_closing_tag ) {
+ if (
+ self::STATE_MATCHED_TAG !== $this->parser_state ||
+ $this->is_closing_tag
+ ) {
return false;
}
- if ( null !== $this->tag_name_starts_at ) {
- $this->classname_updates[ $class_name ] = self::ADD_CLASS;
- }
+ $this->classname_updates[ $class_name ] = self::ADD_CLASS;
return true;
}
@@ -2274,7 +2535,10 @@ public function add_class( $class_name ) {
* @return bool Whether the class was set to be removed.
*/
public function remove_class( $class_name ) {
- if ( $this->is_closing_tag ) {
+ if (
+ self::STATE_MATCHED_TAG !== $this->parser_state ||
+ $this->is_closing_tag
+ ) {
return false;
}
@@ -2480,4 +2744,57 @@ private function matches() {
return true;
}
+
+ /**
+ * Parser Ready State
+ *
+ * Indicates that the parser is ready to run and waiting for a state transition.
+ * It may not have started yet, or it may have just finished parsing a token and
+ * is ready to find the next one.
+ *
+ * @since 6.5.0
+ *
+ * @access private
+ */
+ const STATE_READY = 'STATE_READY';
+
+ /**
+ * Parser Complete State
+ *
+ * Indicates that the parser has reached the end of the document and there is
+ * nothing left to scan. It finished parsing the last token completely.
+ *
+ * @since 6.5.0
+ *
+ * @access private
+ */
+ const STATE_COMPLETE = 'STATE_COMPLETE';
+
+ /**
+ * Parser Incomplete State
+ *
+ * Indicates that the parser has reached the end of the document before finishing
+ * a token. It started parsing a token but there is a possibility that the input
+ * HTML document was truncated in the middle of a token.
+ *
+ * The parser is reset at the start of the incomplete token and has paused. There
+ * is nothing more than can be scanned unless provided a more complete document.
+ *
+ * @since 6.5.0
+ *
+ * @access private
+ */
+ const STATE_INCOMPLETE = 'STATE_INCOMPLETE';
+
+ /**
+ * Parser Matched Tag State
+ *
+ * Indicates that the parser has found an HTML tag and it's possible to get
+ * the tag name and read or modify its attributes (if it's not a closing tag).
+ *
+ * @since 6.5.0
+ *
+ * @access private
+ */
+ const STATE_MATCHED_TAG = 'STATE_MATCHED_TAG';
}
diff --git a/lib/compat/wordpress-6.5/interactivity-api/class-wp-interactivity-api-directives-processor.php b/lib/compat/wordpress-6.5/interactivity-api/class-wp-interactivity-api-directives-processor.php
new file mode 100644
index 00000000000000..b437bcefa67568
--- /dev/null
+++ b/lib/compat/wordpress-6.5/interactivity-api/class-wp-interactivity-api-directives-processor.php
@@ -0,0 +1,143 @@
+get_balanced_tag_bookmarks();
+ if ( ! $bookmarks ) {
+ return null;
+ }
+ list( $start_name, $end_name ) = $bookmarks;
+
+ $start = $this->bookmarks[ $start_name ]->start + $this->bookmarks[ $start_name ]->length + 1;
+ $end = $this->bookmarks[ $end_name ]->start;
+
+ $this->seek( $start_name );
+ $this->release_bookmark( $start_name );
+ $this->release_bookmark( $end_name );
+
+ return substr( $this->html, $start, $end - $start );
+ }
+
+ /**
+ * Sets the content between two balanced tags.
+ *
+ * @access private
+ *
+ * @param string $new_content The string to replace the content between the matching tags.
+ * @return bool Whether the content was successfully replaced.
+ */
+ public function set_content_between_balanced_tags( string $new_content ): bool {
+ $this->get_updated_html();
+
+ $bookmarks = $this->get_balanced_tag_bookmarks();
+ if ( ! $bookmarks ) {
+ return false;
+ }
+ list( $start_name, $end_name ) = $bookmarks;
+
+ $start = $this->bookmarks[ $start_name ]->start + $this->bookmarks[ $start_name ]->length + 1;
+ $end = $this->bookmarks[ $end_name ]->start;
+
+ $this->seek( $start_name );
+ $this->release_bookmark( $start_name );
+ $this->release_bookmark( $end_name );
+
+ $this->lexical_updates[] = new Gutenberg_HTML_Text_Replacement_6_5( $start, $end - $start, esc_html( $new_content ) );
+ return true;
+ }
+
+ /**
+ * Returns a pair of bookmarks for the current opening tag and the matching
+ * closing tag.
+ *
+ * @return array|null A pair of bookmarks, or null if there's no matching closing tag.
+ */
+ private function get_balanced_tag_bookmarks() {
+ static $i = 0;
+ $start_name = 'start_of_balanced_tag_' . ++$i;
+
+ $this->set_bookmark( $start_name );
+ if ( ! $this->next_balanced_closer() ) {
+ $this->release_bookmark( $start_name );
+ return null;
+ }
+
+ $end_name = 'end_of_balanced_tag_' . ++$i;
+ $this->set_bookmark( $end_name );
+
+ return array( $start_name, $end_name );
+ }
+
+ /**
+ * Finds the matching closing tag for an opening tag.
+ *
+ * When called while the processor is on an open tag, it traverses the HTML
+ * until it finds the matching closing tag, respecting any in-between content,
+ * including nested tags of the same name. Returns false when called on a
+ * closing or void tag, or if no matching closing tag was found.
+ *
+ * @return bool Whether a matching closing tag was found.
+ */
+ private function next_balanced_closer(): bool {
+ $depth = 0;
+ $tag_name = $this->get_tag();
+
+ if ( $this->is_void() ) {
+ return false;
+ }
+
+ while ( $this->next_tag(
+ array(
+ 'tag_name' => $tag_name,
+ 'tag_closers' => 'visit',
+ )
+ ) ) {
+ if ( ! $this->is_tag_closer() ) {
+ ++$depth;
+ continue;
+ }
+
+ if ( 0 === $depth ) {
+ return true;
+ }
+
+ --$depth;
+ }
+
+ return false;
+ }
+
+ /**
+ * Checks whether the current tag is void.
+ *
+ * @access private
+ *
+ * @return bool Whether the current tag is void or not.
+ */
+ public function is_void(): bool {
+ $tag_name = $this->get_tag();
+ return Gutenberg_HTML_Processor_6_5::is_void( null !== $tag_name ? $tag_name : '' );
+ }
+ }
+}
diff --git a/lib/compat/wordpress-6.5/interactivity-api/class-wp-interactivity-api.php b/lib/compat/wordpress-6.5/interactivity-api/class-wp-interactivity-api.php
new file mode 100644
index 00000000000000..ad9e5d7c439533
--- /dev/null
+++ b/lib/compat/wordpress-6.5/interactivity-api/class-wp-interactivity-api.php
@@ -0,0 +1,678 @@
+ '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',
+ );
+
+ /**
+ * Holds the initial state of the different Interactivity API stores.
+ *
+ * This state is used during the server directive processing. Then, it is
+ * serialized and sent to the client as part of the interactivity data to be
+ * recovered during the hydration of the client interactivity stores.
+ *
+ * @since 6.5.0
+ * @var array
+ */
+ private $state_data = array();
+
+ /**
+ * Holds the configuration required by the different Interactivity API stores.
+ *
+ * This configuration is serialized and sent to the client as part of the
+ * interactivity data and can be accessed by the client interactivity stores.
+ *
+ * @since 6.5.0
+ * @var array
+ */
+ private $config_data = array();
+
+ /**
+ * Gets and/or sets the initial state of an Interactivity API store for a
+ * given namespace.
+ *
+ * If state for that store namespace already exists, it merges the new
+ * provided state with the existing one.
+ *
+ * @since 6.5.0
+ *
+ * @param string $store_namespace The unique store namespace identifier.
+ * @param array $state Optional. The array that will be merged with the existing state for the specified
+ * store namespace.
+ * @return array The current state for the specified store namespace.
+ */
+ public function state( string $store_namespace, array $state = null ): array {
+ if ( ! isset( $this->state_data[ $store_namespace ] ) ) {
+ $this->state_data[ $store_namespace ] = array();
+ }
+ if ( is_array( $state ) ) {
+ $this->state_data[ $store_namespace ] = array_replace_recursive(
+ $this->state_data[ $store_namespace ],
+ $state
+ );
+ }
+ return $this->state_data[ $store_namespace ];
+ }
+
+ /**
+ * Gets and/or sets the configuration of the Interactivity API for a given
+ * store namespace.
+ *
+ * If configuration for that store namespace exists, it merges the new
+ * provided configuration with the existing one.
+ *
+ * @since 6.5.0
+ *
+ * @param string $store_namespace The unique store namespace identifier.
+ * @param array $config Optional. The array that will be merged with the existing configuration for the
+ * specified store namespace.
+ * @return array The current configuration for the specified store namespace.
+ */
+ public function config( string $store_namespace, array $config = null ): array {
+ if ( ! isset( $this->config_data[ $store_namespace ] ) ) {
+ $this->config_data[ $store_namespace ] = array();
+ }
+ if ( is_array( $config ) ) {
+ $this->config_data[ $store_namespace ] = array_replace_recursive(
+ $this->config_data[ $store_namespace ],
+ $config
+ );
+ }
+ return $this->config_data[ $store_namespace ];
+ }
+
+ /**
+ * Prints the serialized client-side interactivity data.
+ *
+ * Encodes the config and initial state into JSON and prints them inside a
+ * script tag of type "application/json". Once in the browser, the state will
+ * be parsed and used to hydrate the client-side interactivity stores and the
+ * configuration will be available using a `getConfig` utility.
+ *
+ * @since 6.5.0
+ */
+ public function print_client_interactivity_data() {
+ $store = array();
+ $has_state = ! empty( $this->state_data );
+ $has_config = ! empty( $this->config_data );
+
+ if ( $has_state || $has_config ) {
+ if ( $has_config ) {
+ $store['config'] = $this->config_data;
+ }
+ if ( $has_state ) {
+ $store['state'] = $this->state_data;
+ }
+ wp_print_inline_script_tag(
+ wp_json_encode(
+ $store,
+ JSON_HEX_TAG | JSON_HEX_AMP
+ ),
+ array(
+ 'type' => 'application/json',
+ 'id' => 'wp-interactivity-data',
+ )
+ );
+ }
+ }
+
+ /**
+ * Registers the `@wordpress/interactivity` script modules.
+ *
+ * @since 6.5.0
+ */
+ public function register_script_modules() {
+ wp_register_script_module(
+ '@wordpress/interactivity',
+ gutenberg_url( '/build/interactivity/index.min.js' ),
+ array(),
+ defined( 'GUTENBERG_VERSION' ) ? GUTENBERG_VERSION : get_bloginfo( 'version' )
+ );
+
+ wp_register_script_module(
+ '@wordpress/interactivity-router',
+ gutenberg_url( '/build/interactivity/router.min.js' ),
+ array( '@wordpress/interactivity' ),
+ defined( 'GUTENBERG_VERSION' ) ? GUTENBERG_VERSION : get_bloginfo( 'version' )
+ );
+ }
+
+ /**
+ * Adds the necessary hooks for the Interactivity API.
+ *
+ * @since 6.5.0
+ */
+ public function add_hooks() {
+ add_action( 'wp_footer', array( $this, 'print_client_interactivity_data' ) );
+ }
+
+ /**
+ * Processes the interactivity directives contained within the HTML content
+ * and updates the markup accordingly.
+ *
+ * @since 6.5.0
+ *
+ * @param string $html The HTML content to process.
+ * @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;
+
+ $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 ) {
+ $tag_name = $p->get_tag();
+
+ if ( $p->is_tag_closer() ) {
+ list( $opening_tag_name, $directives_prefixes ) = end( $tag_stack );
+
+ if ( 0 === count( $tag_stack ) || $opening_tag_name !== $tag_name ) {
+
+ /*
+ * If the tag stack is empty or the matching opening tag is not the
+ * same than the closing tag, it means the HTML is unbalanced and it
+ * stops processing it.
+ */
+ $unbalanced = true;
+ continue;
+ } else {
+
+ /*
+ * It removes the last tag from the stack.
+ */
+ array_pop( $tag_stack );
+
+ /*
+ * If the matching opening tag didn't have any directives, it can skip
+ * the processing.
+ */
+ if ( 0 === count( $directives_prefixes ) ) {
+ continue;
+ }
+ }
+ } 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.
+ */
+ list( $directive_prefix ) = $this->extract_prefix_and_suffix( $attribute_name );
+ if ( array_key_exists( $directive_prefix, self::$directive_processors ) ) {
+ $directives_prefixes[] = $directive_prefix;
+ }
+ }
+
+ /*
+ * If this is not a void element, it adds it to the tag stack so it can
+ * process its closing tag and check for unbalanced tags.
+ */
+ if ( ! $p->is_void() ) {
+ $tag_stack[] = array( $tag_name, $directives_prefixes );
+ }
+ }
+
+ /*
+ * Sorts the attributes by the order of the `directives_processor` array
+ * and checks what directives are present in this element. The processing
+ * order is reversed for tag closers.
+ */
+ $directives_prefixes = array_intersect(
+ $p->is_tag_closer()
+ ? $directive_processor_prefixes_reversed
+ : $directive_processor_prefixes,
+ $directives_prefixes
+ );
+
+ // Executes the directive processors present in this element.
+ foreach ( $directives_prefixes as $directive_prefix ) {
+ $func = is_array( self::$directive_processors[ $directive_prefix ] )
+ ? self::$directive_processors[ $directive_prefix ]
+ : array( $this, self::$directive_processors[ $directive_prefix ] );
+ call_user_func_array(
+ $func,
+ array( $p, &$context_stack, &$namespace_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.
+ */
+ return $unbalanced || 0 < count( $tag_stack ) ? $html : $p->get_updated_html();
+ }
+
+ /**
+ * Evaluates the reference path passed to a directive based on the current
+ * store namespace, state and context.
+ *
+ * @since 6.5.0
+ *
+ * @param string|true $directive_value The directive attribute value string or `true` when it's a boolean attribute.
+ * @param string $default_namespace The default namespace to use if none is explicitly defined in the directive
+ * value.
+ * @param array|false $context The current context for evaluating the directive or false if there is no
+ * context.
+ * @return mixed|null The result of the evaluation. Null if the reference path doesn't exist.
+ */
+ private function evaluate( $directive_value, string $default_namespace, $context = false ) {
+ list( $ns, $path ) = $this->extract_directive_value( $directive_value, $default_namespace );
+ if ( empty( $path ) ) {
+ return null;
+ }
+
+ $store = array(
+ 'state' => isset( $this->state_data[ $ns ] ) ? $this->state_data[ $ns ] : array(),
+ 'context' => isset( $context[ $ns ] ) ? $context[ $ns ] : array(),
+ );
+
+ // Checks if the reference path is preceded by a negator operator (!).
+ $should_negate_value = '!' === $path[0];
+ $path = $should_negate_value ? substr( $path, 1 ) : $path;
+
+ // Extracts the value from the store using the reference path.
+ $path_segments = explode( '.', $path );
+ $current = $store;
+ foreach ( $path_segments as $path_segment ) {
+ if ( isset( $current[ $path_segment ] ) ) {
+ $current = $current[ $path_segment ];
+ } else {
+ return null;
+ }
+ }
+
+ // Returns the opposite if it contains a negator operator (!).
+ return $should_negate_value ? ! $current : $current;
+ }
+
+ /**
+ * Extracts the directive attribute name to separate and return the directive
+ * prefix and an optional suffix.
+ *
+ * The suffix is the string after the first double hyphen and the prefix is
+ * everything that comes before the suffix.
+ *
+ * Example:
+ *
+ * extract_prefix_and_suffix( 'data-wp-interactive' ) => array( 'data-wp-interactive', null )
+ * extract_prefix_and_suffix( 'data-wp-bind--src' ) => array( 'data-wp-bind', 'src' )
+ * extract_prefix_and_suffix( 'data-wp-foo--and--bar' ) => array( 'data-wp-foo', 'and--bar' )
+ *
+ * @since 6.5.0
+ *
+ * @param string $directive_name The directive attribute name.
+ * @return array An array containing the directive prefix and optional suffix.
+ */
+ private function extract_prefix_and_suffix( string $directive_name ): array {
+ return explode( '--', $directive_name, 2 );
+ }
+
+ /**
+ * Parses and extracts the namespace and reference path from the given
+ * directive attribute value.
+ *
+ * If the value doesn't contain an explicit namespace, it returns the
+ * default one. If the value contains a JSON object instead of a reference
+ * path, the function tries to parse it and return the resulting array. If
+ * the value contains strings that reprenset booleans ("true" and "false"),
+ * numbers ("1" and "1.2") or "null", the function also transform them to
+ * regular booleans, numbers and `null`.
+ *
+ * Example:
+ *
+ * extract_directive_value( 'actions.foo', 'myPlugin' ) => array( 'myPlugin', 'actions.foo' )
+ * extract_directive_value( 'otherPlugin::actions.foo', 'myPlugin' ) => array( 'otherPlugin', 'actions.foo' )
+ * extract_directive_value( '{ "isOpen": false }', 'myPlugin' ) => array( 'myPlugin', array( 'isOpen' => false ) )
+ * extract_directive_value( 'otherPlugin::{ "isOpen": false }', 'myPlugin' ) => array( 'otherPlugin', array( 'isOpen' => false ) )
+ *
+ * @since 6.5.0
+ *
+ * @param string|true $directive_value The directive attribute value. It can be `true` when it's a boolean
+ * attribute.
+ * @param string|null $default_namespace Optional. The default namespace if none is explicitly defined.
+ * @return array An array containing the namespace in the first item and the JSON, the reference path, or null on the
+ * second item.
+ */
+ private function extract_directive_value( $directive_value, $default_namespace = null ): array {
+ if ( empty( $directive_value ) || is_bool( $directive_value ) ) {
+ return array( $default_namespace, null );
+ }
+
+ // Replaces the value and namespace if there is a namespace in the value.
+ if ( 1 === preg_match( '/^([\w\-_\/]+)::./', $directive_value ) ) {
+ list($default_namespace, $directive_value) = explode( '::', $directive_value, 2 );
+ }
+
+ /*
+ * Tries to decode the value as a JSON object. If it fails and the value
+ * isn't `null`, it returns the value as it is. Otherwise, it returns the
+ * decoded JSON or null for the string `null`.
+ */
+ $decoded_json = json_decode( $directive_value, true );
+ if ( null !== $decoded_json || 'null' === $directive_value ) {
+ $directive_value = $decoded_json;
+ }
+
+ return array( $default_namespace, $directive_value );
+ }
+
+
+ /**
+ * Processes the `data-wp-interactive` directive.
+ *
+ * It adds the default store namespace defined in the directive value to the
+ * stack so it's available for the nested interactivity elements.
+ *
+ * @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.
+ */
+ private function data_wp_interactive_processor( WP_Interactivity_API_Directives_Processor $p, array &$context_stack, array &$namespace_stack ) {
+ // In closing tags, it removes the last namespace from the stack.
+ if ( $p->is_tag_closer() ) {
+ return array_pop( $namespace_stack );
+ }
+
+ // Tries to decode the `data-wp-interactive` attribute value.
+ $attribute_value = $p->get_attribute( 'data-wp-interactive' );
+ $decoded_json = is_string( $attribute_value ) && ! empty( $attribute_value )
+ ? json_decode( $attribute_value, true )
+ : null;
+
+ /*
+ * Pushes the newly defined namespace or the current one if the
+ * `data-wp-interactive` definition was invalid or does not contain a
+ * namespace. It does so because the function pops out the current namespace
+ * from the stack whenever it finds a `data-wp-interactive`'s closing tag,
+ * independently of whether the previous `data-wp-interactive` definition
+ * contained a valid namespace.
+ */
+ $namespace_stack[] = isset( $decoded_json['namespace'] )
+ ? $decoded_json['namespace']
+ : end( $namespace_stack );
+ }
+
+ /**
+ * Processes the `data-wp-context` directive.
+ *
+ * It adds the context defined in the directive value to the stack so it's
+ * available for the nested interactivity elements.
+ *
+ * @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.
+ */
+ private function data_wp_context_processor( WP_Interactivity_API_Directives_Processor $p, array &$context_stack, array &$namespace_stack ) {
+ // In closing tags, it removes the last context from the stack.
+ if ( $p->is_tag_closer() ) {
+ return array_pop( $context_stack );
+ }
+
+ $attribute_value = $p->get_attribute( 'data-wp-context' );
+ $namespace_value = end( $namespace_stack );
+
+ // Separates the namespace from the context JSON object.
+ list( $namespace_value, $decoded_json ) = is_string( $attribute_value ) && ! empty( $attribute_value )
+ ? $this->extract_directive_value( $attribute_value, $namespace_value )
+ : array( $namespace_value, null );
+
+ /*
+ * If there is a namespace, it adds a new context to the stack merging the
+ * previous context with the new one.
+ */
+ if ( is_string( $namespace_value ) ) {
+ array_push(
+ $context_stack,
+ array_replace_recursive(
+ end( $context_stack ) !== false ? end( $context_stack ) : array(),
+ array( $namespace_value => is_array( $decoded_json ) ? $decoded_json : array() )
+ )
+ );
+ } else {
+ /*
+ * If there is no namespace, it pushes the current context to the stack.
+ * It needs to do so because the function pops out the current context
+ * from the stack whenever it finds a `data-wp-context`'s closing tag.
+ */
+ array_push( $context_stack, end( $context_stack ) );
+ }
+ }
+
+ /**
+ * Processes the `data-wp-bind` directive.
+ *
+ * It updates or removes the bound attributes based on the evaluation of its
+ * associated reference.
+ *
+ * @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.
+ */
+ private function data_wp_bind_processor( WP_Interactivity_API_Directives_Processor $p, array &$context_stack, array &$namespace_stack ) {
+ if ( ! $p->is_tag_closer() ) {
+ $all_bind_directives = $p->get_attribute_names_with_prefix( 'data-wp-bind--' );
+
+ foreach ( $all_bind_directives as $attribute_name ) {
+ list( , $bound_attribute ) = $this->extract_prefix_and_suffix( $attribute_name );
+ if ( empty( $bound_attribute ) ) {
+ return;
+ }
+
+ $attribute_value = $p->get_attribute( $attribute_name );
+ $result = $this->evaluate( $attribute_value, end( $namespace_stack ), end( $context_stack ) );
+
+ if ( null !== $result && ( false !== $result || '-' === $bound_attribute[4] ) ) {
+ /*
+ * If the result of the evaluation is a boolean and the attribute is
+ * `aria-` or `data-, convert it to a string "true" or "false". It
+ * follows the exact same logic as Preact because it needs to
+ * replicate what Preact will later do in the client:
+ * https://github.com/preactjs/preact/blob/ea49f7a0f9d1ff2c98c0bdd66aa0cbc583055246/src/diff/props.js#L131C24-L136
+ */
+ if ( is_bool( $result ) && '-' === $bound_attribute[4] ) {
+ $result = $result ? 'true' : 'false';
+ }
+ $p->set_attribute( $bound_attribute, $result );
+ } else {
+ $p->remove_attribute( $bound_attribute );
+ }
+ }
+ }
+ }
+
+
+ /**
+ * Processes the `data-wp-class` directive.
+ *
+ * It adds or removes CSS classes in the current HTML element based on the
+ * evaluation of its associated references.
+ *
+ * @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.
+ */
+ private function data_wp_class_processor( WP_Interactivity_API_Directives_Processor $p, array &$context_stack, array &$namespace_stack ) {
+ if ( ! $p->is_tag_closer() ) {
+ $all_class_directives = $p->get_attribute_names_with_prefix( 'data-wp-class--' );
+
+ foreach ( $all_class_directives as $attribute_name ) {
+ list( , $class_name ) = $this->extract_prefix_and_suffix( $attribute_name );
+ if ( empty( $class_name ) ) {
+ return;
+ }
+
+ $attribute_value = $p->get_attribute( $attribute_name );
+ $result = $this->evaluate( $attribute_value, end( $namespace_stack ), end( $context_stack ) );
+
+ if ( $result ) {
+ $p->add_class( $class_name );
+ } else {
+ $p->remove_class( $class_name );
+ }
+ }
+ }
+ }
+
+ /**
+ * Processes the `data-wp-style` directive.
+ *
+ * It updates the style attribute value of the current HTML element based on
+ * the evaluation of its associated references.
+ *
+ * @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.
+ */
+ private function data_wp_style_processor( WP_Interactivity_API_Directives_Processor $p, array &$context_stack, array &$namespace_stack ) {
+ if ( ! $p->is_tag_closer() ) {
+ $all_style_attributes = $p->get_attribute_names_with_prefix( 'data-wp-style--' );
+
+ foreach ( $all_style_attributes as $attribute_name ) {
+ list( , $style_property ) = $this->extract_prefix_and_suffix( $attribute_name );
+ if ( empty( $style_property ) ) {
+ continue;
+ }
+
+ $directive_attribute_value = $p->get_attribute( $attribute_name );
+ $style_property_value = $this->evaluate( $directive_attribute_value, end( $namespace_stack ), end( $context_stack ) );
+ $style_attribute_value = $p->get_attribute( 'style' );
+ $style_attribute_value = ( $style_attribute_value && ! is_bool( $style_attribute_value ) ) ? $style_attribute_value : '';
+
+ /*
+ * Checks first if the style property is not falsy and the style
+ * attribute value is not empty because if it is, it doesn't need to
+ * update the attribute value.
+ */
+ if ( $style_property_value || ( ! $style_property_value && $style_attribute_value ) ) {
+ $style_attribute_value = $this->set_style_property( $style_attribute_value, $style_property, $style_property_value );
+ /*
+ * If the style attribute value is not empty, it sets it. Otherwise,
+ * it removes it.
+ */
+ if ( ! empty( $style_attribute_value ) ) {
+ $p->set_attribute( 'style', $style_attribute_value );
+ } else {
+ $p->remove_attribute( 'style' );
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Sets an individual style property in the `style` attribute of an HTML
+ * element, updating or removing the property when necessary.
+ *
+ * If a property is modified, it is added at the end of the list to make sure
+ * that it overrides the previous ones.
+ *
+ * @since 6.5.0
+ *
+ * Example:
+ *
+ * set_style_property( 'color:green;', 'color', 'red' ) => 'color:red;'
+ * set_style_property( 'background:green;', 'color', 'red' ) => 'background:green;color:red;'
+ * set_style_property( 'color:green;', 'color', null ) => ''
+ *
+ * @param string $style_attribute_value The current style attribute value.
+ * @param string $style_property_name The style property name to set.
+ * @param string|false|null $style_property_value The value to set for the style property. With false, null or an
+ * empty string, it removes the style property.
+ * @return string The new style attribute value after the specified property has been added, updated or removed.
+ */
+ private function set_style_property( string $style_attribute_value, string $style_property_name, $style_property_value ): string {
+ $style_assignments = explode( ';', $style_attribute_value );
+ $result = array();
+ $style_property_value = ! empty( $style_property_value ) ? rtrim( trim( $style_property_value ), ';' ) : null;
+ $new_style_property = $style_property_value ? $style_property_name . ':' . $style_property_value . ';' : '';
+
+ // Generate an array with all the properties but the modified one.
+ foreach ( $style_assignments as $style_assignment ) {
+ if ( empty( trim( $style_assignment ) ) ) {
+ continue;
+ }
+ list( $name, $value ) = explode( ':', $style_assignment );
+ if ( trim( $name ) !== $style_property_name ) {
+ $result[] = trim( $name ) . ':' . trim( $value ) . ';';
+ }
+ }
+
+ // Add the new/modified property at the end of the list.
+ array_push( $result, $new_style_property );
+
+ return implode( '', $result );
+ }
+
+ /**
+ * Processes the `data-wp-text` directive.
+ *
+ * It updates the inner content of the current HTML element based on the
+ * evaluation of its associated reference.
+ *
+ * @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.
+ */
+ private function data_wp_text_processor( WP_Interactivity_API_Directives_Processor $p, array &$context_stack, array &$namespace_stack ) {
+ if ( ! $p->is_tag_closer() ) {
+ $attribute_value = $p->get_attribute( 'data-wp-text' );
+ $result = $this->evaluate( $attribute_value, end( $namespace_stack ), end( $context_stack ) );
+
+ /*
+ * Follows the same logic as Preact in the client and only changes the
+ * content if the value is a string or a number. Otherwise, it removes the
+ * content.
+ */
+ if ( is_string( $result ) || is_numeric( $result ) ) {
+ $p->set_content_between_balanced_tags( esc_html( $result ) );
+ } else {
+ $p->set_content_between_balanced_tags( '' );
+ }
+ }
+ }
+ }
+
+}
diff --git a/lib/compat/wordpress-6.5/interactivity-api/interactivity-api.php b/lib/compat/wordpress-6.5/interactivity-api/interactivity-api.php
new file mode 100644
index 00000000000000..cd7ca7fb902870
--- /dev/null
+++ b/lib/compat/wordpress-6.5/interactivity-api/interactivity-api.php
@@ -0,0 +1,144 @@
+get_registered( $block_name );
+
+ if ( isset( $block_name ) && isset( $block_type->supports['interactivity'] ) && $block_type->supports['interactivity'] ) {
+ // Annotates the root interactive block for processing.
+ $root_interactive_block = array( $block_name, md5( serialize( $parsed_block ) ) );
+
+ /*
+ * Adds a filter to process the root interactive block once it has
+ * finished rendering.
+ */
+ $process_interactive_blocks = static function ( $content, $parsed_block ) use ( &$root_interactive_block, &$process_interactive_blocks ) {
+ // Checks whether the current block is the root interactive block.
+ list($root_block_name, $root_block_md5) = $root_interactive_block;
+ if ( $root_block_name === $parsed_block['blockName'] && md5( serialize( $parsed_block ) ) === $root_block_md5 ) {
+ // The root interactive blocks has finished rendering, process it.
+ $content = wp_interactivity_process_directives( $content );
+ // Removes the filter and reset the root interactive block.
+ remove_filter( 'render_block', $process_interactive_blocks );
+ $root_interactive_block = null;
+ }
+ return $content;
+ };
+
+ /*
+ * Uses a priority of 20 to ensure that other filters can add additional
+ * directives before the processing starts.
+ */
+ add_filter( 'render_block', $process_interactive_blocks, 20, 2 );
+ }
+ }
+
+ return $parsed_block;
+ }
+ add_filter( 'render_block_data', 'wp_interactivity_process_directives_of_interactive_blocks', 10, 1 );
+}
+
+if ( ! function_exists( 'wp_interactivity' ) ) {
+ /**
+ * Retrieves the main WP_Interactivity_API instance.
+ *
+ * It provides access to the WP_Interactivity_API instance, creating one if it
+ * doesn't exist yet. It also registers the hooks and necessary script
+ * modules.
+ *
+ * @since 6.5.0
+ *
+ * @return WP_Interactivity_API The main WP_Interactivity_API instance.
+ */
+ function wp_interactivity() {
+ static $instance = null;
+ if ( is_null( $instance ) ) {
+ $instance = new WP_Interactivity_API();
+ $instance->add_hooks();
+ $instance->register_script_modules();
+ }
+ return $instance;
+ }
+}
+
+if ( ! function_exists( 'wp_interactivity_process_directives' ) ) {
+ /**
+ * Processes the interactivity directives contained within the HTML content
+ * and updates the markup accordingly.
+ *
+ * @since 6.5.0
+ *
+ * @param string $html The HTML content to process.
+ * @return string The processed HTML content. It returns the original content when the HTML contains unbalanced tags.
+ */
+ function wp_interactivity_process_directives( $html ) {
+ return wp_interactivity()->process_directives( $html );
+ }
+}
+
+if ( ! function_exists( 'wp_interactivity_state' ) ) {
+ /**
+ * Gets and/or sets the initial state of an Interactivity API store for a
+ * given namespace.
+ *
+ * If state for that store namespace already exists, it merges the new
+ * provided state with the existing one.
+ *
+ * @since 6.5.0
+ *
+ * @param string $store_namespace The unique store namespace identifier.
+ * @param array $state Optional. The array that will be merged with the existing state for the specified
+ * store namespace.
+ * @return array The current state for the specified store namespace.
+ */
+ function wp_interactivity_state( $store_namespace, $state = null ) {
+ return wp_interactivity()->state( $store_namespace, $state );
+ }
+}
+
+if ( ! function_exists( 'wp_interactivity_config' ) ) {
+ /**
+ * Gets and/or sets the configuration of the Interactivity API for a given
+ * store namespace.
+ *
+ * If configuration for that store namespace exists, it merges the new
+ * provided configuration with the existing one.
+ *
+ * @since 6.5.0
+ *
+ * @param string $store_namespace The unique store namespace identifier.
+ * @param array $config Optional. The array that will be merged with the existing configuration for the
+ * specified store namespace.
+ * @return array The current configuration for the specified store namespace.
+ */
+ function wp_interactivity_config( $store_namespace, $initial_state = null ) {
+ return wp_interactivity()->config( $store_namespace, $initial_state );
+ }
+}
diff --git a/lib/compat/wordpress-6.5/scripts-modules.php b/lib/compat/wordpress-6.5/scripts-modules.php
new file mode 100644
index 00000000000000..ba329b255b1965
--- /dev/null
+++ b/lib/compat/wordpress-6.5/scripts-modules.php
@@ -0,0 +1,119 @@
+add_hooks();
+ }
+ return $instance;
+ }
+}
+
+if ( ! function_exists( 'wp_register_script_module' ) ) {
+ /**
+ * Registers the script module if no script module with that script module
+ * identifier has already been registered.
+ *
+ * @since 6.5.0
+ *
+ * @param string $id The identifier of the script module. Should be unique. It will be used in the
+ * final import map.
+ * @param string $src Optional. Full URL of the script module, or path of the script module relative
+ * to the WordPress root directory. If it is provided and the script module has
+ * not been registered yet, it will be registered.
+ * @param array $deps {
+ * Optional. List of dependencies.
+ *
+ * @type string|array $0... {
+ * An array of script module identifiers of the dependencies of this script
+ * module. The dependencies can be strings or arrays. If they are arrays,
+ * they need an `id` key with the script module identifier, and can contain
+ * an `import` key with either `static` or `dynamic`. By default,
+ * dependencies that don't contain an `import` key are considered static.
+ *
+ * @type string $id The script module identifier.
+ * @type string $import Optional. Import type. May be either `static` or
+ * `dynamic`. Defaults to `static`.
+ * }
+ * }
+ * @param string|false|null $version Optional. String specifying the script module version number. Defaults to false.
+ * It is added to the URL as a query string for cache busting purposes. If $version
+ * is set to false, the version number is the currently installed WordPress version.
+ * If $version is set to null, no version is added.
+ */
+ function wp_register_script_module( string $id, string $src, array $deps = array(), $version = false ) {
+ wp_script_modules()->register( $id, $src, $deps, $version );
+ }
+}
+
+if ( ! function_exists( 'wp_enqueue_script_module' ) ) {
+ /**
+ * Marks the script module to be enqueued in the page.
+ *
+ * If a src is provided and the script module has not been registered yet, it
+ * will be registered.
+ *
+ * @since 6.5.0
+ *
+ * @param string $id The identifier of the script module. Should be unique. It will be used in the
+ * final import map.
+ * @param string $src Optional. Full URL of the script module, or path of the script module relative
+ * to the WordPress root directory. If it is provided and the script module has
+ * not been registered yet, it will be registered.
+ * @param array $deps {
+ * Optional. List of dependencies.
+ *
+ * @type string|array $0... {
+ * An array of script module identifiers of the dependencies of this script
+ * module. The dependencies can be strings or arrays. If they are arrays,
+ * they need an `id` key with the script module identifier, and can contain
+ * an `import` key with either `static` or `dynamic`. By default,
+ * dependencies that don't contain an `import` key are considered static.
+ *
+ * @type string $id The script module identifier.
+ * @type string $import Optional. Import type. May be either `static` or
+ * `dynamic`. Defaults to `static`.
+ * }
+ * }
+ * @param string|false|null $version Optional. String specifying the script module version number. Defaults to false.
+ * It is added to the URL as a query string for cache busting purposes. If $version
+ * is set to false, the version number is the currently installed WordPress version.
+ * If $version is set to null, no version is added.
+ */
+ function wp_enqueue_script_module( string $id, string $src = '', array $deps = array(), $version = false ) {
+ wp_script_modules()->enqueue( $id, $src, $deps, $version );
+ }
+}
+
+if ( ! function_exists( 'wp_dequeue_script_module' ) ) {
+ /**
+ * Unmarks the script module so it is no longer enqueued in the page.
+ *
+ * @since 6.5.0
+ *
+ * @param string $id The identifier of the script module.
+ */
+ function wp_dequeue_script_module( string $id ) {
+ wp_script_modules()->dequeue( $id );
+ }
+}
diff --git a/lib/experimental/blocks.php b/lib/experimental/blocks.php
index d4bb6c9b4586eb..fc67f2c9d43770 100644
--- a/lib/experimental/blocks.php
+++ b/lib/experimental/blocks.php
@@ -77,124 +77,3 @@ function wp_enqueue_block_view_script( $block_name, $args ) {
add_filter( 'render_block', $callback, 10, 2 );
}
}
-
-
-
-
-$gutenberg_experiments = get_option( 'gutenberg-experiments' );
-if ( $gutenberg_experiments && (
- array_key_exists( 'gutenberg-connections', $gutenberg_experiments ) ||
- array_key_exists( 'gutenberg-pattern-partial-syncing', $gutenberg_experiments )
-) ) {
- /**
- * Renders the block meta attributes.
- *
- * @param string $block_content Block Content.
- * @param array $block Block attributes.
- * @param WP_Block $block_instance The block instance.
- */
- function gutenberg_render_block_connections( $block_content, $block, $block_instance ) {
- $connection_sources = require __DIR__ . '/connection-sources/index.php';
- $block_type = $block_instance->block_type;
-
- // Allowlist of blocks that support block connections.
- // Currently, we only allow the following blocks and attributes:
- // - Paragraph: content.
- // - Image: url.
- $blocks_attributes_allowlist = array(
- 'core/paragraph' => array( 'content' ),
- 'core/image' => array( 'url' ),
- );
-
- // Whitelist of the block types that support block connections.
- // Currently, we only allow the Paragraph and Image blocks to use block connections.
- if ( ! in_array( $block['blockName'], array_keys( $blocks_attributes_allowlist ), true ) ) {
- return $block_content;
- }
-
- // If for some reason, the block type is not found, skip it.
- if ( null === $block_type ) {
- return $block_content;
- }
-
- // If the block does not have support for block connections, skip it.
- if ( ! block_has_support( $block_type, array( '__experimentalConnections' ), false ) ) {
- return $block_content;
- }
-
- // Get all the attributes that have a connection.
- $connected_attributes = $block['attrs']['connections']['attributes'] ?? false;
- if ( ! $connected_attributes ) {
- return $block_content;
- }
-
- foreach ( $connected_attributes as $attribute_name => $attribute_value ) {
-
- // If the attribute is not in the allowlist, skip it.
- if ( ! in_array( $attribute_name, $blocks_attributes_allowlist[ $block['blockName'] ], true ) ) {
- continue;
- }
-
- // Skip if the source value is not "meta_fields" or "pattern_attributes".
- if ( 'meta_fields' !== $attribute_value['source'] && 'pattern_attributes' !== $attribute_value['source'] ) {
- continue;
- }
-
- // If the attribute does not have a source, skip it.
- if ( ! isset( $block_type->attributes[ $attribute_name ]['source'] ) ) {
- continue;
- }
-
- if ( 'pattern_attributes' === $attribute_value['source'] ) {
- if ( ! _wp_array_get( $block_instance->attributes, array( 'metadata', 'id' ), false ) ) {
- continue;
- }
-
- $custom_value = $connection_sources[ $attribute_value['source'] ]( $block_instance, $attribute_name );
- } else {
- // If the attribute does not specify the name of the custom field, skip it.
- if ( ! isset( $attribute_value['value'] ) ) {
- continue;
- }
-
- // Get the content from the connection source.
- $custom_value = $connection_sources[ $attribute_value['source'] ](
- $block_instance,
- $attribute_value['value']
- );
- }
-
- if ( false === $custom_value ) {
- continue;
- }
-
- $tags = new WP_HTML_Tag_Processor( $block_content );
- $found = $tags->next_tag(
- array(
- // TODO: In the future, when blocks other than Paragraph and Image are
- // supported, we should build the full query from CSS selector.
- 'tag_name' => $block_type->attributes[ $attribute_name ]['selector'],
- )
- );
- if ( ! $found ) {
- return $block_content;
- }
- $tag_name = $tags->get_tag();
- $markup = "<$tag_name>$custom_value$tag_name>";
- $updated_tags = new WP_HTML_Tag_Processor( $markup );
- $updated_tags->next_tag();
-
- // Get all the attributes from the original block and add them to the new markup.
- $names = $tags->get_attribute_names_with_prefix( '' );
- foreach ( $names as $name ) {
- $updated_tags->set_attribute( $name, $tags->get_attribute( $name ) );
- }
-
- return $updated_tags->get_updated_html();
- }
-
- return $block_content;
- }
-
- add_filter( 'render_block', 'gutenberg_render_block_connections', 10, 3 );
-}
diff --git a/lib/experimental/connection-sources/index.php b/lib/experimental/connection-sources/index.php
deleted file mode 100644
index 4f9e06cb13b945..00000000000000
--- a/lib/experimental/connection-sources/index.php
+++ /dev/null
@@ -1,23 +0,0 @@
- 'meta',
- 'meta_fields' => function ( $block_instance, $meta_field ) {
- // We should probably also check if the meta field exists but for now it's okay because
- // if it doesn't, `get_post_meta()` will just return an empty string.
- return get_post_meta( $block_instance->context['postId'], $meta_field, true );
- },
- 'pattern_attributes' => function ( $block_instance, $attribute_name ) {
- $block_id = $block_instance->attributes['metadata']['id'];
- return _wp_array_get(
- $block_instance->context,
- array( 'pattern/overrides', $block_id, $attribute_name ),
- false
- );
- },
-);
diff --git a/lib/experimental/editor-settings.php b/lib/experimental/editor-settings.php
index 5f61684e8b1342..37774e07b27691 100644
--- a/lib/experimental/editor-settings.php
+++ b/lib/experimental/editor-settings.php
@@ -25,18 +25,9 @@ function gutenberg_enable_experiments() {
if ( $gutenberg_experiments && array_key_exists( 'gutenberg-group-grid-variation', $gutenberg_experiments ) ) {
wp_add_inline_script( 'wp-block-editor', 'window.__experimentalEnableGroupGridVariation = true', 'before' );
}
-
- if ( $gutenberg_experiments && array_key_exists( 'gutenberg-connections', $gutenberg_experiments ) ) {
- wp_add_inline_script( 'wp-block-editor', 'window.__experimentalConnections = true', 'before' );
- }
-
if ( gutenberg_is_experiment_enabled( 'gutenberg-no-tinymce' ) ) {
wp_add_inline_script( 'wp-block-library', 'window.__experimentalDisableTinymce = true', 'before' );
}
-
- if ( $gutenberg_experiments && array_key_exists( 'gutenberg-pattern-partial-syncing', $gutenberg_experiments ) ) {
- wp_add_inline_script( 'wp-block-editor', 'window.__experimentalPatternPartialSyncing = true', 'before' );
- }
}
add_action( 'admin_init', 'gutenberg_enable_experiments' );
diff --git a/lib/experimental/fonts-api/bc-layer/class-gutenberg-fonts-api-bc-layer.php b/lib/experimental/fonts-api/bc-layer/class-gutenberg-fonts-api-bc-layer.php
deleted file mode 100644
index 8c859769875230..00000000000000
--- a/lib/experimental/fonts-api/bc-layer/class-gutenberg-fonts-api-bc-layer.php
+++ /dev/null
@@ -1,96 +0,0 @@
-variation_property_defaults = apply_filters( 'wp_webfont_variation_defaults', $this->variation_property_defaults );
-
- /**
- * Fires when the WP_Webfonts instance is initialized.
- *
- * @since X.X.X
- *
- * @param WP_Web_Fonts $wp_webfonts WP_Web_Fonts instance (passed by reference).
- */
- do_action_ref_array( 'wp_default_webfonts', array( &$this ) );
- }
-
- /**
- * Get the list of registered providers.
- *
- * @since X.X.X
- *
- * @return array $providers {
- * An associative array of registered providers, keyed by their unique ID.
- *
- * @type string $provider_id => array {
- * An associate array of provider's class name and fonts.
- *
- * @type string $class_name Fully qualified name of the provider's class.
- * @type string[] $fonts An array of enqueued font handles for this provider.
- * }
- * }
- */
- public function get_providers() {
- return $this->providers;
- }
-
- /**
- * Register a provider.
- *
- * @since X.X.X
- *
- * @param string $provider_id The provider's unique ID.
- * @param string $class_name The provider class name.
- * @return bool True if successfully registered, else false.
- */
- public function register_provider( $provider_id, $class_name ) {
- if ( empty( $provider_id ) || empty( $class_name ) || ! class_exists( $class_name ) ) {
- return false;
- }
-
- $this->providers[ $provider_id ] = array(
- 'class' => $class_name,
- 'fonts' => array(),
- );
- return true;
- }
-
- /**
- * Get the list of all registered font family handles.
- *
- * @since X.X.X
- *
- * @return string[]
- */
- public function get_registered_font_families() {
- $font_families = array();
- foreach ( $this->registered as $handle => $obj ) {
- if ( $obj->extra['is_font_family'] ) {
- $font_families[] = $handle;
- }
- }
- return $font_families;
- }
-
- /**
- * Get the list of all registered font families and their variations.
- *
- * @since X.X.X
- *
- * @return string[]
- */
- public function get_registered() {
- return array_keys( $this->registered );
- }
-
- /**
- * Get the list of enqueued font families and their variations.
- *
- * @since X.X.X
- *
- * @return array[]
- */
- public function get_enqueued() {
- return $this->queue;
- }
-
- /**
- * Registers a font family.
- *
- * @since X.X.X
- *
- * @param string $font_family Font family name to register.
- * @return string|null Font family handle when registration successes. Null on failure.
- */
- public function add_font_family( $font_family ) {
- $font_family_handle = WP_Fonts_Utils::convert_font_family_into_handle( $font_family );
- if ( ! $font_family_handle ) {
- return null;
- }
-
- if ( isset( $this->registered[ $font_family_handle ] ) ) {
- return $font_family_handle;
- }
-
- $registered = $this->add( $font_family_handle, false );
- if ( ! $registered ) {
- return null;
- }
-
- $this->add_data( $font_family_handle, 'font-properties', array( 'font-family' => $font_family ) );
- $this->add_data( $font_family_handle, 'is_font_family', true );
-
- return $font_family_handle;
- }
-
- /**
- * Removes a font family and all registered variations.
- *
- * @since X.X.X
- *
- * @param string $font_family_handle The font family to remove.
- */
- public function remove_font_family( $font_family_handle ) {
- if ( ! isset( $this->registered[ $font_family_handle ] ) ) {
- return;
- }
-
- $variations = $this->registered[ $font_family_handle ]->deps;
-
- foreach ( $variations as $variation ) {
- $this->remove( $variation );
- }
-
- $this->remove( $font_family_handle );
- }
-
- /**
- * Add a variation to an existing family or register family if none exists.
- *
- * @since X.X.X
- *
- * @param string $font_family_handle The font family's handle for this variation.
- * @param array $variation An array of variation properties to add.
- * @param string $variation_handle Optional. The variation's handle. When none is provided, the
- * handle will be dynamically generated.
- * Default empty string.
- * @return string|null Variation handle on success. Else null.
- */
- public function add_variation( $font_family_handle, array $variation, $variation_handle = '' ) {
- if ( ! WP_Fonts_Utils::is_defined( $font_family_handle ) ) {
- trigger_error( 'Font family handle must be a non-empty string.' );
- return null;
- }
-
- // When there is a variation handle, check it.
- if ( '' !== $variation_handle && ! WP_Fonts_Utils::is_defined( $variation_handle ) ) {
- trigger_error( 'Variant handle must be a non-empty string.' );
- return null;
- }
-
- // Register the font family when it does not yet exist.
- if ( ! isset( $this->registered[ $font_family_handle ] ) ) {
- if ( ! $this->add_font_family( $font_family_handle ) ) {
- return null;
- }
- }
-
- $variation = $this->validate_variation( $variation );
-
- // Variation validation failed.
- if ( ! $variation ) {
- return null;
- }
-
- // When there's no variation handle, attempt to create one.
- if ( '' === $variation_handle ) {
- $variation_handle = WP_Fonts_Utils::convert_variation_into_handle( $font_family_handle, $variation );
- if ( is_null( $variation_handle ) ) {
- return null;
- }
- }
-
- // Bail out if the variant is already registered.
- if ( $this->is_variation_registered( $font_family_handle, $variation_handle ) ) {
- return $variation_handle;
- }
-
- $variation_src = array_key_exists( 'src', $variation ) ? $variation['src'] : false;
- $result = $this->add( $variation_handle, $variation_src );
-
- // Bail out if the registration failed.
- if ( ! $result ) {
- return null;
- }
-
- $this->add_data( $variation_handle, 'font-properties', $variation );
- $this->add_data( $variation_handle, 'is_font_family', false );
-
- // Add the font variation as a dependency to the registered font family.
- $this->add_dependency( $font_family_handle, $variation_handle );
-
- $this->providers[ $variation['provider'] ]['fonts'][] = $variation_handle;
-
- return $variation_handle;
- }
-
- /**
- * Removes a variation.
- *
- * @since X.X.X
- *
- * @param string $font_family_handle The font family for this variation.
- * @param string $variation_handle The variation's handle to remove.
- */
- public function remove_variation( $font_family_handle, $variation_handle ) {
- if ( isset( $this->registered[ $variation_handle ] ) ) {
- $this->remove( $variation_handle );
- }
-
- if ( ! $this->is_variation_registered( $font_family_handle, $variation_handle ) ) {
- return;
- }
-
- // Remove the variation as a dependency from its font family.
- $this->registered[ $font_family_handle ]->deps = array_values(
- array_diff(
- $this->registered[ $font_family_handle ]->deps,
- array( $variation_handle )
- )
- );
- }
-
- /**
- * Checks if the variation is registered.
- *
- * @since X.X.X
- *
- * @param string $font_family_handle The font family's handle for this variation.
- * @param string $variation_handle Variation's handle.
- * @return bool True when registered to the given font family. Else false.
- */
- private function is_variation_registered( $font_family_handle, $variation_handle ) {
- if ( ! isset( $this->registered[ $font_family_handle ] ) ) {
- return false;
- }
-
- return in_array( $variation_handle, $this->registered[ $font_family_handle ]->deps, true );
- }
-
- /**
- * Adds a variation as a dependency to the given font family.
- *
- * @since X.X.X
- *
- * @param string $font_family_handle The font family's handle for this variation.
- * @param string $variation_handle The variation's handle.
- */
- private function add_dependency( $font_family_handle, $variation_handle ) {
- $this->registered[ $font_family_handle ]->deps[] = $variation_handle;
- }
-
- /**
- * Validates and sanitizes a variation.
- *
- * @since X.X.X
- *
- * @param array $variation Variation properties to add.
- * @return false|array Validated variation on success. Else, false.
- */
- private function validate_variation( $variation ) {
- $variation = wp_parse_args( $variation, $this->variation_property_defaults );
-
- // Check the font-family.
- if ( empty( $variation['font-family'] ) || ! is_string( $variation['font-family'] ) ) {
- trigger_error( 'Webfont font-family must be a non-empty string.' );
- return false;
- }
-
- // Local fonts need a "src".
- if ( 'local' === $variation['provider'] ) {
- // Make sure that local fonts have 'src' defined.
- if ( empty( $variation['src'] ) || ( ! is_string( $variation['src'] ) && ! is_array( $variation['src'] ) ) ) {
- trigger_error( 'Webfont src must be a non-empty string or an array of strings.' );
- return false;
- }
- } elseif ( ! isset( $this->providers[ $variation['provider'] ] ) ) {
- trigger_error( sprintf( 'The provider "%s" is not registered', $variation['provider'] ) );
- return false;
- } elseif ( ! class_exists( $this->providers[ $variation['provider'] ]['class'] ) ) {
- trigger_error( sprintf( 'The provider class "%s" does not exist', $variation['provider'] ) );
- return false;
- }
-
- // Validate the 'src' property.
- if ( ! empty( $variation['src'] ) ) {
- foreach ( (array) $variation['src'] as $src ) {
- if ( empty( $src ) || ! is_string( $src ) ) {
- trigger_error( 'Each webfont src must be a non-empty string.' );
- return false;
- }
- }
- }
-
- // Check the font-weight.
- if ( ! is_string( $variation['font-weight'] ) && ! is_int( $variation['font-weight'] ) ) {
- trigger_error( 'Webfont font-weight must be a properly formatted string or integer.' );
- return false;
- }
-
- // Check the font-display.
- if ( ! in_array( $variation['font-display'], array( 'auto', 'block', 'fallback', 'swap', 'optional' ), true ) ) {
- $variation['font-display'] = 'fallback';
- }
-
- $valid_props = array(
- 'ascent-override',
- 'descent-override',
- 'font-display',
- 'font-family',
- 'font-stretch',
- 'font-style',
- 'font-weight',
- 'font-variant',
- 'font-feature-settings',
- 'font-variation-settings',
- 'line-gap-override',
- 'size-adjust',
- 'src',
- 'unicode-range',
-
- // Exceptions.
- 'provider',
- );
-
- foreach ( $variation as $prop => $value ) {
- if ( ! in_array( $prop, $valid_props, true ) ) {
- unset( $variation[ $prop ] );
- }
- }
-
- return $variation;
- }
-
- /**
- * Processes the items and dependencies.
- *
- * Processes the items passed to it or the queue, and their dependencies.
- *
- * @since X.X.X
- *
- * @param string|string[]|bool $handles Optional. Items to be processed: queue (false),
- * single item (string), or multiple items (array of strings).
- * Default false.
- * @param int|false $group Optional. Group level: level (int), no group (false).
- *
- * @return array|string[] Array of web font handles that have been processed.
- * An empty array if none were processed.
- */
- public function do_items( $handles = false, $group = false ) {
- $handles = $this->prepare_handles_for_printing( $handles );
-
- if ( empty( $handles ) ) {
- return $this->done;
- }
-
- $this->all_deps( $handles );
- if ( empty( $this->to_do ) ) {
- return $this->done;
- }
-
- $this->to_do_keyed_handles = array_flip( $this->to_do );
-
- foreach ( $this->get_providers() as $provider_id => $provider ) {
- // Alert and skip if the provider class does not exist.
- if ( ! class_exists( $provider['class'] ) ) {
- /* translators: %s is the provider name. */
- trigger_error(
- sprintf(
- 'Class "%s" not found for "%s" web font provider',
- $provider['class'],
- $provider_id
- )
- );
- continue;
- }
-
- $this->do_item( $provider_id, $group );
- }
-
- $this->process_font_families_after_printing( $handles );
-
- return $this->done;
- }
-
- /**
- * Prepares the given handles for printing.
- *
- * @since X.X.X
- *
- * @param string|string[]|bool $handles Optional. Handles to prepare.
- * Default false.
- * @return array Array of handles.
- */
- private function prepare_handles_for_printing( $handles = false ) {
- if ( false !== $handles ) {
- $handles = $this->validate_handles( $handles );
- // Bail out when invalid.
- if ( empty( $handles ) ) {
- return array();
- }
- }
-
- // Use the enqueued queue.
- if ( empty( $handles ) ) {
- if ( empty( $this->queue ) ) {
- return array();
- }
- $handles = $this->queue;
- }
-
- return $handles;
- }
-
- /**
- * Validates handle(s) to ensure each is a non-empty string.
- *
- * @since X.X.X
- *
- * @param string|string[] $handles Handles to prepare.
- * @return string[]|null Array of handles on success. Else null.
- */
- private function validate_handles( $handles ) {
- // Validate each element is a non-empty string handle.
- $handles = array_filter( (array) $handles, array( WP_Fonts_Utils::class, 'is_defined' ) );
-
- if ( empty( $handles ) ) {
- trigger_error( 'Handles must be a non-empty string or array of non-empty strings' );
- return null;
- }
-
- return $handles;
- }
-
- /**
- * Invokes each provider to process and print its styles.
- *
- * @since X.X.X
- *
- * @see WP_Dependencies::do_item()
- *
- * @param string $provider_id The provider to process.
- * @param int|false $group Not used.
- * @return bool
- */
- public function do_item( $provider_id, $group = false ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
- // Bail out if the provider is not registered.
- if ( ! isset( $this->providers[ $provider_id ] ) ) {
- return false;
- }
-
- $font_handles = $this->get_enqueued_fonts_for_provider( $provider_id );
- if ( empty( $font_handles ) ) {
- return false;
- }
-
- $properties_by_font = $this->get_font_properties_for_provider( $font_handles );
- if ( empty( $properties_by_font ) ) {
- return false;
- }
-
- // Invoke provider to print its styles.
- $provider = $this->get_provider_instance( $provider_id );
- $provider->set_fonts( $properties_by_font );
- $provider->print_styles();
-
- // Clean up.
- $this->update_queues_for_printed_fonts( $font_handles );
-
- return true;
- }
-
- /**
- * Retrieves a list of enqueued web font variations for a provider.
- *
- * @since X.X.X
- *
- * @param string $provider_id The provider to process.
- * @return array[] Webfonts organized by providers.
- */
- private function get_enqueued_fonts_for_provider( $provider_id ) {
- $providers = $this->get_providers();
-
- if ( empty( $providers[ $provider_id ] ) ) {
- return array();
- }
-
- return array_intersect(
- $providers[ $provider_id ]['fonts'],
- $this->to_do
- );
- }
-
- /**
- * Gets a list of font properties for each of the given font handles.
- *
- * @since X.X.X
- *
- * @param array $font_handles Font handles to get properties.
- * @return array A list of fonts with each font's properties.
- */
- private function get_font_properties_for_provider( array $font_handles ) {
- $font_properties = array();
-
- foreach ( $font_handles as $font_handle ) {
- $properties = $this->get_data( $font_handle, 'font-properties' );
- if ( ! $properties ) {
- continue;
- }
- $font_properties[ $font_handle ] = $properties;
- }
-
- return $font_properties;
- }
-
- /**
- * Gets the instance of the provider from the WP_Webfonts::$provider_instance store.
- *
- * @since X.X.X
- *
- * @param string $provider_id The provider to get.
- * @return object Instance of the provider.
- */
- private function get_provider_instance( $provider_id ) {
- if ( ! isset( $this->provider_instances[ $provider_id ] ) ) {
- $this->provider_instances[ $provider_id ] = new $this->providers[ $provider_id ]['class']();
- }
- return $this->provider_instances[ $provider_id ];
- }
-
- /**
- * Update queues for the given printed fonts.
- *
- * @since X.X.X
- *
- * @param array $font_handles Font handles to get properties.
- */
- private function update_queues_for_printed_fonts( array $font_handles ) {
- foreach ( $font_handles as $font_handle ) {
- $this->set_as_done( $font_handle );
- $this->remove_from_to_do_queues( $font_handle );
- }
- }
-
- /**
- * Processes the font families after printing the variations.
- *
- * For each queued font family:
- *
- * a. if any of their variations were printed, the font family is added to the `done` list.
- * b. removes each from the to_do queues.
- *
- * @since X.X.X
- *
- * @param array $handles Handles to process.
- */
- private function process_font_families_after_printing( array $handles ) {
- foreach ( $handles as $handle ) {
- if (
- ! $this->get_data( $handle, 'is_font_family' ) ||
- ! isset( $this->to_do_keyed_handles[ $handle ] )
- ) {
- continue;
- }
- $font_family = $this->registered[ $handle ];
-
- // Add the font family to `done` list if any of its variations were printed.
- if ( ! empty( $font_family->deps ) ) {
- $processed = array_intersect( $font_family->deps, $this->done );
- if ( ! empty( $processed ) ) {
- $this->set_as_done( $handle );
- }
- }
-
- $this->remove_from_to_do_queues( $handle );
- }
- }
-
- /**
- * Removes the handle from the `to_do` and `to_do_keyed_handles` lists.
- *
- * @since X.X.X
- *
- * @param string $handle Handle to remove.
- */
- private function remove_from_to_do_queues( $handle ) {
- unset(
- $this->to_do[ $this->to_do_keyed_handles[ $handle ] ],
- $this->to_do_keyed_handles[ $handle ]
- );
- }
-
- /**
- * Sets the given handle to done by adding it to the `done` list.
- *
- * @since X.X.X
- *
- * @param string $handle Handle to set as done.
- */
- private function set_as_done( $handle ) {
- if ( ! is_array( $this->done ) ) {
- $this->done = array();
- }
- $this->done[] = $handle;
- }
-
- /**
- * Converts the font family and its variations into theme.json structural format.
- *
- * @since X.X.X
- *
- * @param string $font_family_handle Font family to convert.
- * @return array Webfonts in theme.json structural format.
- */
- public function to_theme_json( $font_family_handle ) {
- if ( ! isset( $this->registered[ $font_family_handle ] ) ) {
- return array();
- }
-
- $font_family_name = $this->registered[ $font_family_handle ]->extra['font-properties']['font-family'];
- $theme_json_format = array(
- 'fontFamily' => str_contains( $font_family_name, ' ' ) ? "'{$font_family_name}'" : $font_family_name,
- 'name' => $font_family_name,
- 'slug' => $font_family_handle,
- 'fontFace' => array(),
- );
-
- foreach ( $this->registered[ $font_family_handle ]->deps as $variation_handle ) {
- if ( ! isset( $this->registered[ $variation_handle ] ) ) {
- continue;
- }
-
- $variation_obj = $this->registered[ $variation_handle ];
- $variation_properties = array( 'origin' => 'gutenberg_wp_fonts_api' );
- foreach ( $variation_obj->extra['font-properties'] as $property_name => $property_value ) {
- $property_in_camelcase = lcfirst( str_replace( '-', '', ucwords( $property_name, '-' ) ) );
- $variation_properties[ $property_in_camelcase ] = $property_value;
- }
- $theme_json_format['fontFace'][ $variation_obj->handle ] = $variation_properties;
- }
-
- return $theme_json_format;
- }
-}
diff --git a/lib/experimental/fonts-api/bc-layer/class-wp-webfonts-provider-local.php b/lib/experimental/fonts-api/bc-layer/class-wp-webfonts-provider-local.php
deleted file mode 100644
index b6fd5d78a1435a..00000000000000
--- a/lib/experimental/fonts-api/bc-layer/class-wp-webfonts-provider-local.php
+++ /dev/null
@@ -1,284 +0,0 @@
-style_tag_atts = array( 'type' => 'text/css' );
- }
- }
-
- /**
- * Gets the `@font-face` CSS styles for locally-hosted font files.
- *
- * This method does the following processing tasks:
- * 1. Orchestrates an optimized `src` (with format) for browser support.
- * 2. Generates the `@font-face` for all its webfonts.
- *
- * For example, when given these webfonts:
- *
- * array(
- * 'source-serif-pro.normal.200 900' => array(
- * 'provider' => 'local',
- * 'font_family' => 'Source Serif Pro',
- * 'font_weight' => '200 900',
- * 'font_style' => 'normal',
- * 'src' => 'https://example.com/wp-content/themes/twentytwentytwo/assets/fonts/source-serif-pro/SourceSerif4Variable-Roman.ttf.woff2' ),
- * ),
- * 'source-serif-pro.italic.400 900' => array(
- * 'provider' => 'local',
- * 'font_family' => 'Source Serif Pro',
- * 'font_weight' => '200 900',
- * 'font_style' => 'italic',
- * 'src' => 'https://example.com/wp-content/themes/twentytwentytwo/assets/fonts/source-serif-pro/SourceSerif4Variable-Italic.ttf.woff2' ),
- * ),
- * )
- *
- *
- * the following `@font-face` styles are generated and returned:
- *
- *
- * @font-face{
- * font-family:"Source Serif Pro";
- * font-style:normal;
- * font-weight:200 900;
- * font-stretch:normal;
- * src:local("Source Serif Pro"), url('/assets/fonts/source-serif-pro/SourceSerif4Variable-Roman.ttf.woff2') format('woff2');
- * }
- * @font-face{
- * font-family:"Source Serif Pro";
- * font-style:italic;
- * font-weight:200 900;
- * font-stretch:normal;
- * src:local("Source Serif Pro"), url('/assets/fonts/source-serif-pro/SourceSerif4Variable-Italic.ttf.woff2') format('woff2');
- * }
- *
- *
- * @since X.X.X
- *
- * @return string The `@font-face` CSS.
- */
- public function get_css() {
- $css = '';
-
- foreach ( $this->webfonts as $webfont ) {
- // Order the webfont's `src` items to optimize for browser support.
- $webfont = $this->order_src( $webfont );
-
- // Build the @font-face CSS for this webfont.
- $css .= '@font-face{' . $this->build_font_face_css( $webfont ) . '}';
- }
-
- return $css;
- }
-
- /**
- * Order `src` items to optimize for browser support.
- *
- * @since 6.0.0
- *
- * @param array $webfont Webfont to process.
- * @return array
- */
- private function order_src( array $webfont ) {
- if ( ! is_array( $webfont['src'] ) ) {
- $webfont['src'] = (array) $webfont['src'];
- }
-
- $src = array();
- $src_ordered = array();
-
- foreach ( $webfont['src'] as $url ) {
- // Add data URIs first.
- if ( str_starts_with( trim( $url ), 'data:' ) ) {
- $src_ordered[] = array(
- 'url' => $url,
- 'format' => 'data',
- );
- continue;
- }
- $format = pathinfo( $url, PATHINFO_EXTENSION );
- $src[ $format ] = $url;
- }
-
- // Add woff2.
- if ( ! empty( $src['woff2'] ) ) {
- $src_ordered[] = array(
- 'url' => $src['woff2'],
- 'format' => 'woff2',
- );
- }
-
- // Add woff.
- if ( ! empty( $src['woff'] ) ) {
- $src_ordered[] = array(
- 'url' => $src['woff'],
- 'format' => 'woff',
- );
- }
-
- // Add ttf.
- if ( ! empty( $src['ttf'] ) ) {
- $src_ordered[] = array(
- 'url' => $src['ttf'],
- 'format' => 'truetype',
- );
- }
-
- // Add eot.
- if ( ! empty( $src['eot'] ) ) {
- $src_ordered[] = array(
- 'url' => $src['eot'],
- 'format' => 'embedded-opentype',
- );
- }
-
- // Add otf.
- if ( ! empty( $src['otf'] ) ) {
- $src_ordered[] = array(
- 'url' => $src['otf'],
- 'format' => 'opentype',
- );
- }
- $webfont['src'] = $src_ordered;
-
- return $webfont;
- }
-
- /**
- * Builds the font-family's CSS.
- *
- * @since 6.0.0
- *
- * @param array $webfont Webfont to process.
- * @return string This font-family's CSS.
- */
- private function build_font_face_css( array $webfont ) {
- $css = '';
-
- // Wrap font-family in quotes if it contains spaces
- // and is not already wrapped in quotes.
- if (
- str_contains( $webfont['font-family'], ' ' ) &&
- ! str_contains( $webfont['font-family'], '"' ) &&
- ! str_contains( $webfont['font-family'], "'" )
- ) {
- $webfont['font-family'] = '"' . $webfont['font-family'] . '"';
- }
-
- foreach ( $webfont as $key => $value ) {
-
- // Skip "provider", since it's for internal API use,
- // and not a valid CSS property.
- if ( 'provider' === $key ) {
- continue;
- }
-
- // Compile the "src" parameter.
- if ( 'src' === $key ) {
- $value = $this->compile_src( $webfont['font-family'], $value );
- }
-
- // If font-variation-settings is an array, convert it to a string.
- if ( 'font-variation-settings' === $key && is_array( $value ) ) {
- $value = $this->compile_variations( $value );
- }
-
- if ( ! empty( $value ) ) {
- $css .= "$key:$value;";
- }
- }
-
- return $css;
- }
-
- /**
- * Compiles the `src` into valid CSS.
- *
- * @since 6.0.0
- *
- * @param string $font_family Font family.
- * @param array $value Value to process.
- * @return string The CSS.
- */
- private function compile_src( $font_family, array $value ) {
- $src = "local($font_family)";
-
- foreach ( $value as $item ) {
-
- if ( str_starts_with( $item['url'], get_site_url() ) ) {
- $item['url'] = wp_make_link_relative( $item['url'] );
- }
-
- $src .= ( 'data' === $item['format'] )
- ? ", url({$item['url']})"
- : ", url('{$item['url']}') format('{$item['format']}')";
- }
- return $src;
- }
-
- /**
- * Compiles the font variation settings.
- *
- * @since 6.0.0
- *
- * @param array $font_variation_settings Array of font variation settings.
- * @return string The CSS.
- */
- private function compile_variations( array $font_variation_settings ) {
- $variations = '';
-
- foreach ( $font_variation_settings as $key => $value ) {
- $variations .= "$key $value";
- }
-
- return $variations;
- }
-}
diff --git a/lib/experimental/fonts-api/bc-layer/class-wp-webfonts-provider.php b/lib/experimental/fonts-api/bc-layer/class-wp-webfonts-provider.php
deleted file mode 100644
index 5b7f5ece335b11..00000000000000
--- a/lib/experimental/fonts-api/bc-layer/class-wp-webfonts-provider.php
+++ /dev/null
@@ -1,66 +0,0 @@
-webfonts = $this->fonts;
- }
-
- /**
- * This method is here to wire WP_Fonts_Provider::do_item() to this
- * deprecated class to ensure the fonts get set.
- *
- * @param array[] $fonts Fonts to be processed.
- */
- public function set_fonts( array $fonts ) {
- parent::set_fonts( $fonts );
- $this->webfonts = $this->fonts;
- }
-}
diff --git a/lib/experimental/fonts-api/bc-layer/class-wp-webfonts-utils.php b/lib/experimental/fonts-api/bc-layer/class-wp-webfonts-utils.php
deleted file mode 100644
index d0242acf9a2b6f..00000000000000
--- a/lib/experimental/fonts-api/bc-layer/class-wp-webfonts-utils.php
+++ /dev/null
@@ -1,85 +0,0 @@
-wp_fonts = ! empty( $wp_fonts ) ? $wp_fonts : wp_fonts();
- }
-
- /**
- * Gets the font slug.
- *
- * @since X.X.X
- * @deprecated Use WP_Fonts_Utils::convert_font_family_into_handle() or WP_Fonts_Utils::get_font_family_from_variation().
- *
- * @param array|string $to_convert The value to convert into a slug. Expected as the web font's array
- * or a font-family as a string.
- * @return string|false The font slug on success, or false if the font-family cannot be determined.
- */
- public static function get_font_slug( $to_convert ) {
- $message = is_array( $to_convert )
- ? 'Use WP_Fonts_Utils::get_font_family_from_variation() to get the font family from an array and then WP_Fonts_Utils::convert_font_family_into_handle() to convert the font-family name into a handle'
- : 'Use WP_Fonts_Utils::convert_font_family_into_handle() to convert the font-family name into a handle';
- _deprecated_function( __METHOD__, 'GB 14.9.1', $message );
-
- if ( empty( $to_convert ) ) {
- return false;
- }
-
- $font_family_name = is_array( $to_convert )
- ? WP_Fonts_Utils::get_font_family_from_variation( $to_convert )
- : $to_convert;
-
- $slug = false;
- if ( ! empty( $font_family_name ) ) {
- $slug = WP_Fonts_Utils::convert_font_family_into_handle( $font_family_name );
- }
-
- return $slug;
- }
-
- /**
- * Initializes the API.
- *
- * @since 6.0.0
- * @deprecated GB 14.9.1 Use wp_fonts().
- */
- public static function init() {
- _deprecated_function( __METHOD__, 'GB 14.9.1', 'wp_fonts()' );
- }
-
- /**
- * Get the list of all registered font family handles.
- *
- * @since X.X.X
- * @deprecated GB 15.8.0 Use wp_fonts()->get_registered_font_families().
- *
- * @return string[]
- */
- public function get_registered_font_families() {
- _deprecated_function( __METHOD__, 'GB 15.8.0', 'wp_fonts()->get_registered_font_families()' );
-
- return $this->wp_fonts->get_registered_font_families();
- }
-
- /**
- * Gets the list of registered fonts.
- *
- * @since 6.0.0
- * @deprecated 14.9.1 Use wp_fonts()->get_registered().
- *
- * @return array[]
- */
- public function get_registered_webfonts() {
- _deprecated_function( __METHOD__, '14.9.1', 'wp_fonts()->get_registered()' );
-
- return $this->_get_registered_webfonts();
- }
-
- /**
- * Gets the list of enqueued fonts.
- *
- * @since 6.0.0
- * @deprecated GB 14.9.1 Use wp_fonts()->get_enqueued().
- *
- * @return array[]
- */
- public function get_enqueued_webfonts() {
- _deprecated_function( __METHOD__, 'GB 14.9.1', 'wp_fonts()->get_enqueued()' );
-
- return $this->wp_fonts->queue;
- }
-
- /**
- * Gets the list of all fonts.
- *
- * @since 6.0.0
- * @deprecated GB 14.9.1 Use wp_fonts()->get_registered().
- *
- * @return array[]
- */
- public function get_all_webfonts() {
- _deprecated_function( __METHOD__, 'GB 14.9.1', 'wp_fonts()->get_registered()' );
-
- return $this->_get_registered_webfonts();
- }
-
- /**
- * Registers a webfont.
- *
- * @since 6.0.0
- * @deprecated GB 14.9.1 Use wp_register_fonts().
- *
- * @param array $webfont Web font to register.
- * @param string $font_family_handle Optional. Font family handle for the given variation.
- * Default empty string.
- * @param string $variation_handle Optional. Handle for the variation to register.
- * @param bool $silence_deprecation Optional. Silences the deprecation notice. For internal use.
- * @return string|false The font family slug if successfully registered, else false.
- */
- public function register_webfont( array $webfont, $font_family_handle = '', $variation_handle = '', $silence_deprecation = false ) {
- if ( ! $silence_deprecation ) {
- _deprecated_function( __METHOD__, 'GB 14.9.1', 'wp_register_fonts()' );
- }
-
- // Bail out if no variation passed as there's not to register.
- if ( empty( $webfont ) ) {
- return false;
- }
-
- // Restructure definition: keyed by font-family and array of variations.
- $font = array( $webfont );
- if ( WP_Fonts_Utils::is_defined( $font_family_handle ) ) {
- $font = array( $font_family_handle => $font );
- } else {
- $font = Gutenberg_Fonts_API_BC_Layer::migrate_deprecated_structure( $font, true );
- $font_family_handle = array_key_first( $font );
- }
-
- if ( empty( $font ) || empty( $font_family_handle ) ) {
- return false;
- }
-
- // If the variation handle was passed, add it as variation key.
- if ( WP_Fonts_Utils::is_defined( $variation_handle ) ) {
- $font[ $font_family_handle ] = array( $variation_handle => $font[ $font_family_handle ][0] );
- }
-
- // Register with the Fonts API.
- $handle = wp_register_fonts( $font );
- if ( empty( $handle ) ) {
- return false;
- }
- return array_pop( $handle );
- }
-
- /**
- * Enqueue a font-family that has been already registered.
- *
- * @since 6.0.0
- * @deprecated GB 14.9.1 Use wp_enqueue_fonts().
- *
- * @param string $font_family_name The font family name to be enqueued.
- * @return bool True if successfully enqueued, else false.
- */
- public function enqueue_webfont( $font_family_name ) {
- _deprecated_function( __METHOD__, 'GB 14.9.1', 'wp_enqueue_fonts()' );
-
- wp_enqueue_fonts( array( $font_family_name ) );
- return true;
- }
-
- /**
- * Gets the registered webfonts in the original web font property structure keyed by each handle.
- *
- * @return array[]
- */
- private function _get_registered_webfonts() {
- $font_families = array();
- $registered = array();
-
- // Find the registered font families.
- foreach ( $this->wp_fonts->registered as $handle => $obj ) {
- if ( ! $obj->extra['is_font_family'] ) {
- continue;
- }
-
- if ( ! isset( $registered[ $handle ] ) ) {
- $registered[ $handle ] = array();
- }
-
- $font_families[ $handle ] = $obj->deps;
- }
-
- // Build the return array structure.
- foreach ( $font_families as $font_family_handle => $variations ) {
- foreach ( $variations as $variation_handle ) {
- $variation_obj = $this->wp_fonts->registered[ $variation_handle ];
-
- $registered[ $font_family_handle ][ $variation_handle ] = $variation_obj->extra['font-properties'];
- }
- }
-
- return $registered;
- }
-}
diff --git a/lib/experimental/fonts-api/bc-layer/webfonts-deprecations.php b/lib/experimental/fonts-api/bc-layer/webfonts-deprecations.php
deleted file mode 100644
index 7a3a7bd013eea3..00000000000000
--- a/lib/experimental/fonts-api/bc-layer/webfonts-deprecations.php
+++ /dev/null
@@ -1,260 +0,0 @@
- array[] $variations {
- * An array of web font variations for this font-family.
- * Each variation has the following structure.
- *
- * @type array $variation {
- * @type string $provider The provider ID. Default 'local'.
- * @type string $font-style The font-style property. Default 'normal'.
- * @type string $font-weight The font-weight property. Default '400'.
- * @type string $font-display The font-display property. Default 'fallback'.
- * }
- * }
- * }
- * @return string[] Array of registered font family handles.
- */
- function wp_register_webfonts( array $webfonts ) {
- _deprecated_function( __FUNCTION__, 'GB 15.1', 'wp_register_fonts()' );
-
- $webfonts = Gutenberg_Fonts_API_BC_Layer::migrate_deprecated_structure( $webfonts );
-
- return wp_register_fonts( $webfonts );
- }
-}
-
-if ( ! function_exists( 'wp_register_webfont' ) ) {
- /**
- * Registers a single webfont.
- *
- * Example of how to register Source Serif Pro font with font-weight range of 200-900:
- *
- * If the font file is contained within the theme:
- *
- *
- * wp_register_webfont(
- * array(
- * 'provider' => 'local',
- * 'font-family' => 'Source Serif Pro',
- * 'font-weight' => '200 900',
- * 'font-style' => 'normal',
- * 'src' => get_theme_file_uri( 'assets/fonts/source-serif-pro/SourceSerif4Variable-Roman.ttf.woff2' ),
- * )
- * );
- *
- *
- * @since 6.0.0
- * @deprecated 14.9.1 Use wp_register_fonts().
- *
- * @param array $webfont Webfont to be registered.
- * @return string|false The font family slug if successfully registered, else false.
- */
- function wp_register_webfont( array $webfont ) {
- _deprecated_function( __FUNCTION__, '14.9.1', 'wp_register_fonts()' );
-
- return wp_fonts()->register_webfont( $webfont, '', '', false );
- }
-}
-
-if ( ! function_exists( 'wp_enqueue_webfonts' ) ) {
- /**
- * Enqueues one or more font family and all of its variations.
- *
- * @since X.X.X
- * @since GB 15.1 Use wp_enqueue_fonts() ihstead.
- *
- * @param string[] $font_families Font family(ies) to enqueue.
- */
- function wp_enqueue_webfonts( array $font_families ) {
- _deprecated_function( __FUNCTION__, 'GB 15.1', 'wp_enqueue_fonts()' );
-
- wp_enqueue_fonts( $font_families );
- }
-}
-
-if ( ! function_exists( 'wp_enqueue_webfont' ) ) {
- /**
- * Enqueue a single font family that has been registered beforehand.
- *
- * Example of how to enqueue Source Serif Pro font:
- *
- *
- * wp_enqueue_webfont( 'Source Serif Pro' );
- *
- *
- * Font families should be enqueued from the `init` hook or later.
- *
- * @since 6.0.0
- * @deprecated GB 14.9.1 Use wp_enqueue_fonts() instead.
- *
- * @param string $font_family_name The font family name to be enqueued.
- * @return bool True if successfully enqueued, else false.
- */
- function wp_enqueue_webfont( $font_family_name ) {
- _deprecated_function( __FUNCTION__, 'GB 14.9.1', 'wp_enqueue_fonts()' );
-
- wp_enqueue_fonts( array( $font_family_name ) );
- return true;
- }
-}
-
-if ( ! function_exists( 'wp_enqueue_webfont_variations' ) ) {
- /**
- * Enqueues a specific set of web font variations.
- *
- * @since X.X.X
- * @deprecated GB 15.1 Use wp_enqueue_font_variations() instead.
- *
- * @param string|string[] $variation_handles Variation handle (string) or handles (array of strings).
- */
- function wp_enqueue_webfont_variations( $variation_handles ) {
- _deprecated_function( __FUNCTION__, 'GB 15.1', 'wp_enqueue_font_variations()' );
-
- wp_enqueue_font_variations( $variation_handles );
- }
-}
-
-if ( ! function_exists( 'wp_deregister_webfont_variation' ) ) {
- /**
- * Deregisters a font variation.
- *
- * @since GB 14.9.1
- * @deprecated GB 15.1 Use wp_deregister_font_variation() instead.
- *
- * @param string $font_family_handle The font family for this variation.
- * @param string $variation_handle The variation's handle to remove.
- */
- function wp_deregister_webfont_variation( $font_family_handle, $variation_handle ) {
- _deprecated_function( __FUNCTION__, 'GB 15.1', 'wp_deregister_font_variation()' );
-
- wp_deregister_font_variation( $font_family_handle, $variation_handle );
- }
-}
-
-if ( ! function_exists( 'wp_get_webfont_providers' ) ) {
- /**
- * Gets all registered providers.
- *
- * Return an array of providers, each keyed by their unique
- * ID (i.e. the `$id` property in the provider's object) with
- * an instance of the provider (object):
- * ID => provider instance
- *
- * Each provider contains the business logic for how to
- * process its specific font service (i.e. local or remote)
- * and how to generate the `@font-face` styles for its service.
- *
- * @since X.X.X
- * @deprecated GB 14.9.1 Use wp_fonts()->get_providers().
- *
- * @return string[] All registered providers, each keyed by their unique ID.
- */
- function wp_get_webfont_providers() {
- _deprecated_function( __FUNCTION__, '14.9.1', 'wp_fonts()->get_providers()' );
-
- $providers = array();
- foreach ( wp_fonts()->get_providers() as $id => $config ) {
- $providers[ $id ] = $config['class'];
- }
-
- return $providers;
- }
-}
-
-if ( ! function_exists( 'wp_register_webfont_provider' ) ) {
- /**
- * Registers a custom font service provider.
- *
- * A webfont provider contains the business logic for how to
- * interact with a remote font service and how to generate
- * the `@font-face` styles for that remote service.
- *
- * How to register a custom font service provider:
- * 1. Load its class file into memory before registration.
- * 2. Pass the class' name to this function.
- *
- * For example, for a class named `My_Custom_Font_Service_Provider`:
- * ```
- * wp_register_webfont_provider( My_Custom_Font_Service_Provider::class );
- * ```
- *
- * @since X.X.X
- * @deprecated GB 15.1 Use wp_register_font_provider() instead.
- *
- * @param string $name The provider's name.
- * @param string $classname The provider's class name.
- * The class should be a child of `WP_Webfonts_Provider`.
- * See {@see WP_Webfonts_Provider}.
- *
- * @return bool True if successfully registered, else false.
- */
- function wp_register_webfont_provider( $name, $classname ) {
- _deprecated_function( __FUNCTION__, 'GB 15.1', 'wp_register_font_provider' );
-
- return wp_register_font_provider( $name, $classname );
- }
-}
-
-if ( ! function_exists( 'wp_print_webfonts' ) ) {
- /**
- * Invokes each provider to process and print its styles.
- *
- * @since GB 14.9.1
- * @deprecated GB 15.1 Use wp_print_fonts() instead.
- *
- * @param string|string[]|false $handles Optional. Items to be processed: queue (false),
- * single item (string), or multiple items (array of strings).
- * Default false.
- * @return array|string[] Array of web font handles that have been processed.
- * An empty array if none were processed.
- */
- function wp_print_webfonts( $handles = false ) {
- _deprecated_function( __FUNCTION__, 'GB 15.1', 'wp_print_fonts' );
-
- return wp_print_fonts( $handles );
- }
-}
diff --git a/lib/experimental/fonts-api/class-wp-fonts-provider-local.php b/lib/experimental/fonts-api/class-wp-fonts-provider-local.php
deleted file mode 100644
index 019861a9ae191f..00000000000000
--- a/lib/experimental/fonts-api/class-wp-fonts-provider-local.php
+++ /dev/null
@@ -1,236 +0,0 @@
-style_tag_atts = array( 'type' => 'text/css' );
- }
- }
-
- /**
- * Gets the `@font-face` CSS styles for locally-hosted font files.
- *
- * This method does the following processing tasks:
- * 1. Orchestrates an optimized `src` (with format) for browser support.
- * 2. Generates the `@font-face` for all its fonts.
- *
- * @since X.X.X
- *
- * @return string The `@font-face` CSS.
- */
- public function get_css() {
- $css = '';
-
- foreach ( $this->fonts as $font ) {
- // Order the font's `src` items to optimize for browser support.
- $font = $this->order_src( $font );
-
- // Build the @font-face CSS for this font.
- $css .= '@font-face{' . $this->build_font_face_css( $font ) . '}';
- }
-
- return $css;
- }
-
- /**
- * Order `src` items to optimize for browser support.
- *
- * @since X.X.X
- *
- * @param array $font Font to process.
- * @return array
- */
- private function order_src( array $font ) {
- if ( ! is_array( $font['src'] ) ) {
- $font['src'] = (array) $font['src'];
- }
-
- $src = array();
- $src_ordered = array();
-
- foreach ( $font['src'] as $url ) {
- // Add data URIs first.
- if ( str_starts_with( trim( $url ), 'data:' ) ) {
- $src_ordered[] = array(
- 'url' => $url,
- 'format' => 'data',
- );
- continue;
- }
- $format = pathinfo( $url, PATHINFO_EXTENSION );
- $src[ $format ] = $url;
- }
-
- // Add woff2.
- if ( ! empty( $src['woff2'] ) ) {
- $src_ordered[] = array(
- 'url' => $src['woff2'],
- 'format' => 'woff2',
- );
- }
-
- // Add woff.
- if ( ! empty( $src['woff'] ) ) {
- $src_ordered[] = array(
- 'url' => $src['woff'],
- 'format' => 'woff',
- );
- }
-
- // Add ttf.
- if ( ! empty( $src['ttf'] ) ) {
- $src_ordered[] = array(
- 'url' => $src['ttf'],
- 'format' => 'truetype',
- );
- }
-
- // Add eot.
- if ( ! empty( $src['eot'] ) ) {
- $src_ordered[] = array(
- 'url' => $src['eot'],
- 'format' => 'embedded-opentype',
- );
- }
-
- // Add otf.
- if ( ! empty( $src['otf'] ) ) {
- $src_ordered[] = array(
- 'url' => $src['otf'],
- 'format' => 'opentype',
- );
- }
- $font['src'] = $src_ordered;
-
- return $font;
- }
-
- /**
- * Builds the font-family's CSS.
- *
- * @since X.X.X
- *
- * @param array $font Font to process.
- * @return string This font-family's CSS.
- */
- private function build_font_face_css( array $font ) {
- $css = '';
-
- // Wrap font-family in quotes if it contains spaces
- // and is not already wrapped in quotes.
- if (
- str_contains( $font['font-family'], ' ' ) &&
- ! str_contains( $font['font-family'], '"' ) &&
- ! str_contains( $font['font-family'], "'" )
- ) {
- $font['font-family'] = '"' . $font['font-family'] . '"';
- }
-
- foreach ( $font as $key => $value ) {
-
- // Skip "provider", since it's for internal API use,
- // and not a valid CSS property.
- if ( 'provider' === $key ) {
- continue;
- }
-
- // Compile the "src" parameter.
- if ( 'src' === $key ) {
- $value = $this->compile_src( $value );
- }
-
- // If font-variation-settings is an array, convert it to a string.
- if ( 'font-variation-settings' === $key && is_array( $value ) ) {
- $value = $this->compile_variations( $value );
- }
-
- if ( ! empty( $value ) ) {
- $css .= "$key:$value;";
- }
- }
-
- return $css;
- }
-
- /**
- * Compiles the `src` into valid CSS.
- *
- * @since X.X.X
- *
- * @param array $value Value to process.
- * @return string The CSS.
- */
- private function compile_src( array $value ) {
- $src = '';
-
- foreach ( $value as $item ) {
- $src .= ( 'data' === $item['format'] )
- ? ", url({$item['url']})"
- : ", url('{$item['url']}') format('{$item['format']}')";
- }
-
- $src = ltrim( $src, ', ' );
- return $src;
- }
-
- /**
- * Compiles the font variation settings.
- *
- * @since X.X.X
- *
- * @param array $font_variation_settings Array of font variation settings.
- * @return string The CSS.
- */
- private function compile_variations( array $font_variation_settings ) {
- $variations = '';
-
- foreach ( $font_variation_settings as $key => $value ) {
- $variations .= "$key $value";
- }
-
- return $variations;
- }
-}
diff --git a/lib/experimental/fonts-api/class-wp-fonts-provider.php b/lib/experimental/fonts-api/class-wp-fonts-provider.php
deleted file mode 100644
index b59c59d6b8938f..00000000000000
--- a/lib/experimental/fonts-api/class-wp-fonts-provider.php
+++ /dev/null
@@ -1,129 +0,0 @@
-fonts = $fonts;
- }
-
- /**
- * Prints the generated styles.
- *
- * @since X.X.X
- */
- public function print_styles() {
- printf(
- $this->get_style_element(),
- $this->get_css()
- );
- }
-
- /**
- * Gets the `@font-face` CSS for the provider's fonts.
- *
- * This method is where the provider does it processing to build the
- * needed `@font-face` CSS for all of its fonts. Specifics of how
- * this processing is done is contained in each provider.
- *
- * @since X.X.X
- *
- * @return string The `@font-face` CSS.
- */
- abstract public function get_css();
-
- /**
- * Gets the `\n";
- }
-
- /**
- * Gets the defined
diff --git a/packages/edit-site/src/components/save-button/index.js b/packages/edit-site/src/components/save-button/index.js
index e7eac94de5f314..461264bd5a5633 100644
--- a/packages/edit-site/src/components/save-button/index.js
+++ b/packages/edit-site/src/components/save-button/index.js
@@ -34,9 +34,7 @@ export default function SaveButton( {
const dirtyEntityRecords = __experimentalGetDirtyEntityRecords();
const { isSaveViewOpened } = select( editSiteStore );
const isActivatingTheme = isResolving( 'activateTheme' );
- const previewingTheme = select( coreStore ).getTheme(
- currentlyPreviewingTheme()
- );
+ const currentlyPreviewingThemeId = currentlyPreviewingTheme();
return {
isDirty: dirtyEntityRecords.length > 0,
@@ -49,7 +47,12 @@ export default function SaveButton( {
)
) || isActivatingTheme,
isSaveViewOpen: isSaveViewOpened(),
- previewingThemeName: previewingTheme?.name?.rendered,
+ // Do not call `getTheme` with null, it will cause a request to
+ // the server.
+ previewingThemeName: currentlyPreviewingThemeId
+ ? select( coreStore ).getTheme( currentlyPreviewingThemeId )
+ ?.name?.rendered
+ : undefined,
};
}, [] );
const { setIsSaveViewOpened } = useDispatch( editSiteStore );
diff --git a/packages/edit-site/src/components/save-panel/index.js b/packages/edit-site/src/components/save-panel/index.js
index 5c1bcc1df281e0..2840191b4849f1 100644
--- a/packages/edit-site/src/components/save-panel/index.js
+++ b/packages/edit-site/src/components/save-panel/index.js
@@ -23,10 +23,8 @@ import { store as coreStore } from '@wordpress/core-data';
import { store as editSiteStore } from '../../store';
import { unlock } from '../../lock-unlock';
import { useActivateTheme } from '../../utils/use-activate-theme';
-import {
- currentlyPreviewingTheme,
- isPreviewingTheme,
-} from '../../utils/is-previewing-theme';
+import { useActualCurrentTheme } from '../../utils/use-actual-current-theme';
+import { isPreviewingTheme } from '../../utils/is-previewing-theme';
const { EntitiesSavedStatesExtensible } = unlock( privateApis );
@@ -39,19 +37,22 @@ const EntitiesSavedStatesForPreview = ( { onClose } ) => {
activateSaveLabel = __( 'Activate' );
}
- const themeName = useSelect( ( select ) => {
- const theme = select( coreStore ).getTheme(
- currentlyPreviewingTheme()
- );
+ const currentTheme = useActualCurrentTheme();
- return theme?.name?.rendered;
- }, [] );
+ const previewingTheme = useSelect(
+ ( select ) => select( coreStore ).getCurrentTheme(),
+ []
+ );
const additionalPrompt = (
{ sprintf(
- 'Saving your changes will change your active theme to %s.',
- themeName
+ /* translators: %1$s: The name of active theme, %2$s: The name of theme to be activated. */
+ __(
+ 'Saving your changes will change your active theme from %1$s to %2$s.'
+ ),
+ currentTheme?.name?.rendered ?? '...',
+ previewingTheme?.name?.rendered ?? '...'
) }
);
diff --git a/packages/edit-site/src/components/sidebar-dataviews/dataview-item.js b/packages/edit-site/src/components/sidebar-dataviews/dataview-item.js
index cbcb4f2f8ed59c..586d1b602e0df2 100644
--- a/packages/edit-site/src/components/sidebar-dataviews/dataview-item.js
+++ b/packages/edit-site/src/components/sidebar-dataviews/dataview-item.js
@@ -29,7 +29,7 @@ export default function DataViewItem( {
suffix,
} ) {
const {
- params: { path },
+ params: { path, layout },
} = useLocation();
const iconToUse =
@@ -37,6 +37,7 @@ export default function DataViewItem( {
const linkInfo = useLink( {
path,
+ layout,
activeView: isCustom === 'true' ? customViewId : slug,
isCustom,
} );
diff --git a/packages/edit-site/src/components/sidebar-dataviews/default-views.js b/packages/edit-site/src/components/sidebar-dataviews/default-views.js
index fe9f046f31972f..d6e7ebe72df21a 100644
--- a/packages/edit-site/src/components/sidebar-dataviews/default-views.js
+++ b/packages/edit-site/src/components/sidebar-dataviews/default-views.js
@@ -15,7 +15,9 @@ import {
} from '../../utils/constants';
export const DEFAULT_CONFIG_PER_VIEW_TYPE = {
- [ LAYOUT_TABLE ]: {},
+ [ LAYOUT_TABLE ]: {
+ primaryField: 'title',
+ },
[ LAYOUT_GRID ]: {
mediaField: 'featured-image',
primaryField: 'title',
@@ -27,7 +29,7 @@ export const DEFAULT_CONFIG_PER_VIEW_TYPE = {
};
const DEFAULT_PAGE_BASE = {
- type: LAYOUT_LIST,
+ type: LAYOUT_TABLE,
search: '',
filters: [],
page: 1,
diff --git a/packages/edit-site/src/components/sidebar-dataviews/index.js b/packages/edit-site/src/components/sidebar-dataviews/index.js
index 9748600907e331..caf838b0857866 100644
--- a/packages/edit-site/src/components/sidebar-dataviews/index.js
+++ b/packages/edit-site/src/components/sidebar-dataviews/index.js
@@ -15,7 +15,7 @@ import DataViewItem from './dataview-item';
import CustomDataViewsList from './custom-dataviews-list';
const PATH_TO_TYPE = {
- '/page': 'page',
+ '/pages': 'page',
};
export default function DataViewsSidebarContent() {
@@ -47,11 +47,13 @@ export default function DataViewsSidebarContent() {
);
} ) }
-
+ { window?.__experimentalAdminViews && (
+
+ ) }
>
);
}
diff --git a/packages/edit-site/src/components/sidebar-navigation-item/style.scss b/packages/edit-site/src/components/sidebar-navigation-item/style.scss
index 88ff27a9c1d2f0..908056d52af48c 100644
--- a/packages/edit-site/src/components/sidebar-navigation-item/style.scss
+++ b/packages/edit-site/src/components/sidebar-navigation-item/style.scss
@@ -33,5 +33,5 @@
.edit-site-sidebar-navigation-screen__content .block-editor-list-view-block-select-button {
cursor: grab;
- padding: $grid-unit-10;
+ padding: $grid-unit-10 $grid-unit-10 $grid-unit-10 0;
}
diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-pages-dataviews/index.js b/packages/edit-site/src/components/sidebar-navigation-screen-pages-dataviews/index.js
index 171d59c108e9b8..3bb3c9c0c8fe02 100644
--- a/packages/edit-site/src/components/sidebar-navigation-screen-pages-dataviews/index.js
+++ b/packages/edit-site/src/components/sidebar-navigation-screen-pages-dataviews/index.js
@@ -7,9 +7,10 @@ import {
} from '@wordpress/components';
import { layout } from '@wordpress/icons';
import { useMemo } from '@wordpress/element';
-import { useEntityRecords } from '@wordpress/core-data';
+import { useEntityRecords, store as coreStore } from '@wordpress/core-data';
import { decodeEntities } from '@wordpress/html-entities';
import { __ } from '@wordpress/i18n';
+import { useSelect } from '@wordpress/data';
/**
* Internal dependencies
@@ -41,18 +42,43 @@ export default function SidebarNavigationScreenPagesDataViews() {
per_page: -1,
}
);
- const templates = useMemo(
- () =>
- templateRecords?.filter( ( { slug } ) =>
+
+ const { frontPage, postsPage } = useSelect( ( select ) => {
+ const { getEntityRecord } = select( coreStore );
+ const siteSettings = getEntityRecord( 'root', 'site' );
+ return {
+ frontPage: siteSettings?.page_on_front,
+ postsPage: siteSettings?.page_for_posts,
+ };
+ }, [] );
+
+ const templates = useMemo( () => {
+ if ( ! templateRecords ) {
+ return [];
+ }
+
+ const isHomePageBlog = frontPage === postsPage;
+ const homeTemplate =
+ templateRecords?.find(
+ ( template ) => template.slug === 'front-page'
+ ) ||
+ templateRecords?.find( ( template ) => template.slug === 'home' ) ||
+ templateRecords?.find( ( template ) => template.slug === 'index' );
+
+ return [
+ isHomePageBlog ? homeTemplate : null,
+ ...templateRecords?.filter( ( { slug } ) =>
[ '404', 'search' ].includes( slug )
),
- [ templateRecords ]
- );
+ ].filter( Boolean );
+ }, [ templateRecords, frontPage, postsPage ] );
return (
}
+ backPath="/page"
footer={
{ templates?.map( ( item ) => (
diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-pages/index.js b/packages/edit-site/src/components/sidebar-navigation-screen-pages/index.js
index e9a6163a0047e9..82fc6d0fa2412b 100644
--- a/packages/edit-site/src/components/sidebar-navigation-screen-pages/index.js
+++ b/packages/edit-site/src/components/sidebar-navigation-screen-pages/index.js
@@ -137,14 +137,6 @@ export default function SidebarNavigationScreenPages() {
};
const pagesLink = useLink( { path: '/pages' } );
- const manageAllPagesProps = window?.__experimentalAdminViews
- ? { ...pagesLink }
- : {
- href: 'edit.php?post_type=page',
- onClick: () => {
- document.location = 'edit.php?post_type=page';
- },
- };
return (
<>
@@ -156,7 +148,7 @@ export default function SidebarNavigationScreenPages() {
) }
{ __( 'Manage all pages' ) }
diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-templates-browse/content.js b/packages/edit-site/src/components/sidebar-navigation-screen-templates-browse/content.js
new file mode 100644
index 00000000000000..2188fade413dbb
--- /dev/null
+++ b/packages/edit-site/src/components/sidebar-navigation-screen-templates-browse/content.js
@@ -0,0 +1,73 @@
+/**
+ * WordPress dependencies
+ */
+import { useEntityRecords } from '@wordpress/core-data';
+import { useMemo } from '@wordpress/element';
+import { __experimentalItemGroup as ItemGroup } from '@wordpress/components';
+
+/**
+ * Internal dependencies
+ */
+import DataViewItem from '../sidebar-dataviews/dataview-item';
+import { useAddedBy } from '../list/added-by';
+import { layout } from '@wordpress/icons';
+
+const EMPTY_ARRAY = [];
+
+function TemplateDataviewItem( { template, isActive } ) {
+ const { text, icon } = useAddedBy( template.type, template.id );
+ return (
+
+ );
+}
+
+export default function DataviewsTemplatesSidebarContent( {
+ activeView,
+ postType,
+ config,
+} ) {
+ const { records } = useEntityRecords( 'postType', postType, {
+ per_page: -1,
+ } );
+ const firstItemPerAuthorText = useMemo( () => {
+ const firstItemPerAuthor = records?.reduce( ( acc, template ) => {
+ const author = template.author_text;
+ if ( author && ! acc[ author ] ) {
+ acc[ author ] = template;
+ }
+ return acc;
+ }, {} );
+ return (
+ ( firstItemPerAuthor && Object.values( firstItemPerAuthor ) ) ??
+ EMPTY_ARRAY
+ );
+ }, [ records ] );
+
+ return (
+
+
+ { firstItemPerAuthorText.map( ( template ) => {
+ return (
+
+ );
+ } ) }
+
+ );
+}
diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-templates-browse/index.js b/packages/edit-site/src/components/sidebar-navigation-screen-templates-browse/index.js
index a07e08a44f8b41..a97bb8e8030594 100644
--- a/packages/edit-site/src/components/sidebar-navigation-screen-templates-browse/index.js
+++ b/packages/edit-site/src/components/sidebar-navigation-screen-templates-browse/index.js
@@ -3,6 +3,7 @@
*/
import { __ } from '@wordpress/i18n';
import { useSelect } from '@wordpress/data';
+
import { __experimentalUseNavigator as useNavigator } from '@wordpress/components';
import { privateApis as routerPrivateApis } from '@wordpress/router';
@@ -16,6 +17,7 @@ import {
TEMPLATE_PART_POST_TYPE,
} from '../../utils/constants';
import { unlock } from '../../lock-unlock';
+import DataviewsTemplatesSidebarContent from './content';
const config = {
[ TEMPLATE_POST_TYPE ]: {
@@ -40,7 +42,7 @@ export default function SidebarNavigationScreenTemplatesBrowse() {
params: { postType },
} = useNavigator();
const {
- params: { didAccessPatternsPage },
+ params: { didAccessPatternsPage, activeView = 'all' },
} = useLocation();
const isTemplatePartsMode = useSelect( ( select ) => {
@@ -56,6 +58,13 @@ export default function SidebarNavigationScreenTemplatesBrowse() {
title={ config[ postType ].title }
description={ config[ postType ].description }
backPath={ config[ postType ].backPath }
+ content={
+
+ }
/>
);
}
diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-templates/index.js b/packages/edit-site/src/components/sidebar-navigation-screen-templates/index.js
index 527cb37ceddaf7..3ff934ac100a88 100644
--- a/packages/edit-site/src/components/sidebar-navigation-screen-templates/index.js
+++ b/packages/edit-site/src/components/sidebar-navigation-screen-templates/index.js
@@ -4,6 +4,7 @@
import {
__experimentalItemGroup as ItemGroup,
__experimentalItem as Item,
+ __experimentalVStack as VStack,
} from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import { useEntityRecords } from '@wordpress/core-data';
@@ -30,20 +31,11 @@ const TemplateItem = ( { postType, postId, ...props } ) => {
export default function SidebarNavigationScreenTemplates() {
const isMobileViewport = useViewportMatch( 'medium', '<' );
-
const { records: templates, isResolving: isLoading } = useEntityRecords(
'postType',
TEMPLATE_POST_TYPE,
- {
- per_page: -1,
- }
- );
-
- const sortedTemplates = templates ? [ ...templates ] : [];
- sortedTemplates.sort( ( a, b ) =>
- a.title.rendered.localeCompare( b.title.rendered )
+ { per_page: -1 }
);
-
const browseAllLink = useLink( { path: '/wp_template/all' } );
const canCreate = ! isMobileViewport;
return (
@@ -66,24 +58,7 @@ export default function SidebarNavigationScreenTemplates() {
<>
{ isLoading && __( 'Loading templates…' ) }
{ ! isLoading && (
-
- { ! templates?.length && (
- - { __( 'No templates found' ) }
- ) }
- { sortedTemplates.map( ( template ) => (
-
- { decodeEntities(
- template.title?.rendered ||
- template.slug
- ) }
-
- ) ) }
-
+
) }
>
}
@@ -97,3 +72,85 @@ export default function SidebarNavigationScreenTemplates() {
/>
);
}
+
+function TemplatesGroup( { title, templates } ) {
+ return (
+
+ { !! title && (
+ -
+ { title }
+
+ ) }
+ { templates.map( ( template ) => (
+
+ { decodeEntities(
+ template.title?.rendered || template.slug
+ ) }
+
+ ) ) }
+
+ );
+}
+function SidebarTemplatesList( { templates } ) {
+ if ( ! templates?.length ) {
+ return (
+
+ - { __( 'No templates found' ) }
+
+ );
+ }
+ const sortedTemplates = templates ? [ ...templates ] : [];
+ sortedTemplates.sort( ( a, b ) =>
+ a.title.rendered.localeCompare( b.title.rendered )
+ );
+ const { hierarchyTemplates, customTemplates, ...plugins } =
+ sortedTemplates.reduce(
+ ( accumulator, template ) => {
+ const {
+ original_source: originalSource,
+ author_text: authorText,
+ } = template;
+ if ( originalSource === 'plugin' ) {
+ if ( ! accumulator[ authorText ] ) {
+ accumulator[ authorText ] = [];
+ }
+ accumulator[ authorText ].push( template );
+ } else if ( template.is_custom ) {
+ accumulator.customTemplates.push( template );
+ } else {
+ accumulator.hierarchyTemplates.push( template );
+ }
+ return accumulator;
+ },
+ { hierarchyTemplates: [], customTemplates: [] }
+ );
+ return (
+
+ { !! hierarchyTemplates.length && (
+
+ ) }
+ { !! customTemplates.length && (
+
+ ) }
+ { Object.entries( plugins ).map(
+ ( [ plugin, pluginTemplates ] ) => {
+ return (
+
+ );
+ }
+ ) }
+
+ );
+}
diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-templates/style.scss b/packages/edit-site/src/components/sidebar-navigation-screen-templates/style.scss
new file mode 100644
index 00000000000000..ec2b7744d4e233
--- /dev/null
+++ b/packages/edit-site/src/components/sidebar-navigation-screen-templates/style.scss
@@ -0,0 +1,9 @@
+.edit-site-sidebar-navigation-screen-templates__templates-group-title.components-item {
+ text-transform: uppercase;
+ color: $gray-200;
+ // 6px right padding to align with + button
+ padding: $grid-unit-30 6px $grid-unit-20 $grid-unit-20;
+ border-top: 1px solid $gray-800;
+ font-size: 11px;
+ font-weight: 500;
+}
diff --git a/packages/edit-site/src/components/sidebar-navigation-screen/index.js b/packages/edit-site/src/components/sidebar-navigation-screen/index.js
index 9ab6f58c81b212..becefe8841fa6f 100644
--- a/packages/edit-site/src/components/sidebar-navigation-screen/index.js
+++ b/packages/edit-site/src/components/sidebar-navigation-screen/index.js
@@ -41,17 +41,26 @@ export default function SidebarNavigationScreen( {
description,
backPath: backPathProp,
} ) {
- const { dashboardLink, dashboardLinkText } = useSelect( ( select ) => {
- const { getSettings } = unlock( select( editSiteStore ) );
- return {
- dashboardLink: getSettings().__experimentalDashboardLink,
- dashboardLinkText: getSettings().__experimentalDashboardLinkText,
- };
- }, [] );
- const { getTheme } = useSelect( coreStore );
+ const { dashboardLink, dashboardLinkText, previewingThemeName } = useSelect(
+ ( select ) => {
+ const { getSettings } = unlock( select( editSiteStore ) );
+ const currentlyPreviewingThemeId = currentlyPreviewingTheme();
+ return {
+ dashboardLink: getSettings().__experimentalDashboardLink,
+ dashboardLinkText:
+ getSettings().__experimentalDashboardLinkText,
+ // Do not call `getTheme` with null, it will cause a request to
+ // the server.
+ previewingThemeName: currentlyPreviewingThemeId
+ ? select( coreStore ).getTheme( currentlyPreviewingThemeId )
+ ?.name?.rendered
+ : undefined,
+ };
+ },
+ []
+ );
const location = useLocation();
const navigator = useNavigator();
- const theme = getTheme( currentlyPreviewingTheme() );
const icon = isRTL() ? chevronRight : chevronLeft;
return (
@@ -108,7 +117,7 @@ export default function SidebarNavigationScreen( {
? title
: sprintf(
'Previewing %1$s: %2$s',
- theme?.name?.rendered,
+ previewingThemeName,
title
) }
diff --git a/packages/edit-site/src/components/sidebar/index.js b/packages/edit-site/src/components/sidebar/index.js
index 73c6aea7e328c5..19cd55ae6fac2d 100644
--- a/packages/edit-site/src/components/sidebar/index.js
+++ b/packages/edit-site/src/components/sidebar/index.js
@@ -66,11 +66,10 @@ function SidebarScreens() {
- { window?.__experimentalAdminViews ? (
-
- ) : (
-
- ) }
+
+
+
+
diff --git a/packages/edit-site/src/components/site-hub/index.js b/packages/edit-site/src/components/site-hub/index.js
index 7af0d53090c578..f79df2df367090 100644
--- a/packages/edit-site/src/components/site-hub/index.js
+++ b/packages/edit-site/src/components/site-hub/index.js
@@ -180,7 +180,10 @@ const SiteHub = memo( ( { isTransparent, className } ) => {
'View site (opens in a new tab)'
) }
icon={ external }
- className="edit-site-site-hub__site-view-link"
+ className={ classnames(
+ 'edit-site-site-hub__site-view-link',
+ { 'is-transparent': isTransparent }
+ ) }
/>
) }
diff --git a/packages/edit-site/src/components/site-hub/style.scss b/packages/edit-site/src/components/site-hub/style.scss
index 0fda40c2e8a9a0..ef063c39841fc8 100644
--- a/packages/edit-site/src/components/site-hub/style.scss
+++ b/packages/edit-site/src/components/site-hub/style.scss
@@ -9,6 +9,7 @@
}
.edit-site-site-hub__site-title,
+ .edit-site-site-hub__site-view-link,
.edit-site-site-hub_toggle-command-center {
transition: opacity ease 0.1s;
@@ -20,22 +21,10 @@
.edit-site-site-hub__site-view-link {
flex-grow: 0;
margin-right: var(--wp-admin-border-width-focus);
- @include break-mobile() {
- opacity: 0;
- transition: opacity 0.2s ease-in-out;
- }
- &:focus {
- opacity: 1;
- }
svg {
fill: $gray-200;
}
}
- &:hover {
- .edit-site-site-hub__site-view-link {
- opacity: 1;
- }
- }
}
.edit-site-site-hub__post-type {
diff --git a/packages/edit-site/src/components/site-icon/style.scss b/packages/edit-site/src/components/site-icon/style.scss
index ac16279c9fe184..fc680166bf2691 100644
--- a/packages/edit-site/src/components/site-icon/style.scss
+++ b/packages/edit-site/src/components/site-icon/style.scss
@@ -9,7 +9,7 @@
object-fit: cover;
background: #333;
- .edit-site-layout.is-full-canvas.is-edit-mode & {
+ .edit-site-layout.is-full-canvas & {
border-radius: 0;
}
}
diff --git a/packages/edit-site/src/components/sync-state-with-url/use-init-edited-entity-from-url.js b/packages/edit-site/src/components/sync-state-with-url/use-init-edited-entity-from-url.js
index 7b1321fdf4b8ac..839996e2ebdf9c 100644
--- a/packages/edit-site/src/components/sync-state-with-url/use-init-edited-entity-from-url.js
+++ b/packages/edit-site/src/components/sync-state-with-url/use-init-edited-entity-from-url.js
@@ -27,7 +27,7 @@ const postTypesWithoutParentTemplate = [
PATTERN_TYPES.user,
];
-function useResolveEditedEntityAndContext( { postId, postType } ) {
+function useResolveEditedEntityAndContext( { path, postId, postType } ) {
const { hasLoadedAllDependencies, homepageId, url, frontPageTemplateId } =
useSelect( ( select ) => {
const { getSite, getUnstableBase, getEntityRecords } =
@@ -128,10 +128,25 @@ function useResolveEditedEntityAndContext( { postId, postType } ) {
return currentTemplate.id;
}
}
-
// If no template is assigned, use the default template.
+ let slugToCheck;
+ // In `draft` status we might not have a slug available, so we use the `single`
+ // post type templates slug(ex page, single-post, single-product etc..).
+ // Pages do not need the `single` prefix in the slug to be prioritized
+ // through template hierarchy.
+ if ( editedEntity.slug ) {
+ slugToCheck =
+ postTypeToResolve === 'page'
+ ? `${ postTypeToResolve }-${ editedEntity.slug }`
+ : `single-${ postTypeToResolve }-${ editedEntity.slug }`;
+ } else {
+ slugToCheck =
+ postTypeToResolve === 'page'
+ ? 'page'
+ : `single-${ postTypeToResolve }`;
+ }
return getDefaultTemplateId( {
- slug: `${ postTypeToResolve }-${ editedEntity?.slug }`,
+ slug: slugToCheck,
} );
}
@@ -144,6 +159,11 @@ function useResolveEditedEntityAndContext( { postId, postType } ) {
return resolveTemplateForPostTypeAndId( postType, postId );
}
+ // Some URLs in list views are different
+ if ( path === '/pages' && postId ) {
+ return resolveTemplateForPostTypeAndId( 'page', postId );
+ }
+
// If we're rendering the home page, and we have a static home page, resolve its template.
if ( homepageId ) {
return resolveTemplateForPostTypeAndId( 'page', homepageId );
@@ -161,6 +181,7 @@ function useResolveEditedEntityAndContext( { postId, postType } ) {
url,
postId,
postType,
+ path,
frontPageTemplateId,
]
);
@@ -174,12 +195,25 @@ function useResolveEditedEntityAndContext( { postId, postType } ) {
return { postType, postId };
}
+ // Some URLs in list views are different
+ if ( path === '/pages' && postId ) {
+ return { postType: 'page', postId };
+ }
+
if ( homepageId ) {
return { postType: 'page', postId: homepageId };
}
return {};
- }, [ homepageId, postType, postId ] );
+ }, [ homepageId, postType, postId, path ] );
+
+ if ( path === '/wp_template/all' && postId ) {
+ return { isReady: true, postType: 'wp_template', postId, context };
+ }
+
+ if ( path === '/wp_template_part/all' && postId ) {
+ return { isReady: true, postType: 'wp_template_part', postId, context };
+ }
if ( postTypesWithoutParentTemplate.includes( postType ) ) {
return { isReady: true, postType, postId, context };
@@ -197,7 +231,8 @@ function useResolveEditedEntityAndContext( { postId, postType } ) {
return { isReady: false };
}
-export function useInitEditedEntity( params ) {
+export default function useInitEditedEntityFromURL() {
+ const { params = {} } = useLocation();
const { postType, postId, context, isReady } =
useResolveEditedEntityAndContext( params );
@@ -209,8 +244,3 @@ export function useInitEditedEntity( params ) {
}
}, [ isReady, postType, postId, context, setEditedEntity ] );
}
-
-export default function useInitEditedEntityFromURL() {
- const { params = {} } = useLocation();
- return useInitEditedEntity( params );
-}
diff --git a/packages/edit-site/src/components/sync-state-with-url/use-sync-path-with-url.js b/packages/edit-site/src/components/sync-state-with-url/use-sync-path-with-url.js
index 5f176dff8198d3..3f57a62fba7287 100644
--- a/packages/edit-site/src/components/sync-state-with-url/use-sync-path-with-url.js
+++ b/packages/edit-site/src/components/sync-state-with-url/use-sync-path-with-url.js
@@ -82,6 +82,7 @@ export default function useSyncPathWithURL() {
postType: navigatorParams?.postType,
postId: navigatorParams?.postId,
path: undefined,
+ layout: undefined,
} );
} else if (
navigatorLocation.path.startsWith( '/page/' ) &&
@@ -91,6 +92,7 @@ export default function useSyncPathWithURL() {
postType: 'page',
postId: navigatorParams?.postId,
path: undefined,
+ layout: undefined,
} );
} else if ( navigatorLocation.path === '/patterns' ) {
updateUrlParams( {
@@ -99,12 +101,42 @@ export default function useSyncPathWithURL() {
canvas: undefined,
path: navigatorLocation.path,
} );
+ } else if (
+ navigatorLocation.path === '/wp_template/all' &&
+ ! window?.__experimentalAdminViews
+ ) {
+ // When the experiment is disabled, we only support table layout.
+ // Clear it out from the URL, so layouts other than table cannot be accessed.
+ updateUrlParams( {
+ postType: undefined,
+ categoryType: undefined,
+ categoryId: undefined,
+ path: navigatorLocation.path,
+ layout: undefined,
+ } );
+ } else if (
+ // These sidebar paths are special in the sense that the url in these pages may or may not have a postId and we need to retain it if it has.
+ // The "type" property should be kept as well.
+ ( navigatorLocation.path === '/pages' &&
+ window?.__experimentalAdminViews ) ||
+ ( navigatorLocation.path === '/wp_template/all' &&
+ window?.__experimentalAdminViews ) ||
+ ( navigatorLocation.path === '/wp_template_part/all' &&
+ window?.__experimentalAdminViews )
+ ) {
+ updateUrlParams( {
+ postType: undefined,
+ categoryType: undefined,
+ categoryId: undefined,
+ path: navigatorLocation.path,
+ } );
} else {
updateUrlParams( {
postType: undefined,
postId: undefined,
categoryType: undefined,
categoryId: undefined,
+ layout: undefined,
path:
navigatorLocation.path === '/'
? undefined
diff --git a/packages/edit-site/src/hooks/commands/use-edit-mode-commands.js b/packages/edit-site/src/hooks/commands/use-edit-mode-commands.js
index 0265329f40b095..0febc2379a8b4c 100644
--- a/packages/edit-site/src/hooks/commands/use-edit-mode-commands.js
+++ b/packages/edit-site/src/hooks/commands/use-edit-mode-commands.js
@@ -216,6 +216,8 @@ function useEditUICommands() {
showBlockBreadcrumbs,
isListViewOpen,
isDistractionFree,
+ isTopToolbar,
+ isFocusMode,
} = useSelect( ( select ) => {
const { get } = select( preferencesStore );
const { getEditorMode } = select( editSiteStore );
@@ -229,6 +231,8 @@ function useEditUICommands() {
showBlockBreadcrumbs: get( 'core', 'showBlockBreadcrumbs' ),
isListViewOpen: isListViewOpened(),
isDistractionFree: get( 'core', 'distractionFree' ),
+ isFocusMode: get( 'core', 'focusMode' ),
+ isTopToolbar: get( 'core', 'fixedToolbar' ),
};
}, [] );
const { openModal } = useDispatch( interfaceStore );
@@ -271,16 +275,33 @@ function useEditUICommands() {
commands.push( {
name: 'core/toggle-spotlight-mode',
- label: __( 'Toggle spotlight mode' ),
+ label: __( 'Toggle spotlight' ),
callback: ( { close } ) => {
toggle( 'core', 'focusMode' );
close();
+ createInfoNotice(
+ isFocusMode ? __( 'Spotlight off.' ) : __( 'Spotlight on.' ),
+ {
+ id: 'core/edit-site/toggle-spotlight-mode/notice',
+ type: 'snackbar',
+ actions: [
+ {
+ label: __( 'Undo' ),
+ onClick: () => {
+ toggle( 'core', 'focusMode' );
+ },
+ },
+ ],
+ }
+ );
},
} );
commands.push( {
name: 'core/toggle-distraction-free',
- label: __( 'Toggle distraction free' ),
+ label: isDistractionFree
+ ? __( 'Exit Distraction Free' )
+ : __( 'Enter Distraction Free ' ),
callback: ( { close } ) => {
toggleDistractionFree();
close();
@@ -296,6 +317,23 @@ function useEditUICommands() {
toggleDistractionFree();
}
close();
+ createInfoNotice(
+ isTopToolbar
+ ? __( 'Top toolbar off.' )
+ : __( 'Top toolbar on.' ),
+ {
+ id: 'core/edit-site/toggle-top-toolbar/notice',
+ type: 'snackbar',
+ actions: [
+ {
+ label: __( 'Undo' ),
+ onClick: () => {
+ toggle( 'core', 'fixedToolbar' );
+ },
+ },
+ ],
+ }
+ );
},
} );
@@ -350,11 +388,20 @@ function useEditUICommands() {
commands.push( {
name: 'core/toggle-list-view',
- label: __( 'Toggle list view' ),
+ label: isListViewOpen
+ ? __( 'Close List View' )
+ : __( 'Open List View' ),
icon: listView,
callback: ( { close } ) => {
setIsListViewOpened( ! isListViewOpen );
close();
+ createInfoNotice(
+ isListViewOpen ? __( 'List View off.' ) : __( 'List View on.' ),
+ {
+ id: 'core/edit-site/toggle-list-view/notice',
+ type: 'snackbar',
+ }
+ );
},
} );
diff --git a/packages/edit-site/src/hooks/push-changes-to-global-styles/index.js b/packages/edit-site/src/hooks/push-changes-to-global-styles/index.js
index 0407b85c4e8bcd..4844ec6d03eb5e 100644
--- a/packages/edit-site/src/hooks/push-changes-to-global-styles/index.js
+++ b/packages/edit-site/src/hooks/push-changes-to-global-styles/index.js
@@ -389,30 +389,36 @@ function PushChangesToGlobalStylesControl( {
);
}
-const withPushChangesToGlobalStyles = createHigherOrderComponent(
- ( BlockEdit ) => ( props ) => {
- const blockEditingMode = useBlockEditingMode();
- const isBlockBasedTheme = useSelect(
- ( select ) => select( coreStore ).getCurrentTheme()?.is_block_theme,
- []
- );
- const supportsStyles = SUPPORTED_STYLES.some( ( feature ) =>
- hasBlockSupport( props.name, feature )
- );
-
- return (
- <>
-
- { blockEditingMode === 'default' &&
- supportsStyles &&
- isBlockBasedTheme && (
-
-
-
- ) }
- >
- );
+function PushChangesToGlobalStyles( props ) {
+ const blockEditingMode = useBlockEditingMode();
+ const isBlockBasedTheme = useSelect(
+ ( select ) => select( coreStore ).getCurrentTheme()?.is_block_theme,
+ []
+ );
+ const supportsStyles = SUPPORTED_STYLES.some( ( feature ) =>
+ hasBlockSupport( props.name, feature )
+ );
+ const isDisplayed =
+ blockEditingMode === 'default' && supportsStyles && isBlockBasedTheme;
+
+ if ( ! isDisplayed ) {
+ return null;
}
+
+ return (
+
+
+
+ );
+}
+
+const withPushChangesToGlobalStyles = createHigherOrderComponent(
+ ( BlockEdit ) => ( props ) => (
+ <>
+
+ { props.isSelected && }
+ >
+ )
);
addFilter(
diff --git a/packages/edit-site/src/hooks/template-part-edit.js b/packages/edit-site/src/hooks/template-part-edit.js
index e60b7945448e7b..1c5c47a68fbfb7 100644
--- a/packages/edit-site/src/hooks/template-part-edit.js
+++ b/packages/edit-site/src/hooks/template-part-edit.js
@@ -52,16 +52,14 @@ function EditTemplatePartMenuItem( { attributes } ) {
}
return (
-
- {
- linkProps.onClick( event );
- } }
- >
- { __( 'Edit' ) }
-
-
+ {
+ linkProps.onClick( event );
+ } }
+ >
+ { __( 'Edit' ) }
+
);
}
@@ -72,9 +70,11 @@ export const withEditBlockControls = createHigherOrderComponent(
return (
<>
-
+
{ isDisplayed && (
-
+
+
+
) }
>
);
diff --git a/packages/edit-site/src/index.js b/packages/edit-site/src/index.js
index 29b7df32e6d693..fccf8cb02bc548 100644
--- a/packages/edit-site/src/index.js
+++ b/packages/edit-site/src/index.js
@@ -52,7 +52,6 @@ export function initializeEditor( id, settings ) {
// We dispatch actions and update the store synchronously before rendering
// so that we won't trigger unnecessary re-renders with useEffect.
dispatch( preferencesStore ).setDefaults( 'core/edit-site', {
- editorMode: 'visual',
welcomeGuide: true,
welcomeGuideStyles: true,
welcomeGuidePage: true,
@@ -62,6 +61,7 @@ export function initializeEditor( id, settings ) {
dispatch( preferencesStore ).setDefaults( 'core', {
allowRightClickOverrides: true,
distractionFree: false,
+ editorMode: 'visual',
fixedToolbar: false,
focusMode: false,
inactivePanels: [],
diff --git a/packages/edit-site/src/store/actions.js b/packages/edit-site/src/store/actions.js
index 5a8adad8e198b8..728081bf2fc0f4 100644
--- a/packages/edit-site/src/store/actions.js
+++ b/packages/edit-site/src/store/actions.js
@@ -5,7 +5,7 @@ import apiFetch from '@wordpress/api-fetch';
import { parse, __unstableSerializeAndClean } from '@wordpress/blocks';
import deprecated from '@wordpress/deprecated';
import { addQueryArgs } from '@wordpress/url';
-import { __, sprintf } from '@wordpress/i18n';
+import { __ } from '@wordpress/i18n';
import { store as noticesStore } from '@wordpress/notices';
import { store as coreStore } from '@wordpress/core-data';
import { store as interfaceStore } from '@wordpress/interface';
@@ -13,7 +13,6 @@ import { store as blockEditorStore } from '@wordpress/block-editor';
import { store as editorStore } from '@wordpress/editor';
import { speak } from '@wordpress/a11y';
import { store as preferencesStore } from '@wordpress/preferences';
-import { decodeEntities } from '@wordpress/html-entities';
/**
* Internal dependencies
@@ -25,6 +24,8 @@ import {
TEMPLATE_PART_POST_TYPE,
NAVIGATION_POST_TYPE,
} from '../utils/constants';
+import { removeTemplates } from './private-actions';
+
/**
* Dispatches an action that toggles a feature flag.
*
@@ -133,54 +134,9 @@ export const addTemplate =
*
* @param {Object} template The template object.
*/
-export const removeTemplate =
- ( template ) =>
- async ( { registry } ) => {
- try {
- await registry
- .dispatch( coreStore )
- .deleteEntityRecord( 'postType', template.type, template.id, {
- force: true,
- } );
-
- const lastError = registry
- .select( coreStore )
- .getLastEntityDeleteError(
- 'postType',
- template.type,
- template.id
- );
-
- if ( lastError ) {
- throw lastError;
- }
-
- // Depending on how the entity was retrieved it's title might be
- // an object or simple string.
- const templateTitle =
- typeof template.title === 'string'
- ? template.title
- : template.title?.rendered;
-
- registry.dispatch( noticesStore ).createSuccessNotice(
- sprintf(
- /* translators: The template/part's name. */
- __( '"%s" deleted.' ),
- decodeEntities( templateTitle )
- ),
- { type: 'snackbar', id: 'site-editor-template-deleted-success' }
- );
- } catch ( error ) {
- const errorMessage =
- error.message && error.code !== 'unknown_error'
- ? error.message
- : __( 'An error occurred while deleting the template.' );
-
- registry
- .dispatch( noticesStore )
- .createErrorNotice( errorMessage, { type: 'snackbar' } );
- }
- };
+export const removeTemplate = ( template ) => {
+ return removeTemplates( [ template ] );
+};
/**
* Action that sets a template part.
@@ -549,7 +505,7 @@ export const switchEditorMode =
( { dispatch, registry } ) => {
registry
.dispatch( 'core/preferences' )
- .set( 'core/edit-site', 'editorMode', mode );
+ .set( 'core', 'editorMode', mode );
// Unselect blocks when we switch to a non visual mode.
if ( mode !== 'visual' ) {
@@ -626,6 +582,16 @@ export const toggleDistractionFree =
{
id: 'core/edit-site/distraction-free-mode/notice',
type: 'snackbar',
+ actions: [
+ {
+ label: __( 'Undo' ),
+ onClick: () => {
+ registry
+ .dispatch( preferencesStore )
+ .toggle( 'core', 'distractionFree' );
+ },
+ },
+ ],
}
);
} );
diff --git a/packages/edit-site/src/store/private-actions.js b/packages/edit-site/src/store/private-actions.js
index 7354f7b9b8843a..930e89c6254102 100644
--- a/packages/edit-site/src/store/private-actions.js
+++ b/packages/edit-site/src/store/private-actions.js
@@ -4,6 +4,15 @@
import { store as blockEditorStore } from '@wordpress/block-editor';
import { store as preferencesStore } from '@wordpress/preferences';
import { store as editorStore } from '@wordpress/editor';
+import { store as coreStore } from '@wordpress/core-data';
+import { store as noticesStore } from '@wordpress/notices';
+import { __, sprintf } from '@wordpress/i18n';
+import { decodeEntities } from '@wordpress/html-entities';
+
+/**
+ * Internal dependencies
+ */
+import { TEMPLATE_POST_TYPE } from '../utils/constants';
/**
* Action that switches the canvas mode.
@@ -49,3 +58,127 @@ export const setEditorCanvasContainerView =
view,
} );
};
+
+/**
+ * Action that removes an array of templates.
+ *
+ * @param {Array} items An array of template or template part objects to remove.
+ */
+export const removeTemplates =
+ ( items ) =>
+ async ( { registry } ) => {
+ const isTemplate = items[ 0 ].type === TEMPLATE_POST_TYPE;
+ const promiseResult = await Promise.allSettled(
+ items.map( ( item ) => {
+ return registry
+ .dispatch( coreStore )
+ .deleteEntityRecord(
+ 'postType',
+ item.type,
+ item.id,
+ { force: true },
+ { throwOnError: true }
+ );
+ } )
+ );
+
+ // If all the promises were fulfilled with sucess.
+ if ( promiseResult.every( ( { status } ) => status === 'fulfilled' ) ) {
+ let successMessage;
+
+ if ( items.length === 1 ) {
+ // Depending on how the entity was retrieved its title might be
+ // an object or simple string.
+ const title =
+ typeof items[ 0 ].title === 'string'
+ ? items[ 0 ].title
+ : items[ 0 ].title?.rendered;
+ successMessage = sprintf(
+ /* translators: The template/part's name. */
+ __( '"%s" deleted.' ),
+ decodeEntities( title )
+ );
+ } else {
+ successMessage = isTemplate
+ ? __( 'Templates deleted.' )
+ : __( 'Template parts deleted.' );
+ }
+
+ registry
+ .dispatch( noticesStore )
+ .createSuccessNotice( successMessage, {
+ type: 'snackbar',
+ id: 'site-editor-template-deleted-success',
+ } );
+ } else {
+ // If there was at lease one failure.
+ let errorMessage;
+ // If we were trying to delete a single template.
+ if ( promiseResult.length === 1 ) {
+ if ( promiseResult[ 0 ].reason?.message ) {
+ errorMessage = promiseResult[ 0 ].reason.message;
+ } else {
+ errorMessage = isTemplate
+ ? __( 'An error occurred while deleting the template.' )
+ : __(
+ 'An error occurred while deleting the template part.'
+ );
+ }
+ // If we were trying to delete a multiple templates
+ } else {
+ const errorMessages = new Set();
+ const failedPromises = promiseResult.filter(
+ ( { status } ) => status === 'rejected'
+ );
+ for ( const failedPromise of failedPromises ) {
+ if ( failedPromise.reason?.message ) {
+ errorMessages.add( failedPromise.reason.message );
+ }
+ }
+ if ( errorMessages.size === 0 ) {
+ errorMessage = isTemplate
+ ? __(
+ 'An error occurred while deleting the templates.'
+ )
+ : __(
+ 'An error occurred while deleting the template parts.'
+ );
+ } else if ( errorMessages.size === 1 ) {
+ errorMessage = isTemplate
+ ? sprintf(
+ /* translators: %s: an error message */
+ __(
+ 'An error occurred while deleting the templates: %s'
+ ),
+ [ ...errorMessages ][ 0 ]
+ )
+ : sprintf(
+ /* translators: %s: an error message */
+ __(
+ 'An error occurred while deleting the template parts: %s'
+ ),
+ [ ...errorMessages ][ 0 ]
+ );
+ } else {
+ errorMessage = isTemplate
+ ? sprintf(
+ /* translators: %s: a list of comma separated error messages */
+ __(
+ 'Some errors occurred while deleting the templates: %s'
+ ),
+ [ ...errorMessages ].join( ',' )
+ )
+ : sprintf(
+ /* translators: %s: a list of comma separated error messages */
+ __(
+ 'Some errors occurred while deleting the template parts: %s'
+ ),
+ [ ...errorMessages ].join( ',' )
+ );
+ }
+ }
+ registry
+ .dispatch( noticesStore )
+ .createErrorNotice( errorMessage, { type: 'snackbar' } );
+ }
+ };
diff --git a/packages/edit-site/src/store/selectors.js b/packages/edit-site/src/store/selectors.js
index 4d7adaaa848fe5..2bc26386827cc4 100644
--- a/packages/edit-site/src/store/selectors.js
+++ b/packages/edit-site/src/store/selectors.js
@@ -250,9 +250,7 @@ export const getCurrentTemplateTemplateParts = createRegistrySelector(
);
const clientIds =
- select( blockEditorStore ).__experimentalGetGlobalBlocksByName(
- 'core/template-part'
- );
+ select( blockEditorStore ).getBlocksByName( 'core/template-part' );
const blocks =
select( blockEditorStore ).getBlocksByClientId( clientIds );
@@ -268,7 +266,7 @@ export const getCurrentTemplateTemplateParts = createRegistrySelector(
* @return {string} Editing mode.
*/
export const getEditorMode = createRegistrySelector( ( select ) => () => {
- return select( preferencesStore ).get( 'core/edit-site', 'editorMode' );
+ return select( preferencesStore ).get( 'core', 'editorMode' );
} );
/**
diff --git a/packages/edit-site/src/style.scss b/packages/edit-site/src/style.scss
index c7d0609b4e771c..e6cb953b0b1861 100644
--- a/packages/edit-site/src/style.scss
+++ b/packages/edit-site/src/style.scss
@@ -12,7 +12,7 @@
@import "./components/page/style.scss";
@import "./components/page-pages/style.scss";
@import "./components/page-patterns/style.scss";
-@import "./components/page-templates/style.scss";
+@import "./components/page-templates-template-parts/style.scss";
@import "./components/table/style.scss";
@import "./components/sidebar-edit-mode/style.scss";
@import "./components/sidebar-edit-mode/page-panels/style.scss";
@@ -34,6 +34,7 @@
@import "./components/sidebar-navigation-screen-details-footer/style.scss";
@import "./components/sidebar-navigation-screen-navigation-menu/style.scss";
@import "./components/sidebar-navigation-screen-page/style.scss";
+@import "./components/sidebar-navigation-screen-templates/style.scss";
@import "components/sidebar-navigation-screen-details-panel/style.scss";
@import "./components/sidebar-navigation-screen-pattern/style.scss";
@import "./components/sidebar-navigation-screen-patterns/style.scss";
diff --git a/packages/edit-site/src/utils/get-is-list-page.js b/packages/edit-site/src/utils/get-is-list-page.js
index 9530cd85bf04b4..2ee661253cf063 100644
--- a/packages/edit-site/src/utils/get-is-list-page.js
+++ b/packages/edit-site/src/utils/get-is-list-page.js
@@ -14,8 +14,9 @@ export default function getIsListPage(
isMobileViewport
) {
return (
- [ '/wp_template/all', '/wp_template_part/all' ].includes( path ) ||
- ( path === '/page' && window?.__experimentalAdminViews ) ||
+ [ '/wp_template/all', '/wp_template_part/all', '/pages' ].includes(
+ path
+ ) ||
( path === '/patterns' &&
// Don't treat "/patterns" without categoryType and categoryId as a
// list page in mobile because the sidebar covers the whole page.
diff --git a/packages/edit-site/src/utils/use-actual-current-theme.js b/packages/edit-site/src/utils/use-actual-current-theme.js
new file mode 100644
index 00000000000000..6f8310c2f7de18
--- /dev/null
+++ b/packages/edit-site/src/utils/use-actual-current-theme.js
@@ -0,0 +1,27 @@
+/**
+ * WordPress dependencies
+ */
+import apiFetch from '@wordpress/api-fetch';
+import { useState, useEffect } from '@wordpress/element';
+import { addQueryArgs } from '@wordpress/url';
+
+const ACTIVE_THEMES_URL = '/wp/v2/themes?status=active';
+
+export function useActualCurrentTheme() {
+ const [ currentTheme, setCurrentTheme ] = useState();
+
+ useEffect( () => {
+ // Set the `wp_theme_preview` to empty string to bypass the createThemePreviewMiddleware.
+ const path = addQueryArgs( ACTIVE_THEMES_URL, {
+ context: 'edit',
+ wp_theme_preview: '',
+ } );
+
+ apiFetch( { path } )
+ .then( ( activeThemes ) => setCurrentTheme( activeThemes[ 0 ] ) )
+ // Do nothing
+ .catch( () => {} );
+ }, [] );
+
+ return currentTheme;
+}
diff --git a/packages/edit-widgets/src/components/header/document-tools/index.js b/packages/edit-widgets/src/components/header/document-tools/index.js
index a9799ac993f9ab..4391ece0b89e26 100644
--- a/packages/edit-widgets/src/components/header/document-tools/index.js
+++ b/packages/edit-widgets/src/components/header/document-tools/index.js
@@ -83,6 +83,7 @@ function DocumentTools() {
className="edit-widgets-header-toolbar"
aria-label={ __( 'Document tools' ) }
shouldUseKeyboardFocusShortcut={ ! blockToolbarCanBeFocused }
+ variant="unstyled"
>
{ isMediumViewport && (
<>
-
-
+
+
);
}
+
+export default forwardRef( RedoButton );
diff --git a/packages/edit-widgets/src/components/header/undo-redo/undo.js b/packages/edit-widgets/src/components/header/undo-redo/undo.js
index 827ed1a415d74b..271c73a452d9ea 100644
--- a/packages/edit-widgets/src/components/header/undo-redo/undo.js
+++ b/packages/edit-widgets/src/components/header/undo-redo/undo.js
@@ -2,20 +2,23 @@
* WordPress dependencies
*/
import { __, isRTL } from '@wordpress/i18n';
-import { ToolbarButton } from '@wordpress/components';
+import { Button } from '@wordpress/components';
import { useSelect, useDispatch } from '@wordpress/data';
import { undo as undoIcon, redo as redoIcon } from '@wordpress/icons';
import { displayShortcut } from '@wordpress/keycodes';
import { store as coreStore } from '@wordpress/core-data';
+import { forwardRef } from '@wordpress/element';
-export default function UndoButton() {
+function UndoButton( props, ref ) {
const hasUndo = useSelect(
( select ) => select( coreStore ).hasUndo(),
[]
);
const { undo } = useDispatch( coreStore );
return (
-
);
}
+
+export default forwardRef( UndoButton );
diff --git a/packages/edit-widgets/src/components/notices/style.scss b/packages/edit-widgets/src/components/notices/style.scss
index c0b1f01836fd33..78aaecc883ba45 100644
--- a/packages/edit-widgets/src/components/notices/style.scss
+++ b/packages/edit-widgets/src/components/notices/style.scss
@@ -11,7 +11,6 @@
.edit-widgets-notices__pinned {
.components-notice {
box-sizing: border-box;
- margin: 0;
border-bottom: $border-width solid rgba(0, 0, 0, 0.2);
padding: 0 $grid-unit-15;
diff --git a/packages/editor/package.json b/packages/editor/package.json
index b974c7443851f1..63c81cbd5cc7f8 100644
--- a/packages/editor/package.json
+++ b/packages/editor/package.json
@@ -27,7 +27,7 @@
"sideEffects": [
"build-style/**",
"src/**/*.scss",
- "{src,build,build-module}/{index.js,store/index.js,hooks/**}"
+ "{src,build,build-module}/{index.js,store/index.js,hooks/**,bindings/**}"
],
"dependencies": {
"@babel/runtime": "^7.16.0",
diff --git a/packages/editor/src/bindings/index.js b/packages/editor/src/bindings/index.js
new file mode 100644
index 00000000000000..8a883e8904a71b
--- /dev/null
+++ b/packages/editor/src/bindings/index.js
@@ -0,0 +1,13 @@
+/**
+ * WordPress dependencies
+ */
+import { store as blockEditorStore } from '@wordpress/block-editor';
+import { dispatch } from '@wordpress/data';
+/**
+ * Internal dependencies
+ */
+import { unlock } from '../lock-unlock';
+import postMeta from './post-meta';
+
+const { registerBlockBindingsSource } = unlock( dispatch( blockEditorStore ) );
+registerBlockBindingsSource( postMeta );
diff --git a/packages/editor/src/bindings/post-meta.js b/packages/editor/src/bindings/post-meta.js
new file mode 100644
index 00000000000000..17f5e1837e35e0
--- /dev/null
+++ b/packages/editor/src/bindings/post-meta.js
@@ -0,0 +1,42 @@
+/**
+ * WordPress dependencies
+ */
+import { useEntityProp } from '@wordpress/core-data';
+import { useSelect } from '@wordpress/data';
+import { __ } from '@wordpress/i18n';
+/**
+ * Internal dependencies
+ */
+import { store as editorStore } from '../store';
+
+export default {
+ name: 'post_meta',
+ label: __( 'Post Meta' ),
+ useSource( props, sourceAttributes ) {
+ const { getCurrentPostType } = useSelect( editorStore );
+ const { context } = props;
+ const { value: metaKey } = sourceAttributes;
+ const postType = context.postType
+ ? context.postType
+ : getCurrentPostType();
+ const [ meta, setMeta ] = useEntityProp(
+ 'postType',
+ context.postType,
+ 'meta',
+ context.postId
+ );
+
+ if ( postType === 'wp_template' ) {
+ return { placeholder: metaKey };
+ }
+ const metaValue = meta[ metaKey ];
+ const updateMetaValue = ( newValue ) => {
+ setMeta( { ...meta, [ metaKey ]: newValue } );
+ };
+ return {
+ placeholder: metaKey,
+ useValue: [ metaValue, updateMetaValue ],
+ };
+ },
+ lockAttributesEditing: true,
+};
diff --git a/packages/edit-post/src/components/block-manager/category.js b/packages/editor/src/components/block-manager/category.js
similarity index 57%
rename from packages/edit-post/src/components/block-manager/category.js
rename to packages/editor/src/components/block-manager/category.js
index 6a64563366d633..e7125fa151f72a 100644
--- a/packages/edit-post/src/components/block-manager/category.js
+++ b/packages/editor/src/components/block-manager/category.js
@@ -5,44 +5,46 @@ import { useMemo, useCallback } from '@wordpress/element';
import { useDispatch, useSelect } from '@wordpress/data';
import { useInstanceId } from '@wordpress/compose';
import { CheckboxControl } from '@wordpress/components';
-import { store as editorStore } from '@wordpress/editor';
+import { store as preferencesStore } from '@wordpress/preferences';
/**
* Internal dependencies
*/
import BlockTypesChecklist from './checklist';
-import { store as editPostStore } from '../../store';
+import { store as editorStore } from '../../store';
+import { unlock } from '../../lock-unlock';
function BlockManagerCategory( { title, blockTypes } ) {
const instanceId = useInstanceId( BlockManagerCategory );
- const { defaultAllowedBlockTypes, hiddenBlockTypes } = useSelect(
- ( select ) => {
- const { getEditorSettings } = select( editorStore );
- const { getHiddenBlockTypes } = select( editPostStore );
- return {
- defaultAllowedBlockTypes:
- getEditorSettings().defaultAllowedBlockTypes,
- hiddenBlockTypes: getHiddenBlockTypes(),
- };
- },
- []
- );
+ const { allowedBlockTypes, hiddenBlockTypes } = useSelect( ( select ) => {
+ const { getEditorSettings } = select( editorStore );
+ const { get } = select( preferencesStore );
+ return {
+ allowedBlockTypes: getEditorSettings().allowedBlockTypes,
+ hiddenBlockTypes: get( 'core', 'hiddenBlockTypes' ),
+ };
+ }, [] );
const filteredBlockTypes = useMemo( () => {
- if ( defaultAllowedBlockTypes === true ) {
+ if ( allowedBlockTypes === true ) {
return blockTypes;
}
return blockTypes.filter( ( { name } ) => {
- return defaultAllowedBlockTypes?.includes( name );
+ return allowedBlockTypes?.includes( name );
} );
- }, [ defaultAllowedBlockTypes, blockTypes ] );
- const { showBlockTypes, hideBlockTypes } = useDispatch( editPostStore );
- const toggleVisible = useCallback( ( blockName, nextIsChecked ) => {
- if ( nextIsChecked ) {
- showBlockTypes( blockName );
- } else {
- hideBlockTypes( blockName );
- }
- }, [] );
+ }, [ allowedBlockTypes, blockTypes ] );
+ const { showBlockTypes, hideBlockTypes } = unlock(
+ useDispatch( editorStore )
+ );
+ const toggleVisible = useCallback(
+ ( blockName, nextIsChecked ) => {
+ if ( nextIsChecked ) {
+ showBlockTypes( blockName );
+ } else {
+ hideBlockTypes( blockName );
+ }
+ },
+ [ showBlockTypes, hideBlockTypes ]
+ );
const toggleAllVisible = useCallback(
( nextIsChecked ) => {
const blockNames = blockTypes.map( ( { name } ) => name );
@@ -52,7 +54,7 @@ function BlockManagerCategory( { title, blockTypes } ) {
hideBlockTypes( blockNames );
}
},
- [ blockTypes ]
+ [ blockTypes, showBlockTypes, hideBlockTypes ]
);
if ( ! filteredBlockTypes.length ) {
@@ -61,9 +63,9 @@ function BlockManagerCategory( { title, blockTypes } ) {
const checkedBlockNames = filteredBlockTypes
.map( ( { name } ) => name )
- .filter( ( type ) => ! hiddenBlockTypes.includes( type ) );
+ .filter( ( type ) => ! ( hiddenBlockTypes ?? [] ).includes( type ) );
- const titleId = 'edit-post-block-manager__category-title-' + instanceId;
+ const titleId = 'editor-block-manager__category-title-' + instanceId;
const isAllChecked = checkedBlockNames.length === filteredBlockTypes.length;
const isIndeterminate = ! isAllChecked && checkedBlockNames.length > 0;
@@ -72,13 +74,13 @@ function BlockManagerCategory( { title, blockTypes } ) {
{ title } }
/>
diff --git a/packages/edit-post/src/components/block-manager/checklist.js b/packages/editor/src/components/block-manager/checklist.js
similarity index 85%
rename from packages/edit-post/src/components/block-manager/checklist.js
rename to packages/editor/src/components/block-manager/checklist.js
index aa21fefb1c8180..01bd06abdeba86 100644
--- a/packages/edit-post/src/components/block-manager/checklist.js
+++ b/packages/editor/src/components/block-manager/checklist.js
@@ -6,11 +6,11 @@ import { CheckboxControl } from '@wordpress/components';
function BlockTypesChecklist( { blockTypes, value, onItemChange } ) {
return (
-
+
{ blockTypes.map( ( blockType ) => (
+
{ !! numberOfHiddenBlocks && (
-
+
{ sprintf(
/* translators: %d: number of blocks. */
_n(
@@ -78,16 +80,16 @@ function BlockManager( {
placeholder={ __( 'Search for a block' ) }
value={ search }
onChange={ ( nextSearch ) => setSearch( nextSearch ) }
- className="edit-post-block-manager__search"
+ className="editor-block-manager__search"
/>
{ blockTypes.length === 0 && (
-
+
{ __( 'No blocks found.' ) }
) }
@@ -120,7 +122,7 @@ export default compose( [
hasBlockSupport,
isMatchingSearchTerm,
} = select( blocksStore );
- const { getHiddenBlockTypes } = select( editPostStore );
+ const { get } = select( preferencesStore );
// Some hidden blocks become unregistered
// by removing for instance the plugin that registered them, yet
@@ -128,13 +130,13 @@ export default compose( [
// We consider "hidden", blocks which were hidden and
// are still registered.
const blockTypes = getBlockTypes();
- const hiddenBlockTypes = getHiddenBlockTypes().filter(
- ( hiddenBlock ) => {
- return blockTypes.some(
- ( registeredBlock ) => registeredBlock.name === hiddenBlock
- );
- }
- );
+ const hiddenBlockTypes = (
+ get( 'core', 'hiddenBlockTypes' ) ?? []
+ ).filter( ( hiddenBlock ) => {
+ return blockTypes.some(
+ ( registeredBlock ) => registeredBlock.name === hiddenBlock
+ );
+ } );
const numberOfHiddenBlocks =
Array.isArray( hiddenBlockTypes ) && hiddenBlockTypes.length;
@@ -147,7 +149,7 @@ export default compose( [
};
} ),
withDispatch( ( dispatch ) => {
- const { showBlockTypes } = dispatch( editPostStore );
+ const { showBlockTypes } = unlock( dispatch( editorStore ) );
return {
enableAllBlockTypes: ( blockTypes ) => {
const blockNames = blockTypes.map( ( { name } ) => name );
diff --git a/packages/edit-post/src/components/block-manager/style.scss b/packages/editor/src/components/block-manager/style.scss
similarity index 66%
rename from packages/edit-post/src/components/block-manager/style.scss
rename to packages/editor/src/components/block-manager/style.scss
index e1b92c7c4da430..62e5c9d60cb178 100644
--- a/packages/edit-post/src/components/block-manager/style.scss
+++ b/packages/editor/src/components/block-manager/style.scss
@@ -1,14 +1,14 @@
-.edit-post-block-manager__no-results {
+.editor-block-manager__no-results {
font-style: italic;
padding: $grid-unit-30 0;
text-align: center;
}
-.edit-post-block-manager__search {
+.editor-block-manager__search {
margin: $grid-unit-20 0;
}
-.edit-post-block-manager__disabled-blocks-count {
+.editor-block-manager__disabled-blocks-count {
border: 1px solid $gray-300;
border-width: 1px 0;
// Cover up horizontal areas off the sides of the box rectangle
@@ -19,10 +19,10 @@
position: sticky;
// When sticking, tuck the top border beneath the modal header border
top: ($grid-unit-05 + 1) * -1;
- z-index: z-index(".edit-post-block-manager__disabled-blocks-count");
+ z-index: z-index(".editor-block-manager__disabled-blocks-count");
// Stick the category titles to the bottom
- ~ .edit-post-block-manager__results .edit-post-block-manager__category-title {
+ ~ .editor-block-manager__results .editor-block-manager__category-title {
top: $grid-unit-40 - 1;
}
.is-link {
@@ -30,32 +30,32 @@
}
}
-.edit-post-block-manager__category {
+.editor-block-manager__category {
margin: 0 0 $grid-unit-30 0;
}
-.edit-post-block-manager__category-title {
+.editor-block-manager__category-title {
position: sticky;
top: - $grid-unit-05; // Offsets the top padding on the modal content container
padding: $grid-unit-20 0;
background-color: $white;
- z-index: z-index(".edit-post-block-manager__category-title");
+ z-index: z-index(".editor-block-manager__category-title");
.components-checkbox-control__label {
font-weight: 600;
}
}
-.edit-post-block-manager__checklist {
+.editor-block-manager__checklist {
margin-top: 0;
}
-.edit-post-block-manager__category-title,
-.edit-post-block-manager__checklist-item {
+.editor-block-manager__category-title,
+.editor-block-manager__checklist-item {
border-bottom: 1px solid $gray-300;
}
-.edit-post-block-manager__checklist-item {
+.editor-block-manager__checklist-item {
display: flex;
justify-content: space-between;
align-items: center;
@@ -72,11 +72,11 @@
}
}
-.edit-post-block-manager__results {
+.editor-block-manager__results {
border-top: $border-width solid $gray-300;
}
// Remove the top border from results when adjacent to the disabled block count
-.edit-post-block-manager__disabled-blocks-count + .edit-post-block-manager__results {
+.editor-block-manager__disabled-blocks-count + .editor-block-manager__results {
border-top-width: 0;
}
diff --git a/packages/editor/src/components/document-tools/index.js b/packages/editor/src/components/document-tools/index.js
index cf26fc600a0385..05907654fa9b84 100644
--- a/packages/editor/src/components/document-tools/index.js
+++ b/packages/editor/src/components/document-tools/index.js
@@ -104,8 +104,16 @@ function DocumentTools( {
const shortLabel = ! isInserterOpened ? __( 'Add' ) : __( 'Close' );
return (
+ // Some plugins expect and use the `edit-post-header-toolbar` CSS class to
+ // find the toolbar and inject UI elements into it. This is not officially
+ // supported, but we're keeping it in the list of class names for backwards
+ // compatibility.
( {
+ notices: select( noticesStore ).getNotices(),
+ } ),
+ []
+ );
+ const { removeNotice } = useDispatch( noticesStore );
const dismissibleNotices = notices.filter(
( { isDismissible, type } ) => isDismissible && type === 'default'
);
@@ -28,7 +34,7 @@ export function EditorNotices( { notices, onRemove } ) {
@@ -36,11 +42,4 @@ export function EditorNotices( { notices, onRemove } ) {
);
}
-export default compose( [
- withSelect( ( select ) => ( {
- notices: select( noticesStore ).getNotices(),
- } ) ),
- withDispatch( ( dispatch ) => ( {
- onRemove: dispatch( noticesStore ).removeNotice,
- } ) ),
-] )( EditorNotices );
+export default EditorNotices;
diff --git a/packages/editor/src/components/editor-notices/style.scss b/packages/editor/src/components/editor-notices/style.scss
index f3b1f76285874f..85cd148bfea763 100644
--- a/packages/editor/src/components/editor-notices/style.scss
+++ b/packages/editor/src/components/editor-notices/style.scss
@@ -9,7 +9,6 @@
.components-notice {
box-sizing: border-box;
- margin: 0;
border-bottom: $border-width solid rgba(0, 0, 0, 0.2);
padding: 0 $grid-unit-15;
diff --git a/packages/editor/src/components/entities-saved-states/entity-type-list.js b/packages/editor/src/components/entities-saved-states/entity-type-list.js
index dffac536a1d220..d422c2ae9bfdbd 100644
--- a/packages/editor/src/components/entities-saved-states/entity-type-list.js
+++ b/packages/editor/src/components/entities-saved-states/entity-type-list.js
@@ -5,11 +5,18 @@ import { __ } from '@wordpress/i18n';
import { useSelect } from '@wordpress/data';
import { PanelBody, PanelRow } from '@wordpress/components';
import { store as coreStore } from '@wordpress/core-data';
+import { privateApis as blockEditorPrivateApis } from '@wordpress/block-editor';
+import { useContext } from '@wordpress/element';
/**
* Internal dependencies
*/
import EntityRecordItem from './entity-record-item';
+import { unlock } from '../../lock-unlock';
+
+const { getGlobalStylesChanges, GlobalStylesContext } = unlock(
+ blockEditorPrivateApis
+);
function getEntityDescription( entity, count ) {
switch ( entity ) {
@@ -27,6 +34,44 @@ function getEntityDescription( entity, count ) {
}
}
+function GlobalStylesDescription( { record } ) {
+ const { user: currentEditorGlobalStyles } =
+ useContext( GlobalStylesContext );
+ const savedRecord = useSelect(
+ ( select ) =>
+ select( coreStore ).getEntityRecord(
+ record.kind,
+ record.name,
+ record.key
+ ),
+ [ record.kind, record.name, record.key ]
+ );
+
+ const globalStylesChanges = getGlobalStylesChanges(
+ currentEditorGlobalStyles,
+ savedRecord,
+ {
+ maxResults: 10,
+ }
+ );
+ return globalStylesChanges.length ? (
+ <>
+
+ { __( 'Changes made to:' ) }
+
+ { globalStylesChanges.join( ', ' ) }
+ >
+ ) : null;
+}
+
+function EntityDescription( { record, count } ) {
+ if ( 'globalStyles' === record?.name ) {
+ return ;
+ }
+ const description = getEntityDescription( record?.name, count );
+ return description ? { description } : null;
+}
+
export default function EntityTypeList( {
list,
unselectedEntities,
@@ -42,19 +87,16 @@ export default function EntityTypeList( {
),
[ firstRecord.kind, firstRecord.name ]
);
- const { name } = firstRecord;
let entityLabel = entityConfig.label;
- if ( name === 'wp_template_part' ) {
+ if ( firstRecord?.name === 'wp_template_part' ) {
entityLabel =
1 === count ? __( 'Template Part' ) : __( 'Template Parts' );
}
- // Set description based on type of entity.
- const description = getEntityDescription( name, count );
return (
- { description && { description } }
+
{ list.map( ( record ) => {
return (
{ additionalPrompt }
- { isDirty && (
-
- { __(
- 'The following changes have been made to your site, templates, and content.'
- ) }
-
- ) }
+
+ { isDirty
+ ? __(
+ 'The following changes have been made to your site, templates, and content.'
+ )
+ : __( 'Select the items you want to save.' ) }
+
{ sortedPartitionedSavables.map( ( list ) => {
diff --git a/packages/editor/src/components/entities-saved-states/style.scss b/packages/editor/src/components/entities-saved-states/style.scss
index 8dc3d55a2bd28c..6fb981c22f9600 100644
--- a/packages/editor/src/components/entities-saved-states/style.scss
+++ b/packages/editor/src/components/entities-saved-states/style.scss
@@ -15,3 +15,7 @@
margin-bottom: $grid-unit-15;
}
}
+
+.entities-saved-states__description-heading {
+ font-size: $default-font-size;
+}
diff --git a/packages/editor/src/components/offline-status/index.native.js b/packages/editor/src/components/offline-status/index.native.js
index b136fdae1d5b29..2e514abb6e091c 100644
--- a/packages/editor/src/components/offline-status/index.native.js
+++ b/packages/editor/src/components/offline-status/index.native.js
@@ -90,10 +90,8 @@ const OfflineStatus = () => {
) }
style={ containerStyle }
>
-
-
- { __( 'Working Offline' ) }
-
+
+
{ __( 'Working Offline' ) }
) : null;
};
diff --git a/packages/editor/src/components/post-locked-modal/index.js b/packages/editor/src/components/post-locked-modal/index.js
index ea21c61de1e153..7597bd9b4f3f30 100644
--- a/packages/editor/src/components/post-locked-modal/index.js
+++ b/packages/editor/src/components/post-locked-modal/index.js
@@ -172,7 +172,7 @@ export default function PostLockedModal() {
shouldCloseOnClickOutside={ false }
shouldCloseOnEsc={ false }
isDismissible={ false }
- className="editor-post-locked-modal"
+ size="medium"
>
{ !! userAvatar && (
diff --git a/packages/editor/src/components/post-locked-modal/style.scss b/packages/editor/src/components/post-locked-modal/style.scss
index 9225032b6d2289..03e86642493df3 100644
--- a/packages/editor/src/components/post-locked-modal/style.scss
+++ b/packages/editor/src/components/post-locked-modal/style.scss
@@ -1,9 +1,3 @@
-.editor-post-locked-modal {
- @include break-small() {
- max-width: $break-mobile;
- }
-}
-
.editor-post-locked-modal__buttons {
margin-top: $grid-unit-30;
}
diff --git a/packages/editor/src/components/post-saved-state/index.js b/packages/editor/src/components/post-saved-state/index.js
index c3089057757d9d..ae9e03b5c300e6 100644
--- a/packages/editor/src/components/post-saved-state/index.js
+++ b/packages/editor/src/components/post-saved-state/index.js
@@ -9,7 +9,6 @@ import classnames from 'classnames';
import {
__unstableGetAnimateClassName as getAnimateClassName,
Button,
- Tooltip,
} from '@wordpress/components';
import { usePrevious, useViewportMatch } from '@wordpress/compose';
import { useDispatch, useSelect } from '@wordpress/data';
@@ -129,54 +128,38 @@ export default function PostSavedState( { forceIsDirty } ) {
text = shortLabel;
}
- const buttonAccessibleLabel = text || label;
-
- /**
- * The tooltip needs to be enabled only if the button is not disabled. When
- * relying on the internal Button tooltip functionality, this causes the
- * resulting `button` element to be always removed and re-added to the DOM,
- * causing focus loss. An alternative approach to circumvent the issue
- * is not to use the `label` and `shortcut` props on `Button` (which would
- * trigger the tooltip), and instead manually wrap the `Button` in a separate
- * `Tooltip` component.
- */
- const tooltipProps = isDisabled
- ? undefined
- : {
- text: buttonAccessibleLabel,
- shortcut: displayShortcut.primary( 's' ),
- };
-
// Use common Button instance for all saved states so that focus is not
// lost.
return (
-
- savePost() }
- variant="tertiary"
- size="compact"
- icon={ isLargeViewport ? undefined : cloudUpload }
- // Make sure the aria-label has always a value, as the default `text` is undefined on small screens.
- aria-label={ buttonAccessibleLabel }
- aria-disabled={ isDisabled }
- >
- { isSavedState && }
- { text }
-
-
+ savePost() }
+ /*
+ * We want the tooltip to show the keyboard shortcut only when the
+ * button does something, i.e. when it's not disabled.
+ */
+ shortcut={ isDisabled ? undefined : displayShortcut.primary( 's' ) }
+ variant="tertiary"
+ size="compact"
+ icon={ isLargeViewport ? undefined : cloudUpload }
+ label={ text || label }
+ aria-disabled={ isDisabled }
+ >
+ { isSavedState && }
+ { text }
+
);
}
diff --git a/packages/editor/src/components/post-schedule/check.js b/packages/editor/src/components/post-schedule/check.js
index 050e5a82e8c455..716d75efc9d407 100644
--- a/packages/editor/src/components/post-schedule/check.js
+++ b/packages/editor/src/components/post-schedule/check.js
@@ -1,29 +1,25 @@
/**
* WordPress dependencies
*/
-import { compose } from '@wordpress/compose';
-import { withSelect } from '@wordpress/data';
+import { useSelect } from '@wordpress/data';
/**
* Internal dependencies
*/
import { store as editorStore } from '../../store';
-export function PostScheduleCheck( { hasPublishAction, children } ) {
+export default function PostScheduleCheck( { children } ) {
+ const hasPublishAction = useSelect( ( select ) => {
+ return (
+ select( editorStore ).getCurrentPost()._links?.[
+ 'wp:action-publish'
+ ] ?? false
+ );
+ }, [] );
+
if ( ! hasPublishAction ) {
return null;
}
return children;
}
-
-export default compose( [
- withSelect( ( select ) => {
- const { getCurrentPost, getCurrentPostType } = select( editorStore );
- return {
- hasPublishAction:
- getCurrentPost()._links?.[ 'wp:action-publish' ] ?? false,
- postType: getCurrentPostType(),
- };
- } ),
-] )( PostScheduleCheck );
diff --git a/packages/editor/src/components/post-schedule/test/check.js b/packages/editor/src/components/post-schedule/test/check.js
index 26fc0193ece8d8..b892842e3ef8d7 100644
--- a/packages/editor/src/components/post-schedule/test/check.js
+++ b/packages/editor/src/components/post-schedule/test/check.js
@@ -3,25 +3,40 @@
*/
import { render, screen } from '@testing-library/react';
+/**
+ * WordPress dependencies
+ */
+import { useSelect } from '@wordpress/data';
+
/**
* Internal dependencies
*/
-import { PostScheduleCheck } from '../check';
+import PostScheduleCheck from '../check';
+
+jest.mock( '@wordpress/data/src/components/use-select', () => jest.fn() );
+
+function setupMockSelect( hasPublishAction ) {
+ useSelect.mockImplementation( ( mapSelect ) => {
+ return mapSelect( () => ( {
+ getCurrentPost: () => ( {
+ _links: {
+ 'wp:action-publish': hasPublishAction,
+ },
+ } ),
+ } ) );
+ } );
+}
describe( 'PostScheduleCheck', () => {
it( "should not render anything if the user doesn't have the right capabilities", () => {
- render(
-
- yes
-
- );
+ setupMockSelect( false );
+ render( yes );
expect( screen.queryByText( 'yes' ) ).not.toBeInTheDocument();
} );
it( 'should render if the user has the correct capability', () => {
- render(
- yes
- );
+ setupMockSelect( true );
+ render( yes );
expect( screen.getByText( 'yes' ) ).toBeVisible();
} );
} );
diff --git a/packages/edit-site/src/components/preferences-modal/enable-panel-option.js b/packages/editor/src/components/preferences-modal/enable-panel.js
similarity index 66%
rename from packages/edit-site/src/components/preferences-modal/enable-panel-option.js
rename to packages/editor/src/components/preferences-modal/enable-panel.js
index 6c9ea22b7f17dd..66c91409434415 100644
--- a/packages/edit-site/src/components/preferences-modal/enable-panel-option.js
+++ b/packages/editor/src/components/preferences-modal/enable-panel.js
@@ -3,8 +3,15 @@
*/
import { compose, ifCondition } from '@wordpress/compose';
import { withSelect, withDispatch } from '@wordpress/data';
-import { ___unstablePreferencesModalBaseOption as BaseOption } from '@wordpress/interface';
-import { store as editorStore } from '@wordpress/editor';
+import { privateApis as preferencesPrivateApis } from '@wordpress/preferences';
+
+/**
+ * Internal dependencies
+ */
+import { unlock } from '../../lock-unlock';
+import { store as editorStore } from '../../store';
+
+const { PreferenceBaseOption } = unlock( preferencesPrivateApis );
export default compose(
withSelect( ( select, { panelName } ) => {
@@ -20,4 +27,4 @@ export default compose(
onChange: () =>
dispatch( editorStore ).toggleEditorPanelEnabled( panelName ),
} ) )
-)( BaseOption );
+)( PreferenceBaseOption );
diff --git a/packages/edit-post/src/components/preferences-modal/options/enable-plugin-document-setting-panel.js b/packages/editor/src/components/preferences-modal/enable-plugin-document-setting-panel.js
similarity index 90%
rename from packages/edit-post/src/components/preferences-modal/options/enable-plugin-document-setting-panel.js
rename to packages/editor/src/components/preferences-modal/enable-plugin-document-setting-panel.js
index b18b0436579bcb..4dd2e55eba007b 100644
--- a/packages/edit-post/src/components/preferences-modal/options/enable-plugin-document-setting-panel.js
+++ b/packages/editor/src/components/preferences-modal/enable-plugin-document-setting-panel.js
@@ -6,7 +6,7 @@ import { createSlotFill } from '@wordpress/components';
/**
* Internal dependencies
*/
-import { EnablePanelOption } from './index';
+import EnablePanelOption from './enable-panel';
const { Fill, Slot } = createSlotFill(
'EnablePluginDocumentSettingPanelOption'
diff --git a/packages/editor/src/components/preferences-modal/index.js b/packages/editor/src/components/preferences-modal/index.js
new file mode 100644
index 00000000000000..044c0eb426e222
--- /dev/null
+++ b/packages/editor/src/components/preferences-modal/index.js
@@ -0,0 +1,269 @@
+/**
+ * WordPress dependencies
+ */
+
+import { __ } from '@wordpress/i18n';
+import { useViewportMatch } from '@wordpress/compose';
+import { useSelect, useDispatch } from '@wordpress/data';
+import { useMemo } from '@wordpress/element';
+import {
+ store as preferencesStore,
+ privateApis as preferencesPrivateApis,
+} from '@wordpress/preferences';
+
+/**
+ * Internal dependencies
+ */
+import EnablePanelOption from './enable-panel';
+import EnablePluginDocumentSettingPanelOption from './enable-plugin-document-setting-panel';
+import BlockManager from '../block-manager';
+import PostTaxonomies from '../post-taxonomies';
+import PostFeaturedImageCheck from '../post-featured-image/check';
+import PostExcerptCheck from '../post-excerpt/check';
+import PageAttributesCheck from '../page-attributes/check';
+import PostTypeSupportCheck from '../post-type-support-check';
+import { store as editorStore } from '../../store';
+import { unlock } from '../../lock-unlock';
+
+const {
+ PreferencesModal,
+ PreferencesModalTabs,
+ PreferencesModalSection,
+ PreferenceToggleControl,
+} = unlock( preferencesPrivateApis );
+
+export default function EditorPreferencesModal( {
+ extraSections = {},
+ isActive,
+ onClose,
+} ) {
+ const isLargeViewport = useViewportMatch( 'medium' );
+ const { showBlockBreadcrumbsOption } = useSelect(
+ ( select ) => {
+ const { getEditorSettings } = select( editorStore );
+ const { get } = select( preferencesStore );
+ const isRichEditingEnabled = getEditorSettings().richEditingEnabled;
+ const isDistractionFreeEnabled = get( 'core', 'distractionFree' );
+ return {
+ showBlockBreadcrumbsOption:
+ ! isDistractionFreeEnabled &&
+ isLargeViewport &&
+ isRichEditingEnabled,
+ };
+ },
+ [ isLargeViewport ]
+ );
+
+ const { setIsListViewOpened, setIsInserterOpened } =
+ useDispatch( editorStore );
+ const { set: setPreference } = useDispatch( preferencesStore );
+
+ const toggleDistractionFree = () => {
+ setPreference( 'core', 'fixedToolbar', true );
+ setIsInserterOpened( false );
+ setIsListViewOpened( false );
+ // Todo: Check sidebar when closing/opening distraction free.
+ };
+
+ const turnOffDistractionFree = () => {
+ setPreference( 'core', 'distractionFree', false );
+ };
+
+ const sections = useMemo(
+ () => [
+ {
+ name: 'general',
+ tabLabel: __( 'General' ),
+ content: (
+ <>
+
+
+ { showBlockBreadcrumbsOption && (
+
+ ) }
+
+
+
+
+ (
+
+ ) }
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+ { extraSections?.general }
+ >
+ ),
+ },
+ {
+ name: 'appearance',
+ tabLabel: __( 'Appearance' ),
+ content: (
+
+
+
+
+ { extraSections?.appearance }
+
+ ),
+ },
+ {
+ name: 'accessibility',
+ tabLabel: __( 'Accessibility' ),
+ content: (
+ <>
+
+
+
+
+
+
+ >
+ ),
+ },
+ {
+ name: 'blocks',
+ tabLabel: __( 'Blocks' ),
+ content: (
+ <>
+
+
+
+
+
+
+ >
+ ),
+ },
+ ],
+ [ isLargeViewport, showBlockBreadcrumbsOption, extraSections ]
+ );
+
+ if ( ! isActive ) {
+ return null;
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/packages/editor/src/components/preferences-modal/test/index.js b/packages/editor/src/components/preferences-modal/test/index.js
new file mode 100644
index 00000000000000..01ac1a88fbe7d8
--- /dev/null
+++ b/packages/editor/src/components/preferences-modal/test/index.js
@@ -0,0 +1,28 @@
+/**
+ * External dependencies
+ */
+import { render, screen } from '@testing-library/react';
+
+/**
+ * WordPress dependencies
+ */
+import { useSelect } from '@wordpress/data';
+
+/**
+ * Internal dependencies
+ */
+import EditPostPreferencesModal from '../';
+
+// This allows us to tweak the returned value on each test.
+jest.mock( '@wordpress/data/src/components/use-select', () => jest.fn() );
+jest.mock( '@wordpress/compose/src/hooks/use-viewport-match', () => jest.fn() );
+
+describe( 'EditPostPreferencesModal', () => {
+ it( 'should not render when the modal is not active', () => {
+ useSelect.mockImplementation( () => [ false, false, false ] );
+ render( );
+ expect(
+ screen.queryByRole( 'dialog', { name: 'Preferences' } )
+ ).not.toBeInTheDocument();
+ } );
+} );
diff --git a/packages/editor/src/components/provider/disable-non-page-content-blocks.js b/packages/editor/src/components/provider/disable-non-page-content-blocks.js
index 048b01d026c24e..48a8119350d78f 100644
--- a/packages/editor/src/components/provider/disable-non-page-content-blocks.js
+++ b/packages/editor/src/components/provider/disable-non-page-content-blocks.js
@@ -44,9 +44,9 @@ function DisableBlock( { clientId } ) {
export default function DisableNonPageContentBlocks() {
useBlockEditingMode( 'disabled' );
const clientIds = useSelect( ( select ) => {
- const { __experimentalGetGlobalBlocksByName } =
- select( blockEditorStore );
- return __experimentalGetGlobalBlocksByName( PAGE_CONTENT_BLOCK_TYPES );
+ return select( blockEditorStore ).getBlocksByName(
+ PAGE_CONTENT_BLOCK_TYPES
+ );
}, [] );
return clientIds.map( ( clientId ) => {
diff --git a/packages/editor/src/components/provider/use-block-editor-settings.js b/packages/editor/src/components/provider/use-block-editor-settings.js
index eddc295766f8ce..78c9b1a56e83a7 100644
--- a/packages/editor/src/components/provider/use-block-editor-settings.js
+++ b/packages/editor/src/components/provider/use-block-editor-settings.js
@@ -7,10 +7,12 @@ import {
store as coreStore,
__experimentalFetchLinkSuggestions as fetchLinkSuggestions,
__experimentalFetchUrlData as fetchUrlData,
+ fetchBlockPatterns,
} from '@wordpress/core-data';
import { __ } from '@wordpress/i18n';
import { store as preferencesStore } from '@wordpress/preferences';
import { useViewportMatch } from '@wordpress/compose';
+import { store as blocksStore } from '@wordpress/blocks';
/**
* Internal dependencies
@@ -29,7 +31,6 @@ const BLOCK_EDITOR_SETTINGS = [
'__experimentalPreferredStyleVariations',
'__unstableGalleryWithImageBlocks',
'alignWide',
- 'allowedBlockTypes',
'blockInspectorTabs',
'allowedMimeTypes',
'bodyPlaceholder',
@@ -88,18 +89,19 @@ function useBlockEditorSettings( settings, postType, postId ) {
const isLargeViewport = useViewportMatch( 'medium' );
const {
allowRightClickOverrides,
+ blockTypes,
focusMode,
hasFixedToolbar,
isDistractionFree,
keepCaretInsideBlock,
reusableBlocks,
hasUploadPermissions,
+ hiddenBlockTypes,
canUseUnfilteredHTML,
userCanCreatePages,
pageOnFront,
pageForPosts,
userPatternCategories,
- restBlockPatterns,
restBlockPatternCategories,
} = useSelect(
( select ) => {
@@ -110,11 +112,10 @@ function useBlockEditorSettings( settings, postType, postId ) {
getEntityRecord,
getUserPatternCategories,
getEntityRecords,
- getBlockPatterns,
getBlockPatternCategories,
} = select( coreStore );
const { get } = select( preferencesStore );
-
+ const { getBlockTypes } = select( blocksStore );
const siteSettings = canUser( 'read', 'settings' )
? getEntityRecord( 'root', 'site' )
: undefined;
@@ -124,6 +125,7 @@ function useBlockEditorSettings( settings, postType, postId ) {
'core',
'allowRightClickOverrides'
),
+ blockTypes: getBlockTypes(),
canUseUnfilteredHTML: getRawEntityRecord(
'postType',
postType,
@@ -132,6 +134,7 @@ function useBlockEditorSettings( settings, postType, postId ) {
focusMode: get( 'core', 'focusMode' ),
hasFixedToolbar:
get( 'core', 'fixedToolbar' ) || ! isLargeViewport,
+ hiddenBlockTypes: get( 'core', 'hiddenBlockTypes' ),
isDistractionFree: get( 'core', 'distractionFree' ),
keepCaretInsideBlock: get( 'core', 'keepCaretInsideBlock' ),
reusableBlocks: isWeb
@@ -144,7 +147,6 @@ function useBlockEditorSettings( settings, postType, postId ) {
pageOnFront: siteSettings?.page_on_front,
pageForPosts: siteSettings?.page_for_posts,
userPatternCategories: getUserPatternCategories(),
- restBlockPatterns: getBlockPatterns(),
restBlockPatternCategories: getBlockPatternCategories(),
};
},
@@ -160,22 +162,16 @@ function useBlockEditorSettings( settings, postType, postId ) {
const blockPatterns = useMemo(
() =>
- [
- ...( settingsBlockPatterns || [] ),
- ...( restBlockPatterns || [] ),
- ]
- .filter(
- ( x, index, arr ) =>
- index === arr.findIndex( ( y ) => x.name === y.name )
- )
- .filter( ( { postTypes } ) => {
+ [ ...( settingsBlockPatterns || [] ) ].filter(
+ ( { postTypes } ) => {
return (
! postTypes ||
( Array.isArray( postTypes ) &&
postTypes.includes( postType ) )
);
- } ),
- [ settingsBlockPatterns, restBlockPatterns, postType ]
+ }
+ ),
+ [ settingsBlockPatterns, postType ]
);
const blockPatternCategories = useMemo(
@@ -215,6 +211,25 @@ function useBlockEditorSettings( settings, postType, postId ) {
[ saveEntityRecord, userCanCreatePages ]
);
+ const allowedBlockTypes = useMemo( () => {
+ // Omit hidden block types if exists and non-empty.
+ if ( hiddenBlockTypes && hiddenBlockTypes.length > 0 ) {
+ // Defer to passed setting for `allowedBlockTypes` if provided as
+ // anything other than `true` (where `true` is equivalent to allow
+ // all block types).
+ const defaultAllowedBlockTypes =
+ true === settings.allowedBlockTypes
+ ? blockTypes.map( ( { name } ) => name )
+ : settings.allowedBlockTypes || [];
+
+ return defaultAllowedBlockTypes.filter(
+ ( type ) => ! hiddenBlockTypes.includes( type )
+ );
+ }
+
+ return settings.allowedBlockTypes;
+ }, [ settings.allowedBlockTypes, hiddenBlockTypes, blockTypes ] );
+
const forceDisableFocusMode = settings.focusMode === false;
return useMemo(
@@ -224,14 +239,26 @@ function useBlockEditorSettings( settings, postType, postId ) {
BLOCK_EDITOR_SETTINGS.includes( key )
)
),
+ allowedBlockTypes,
allowRightClickOverrides,
focusMode: focusMode && ! forceDisableFocusMode,
hasFixedToolbar,
isDistractionFree,
keepCaretInsideBlock,
mediaUpload: hasUploadPermissions ? mediaUpload : undefined,
- __experimentalReusableBlocks: reusableBlocks,
__experimentalBlockPatterns: blockPatterns,
+ __experimentalFetchBlockPatterns: async () => {
+ return ( await fetchBlockPatterns() ).filter(
+ ( { postTypes } ) => {
+ return (
+ ! postTypes ||
+ ( Array.isArray( postTypes ) &&
+ postTypes.includes( postType ) )
+ );
+ }
+ );
+ },
+ __experimentalReusableBlocks: reusableBlocks,
__experimentalBlockPatternCategories: blockPatternCategories,
__experimentalUserPatternCategories: userPatternCategories,
__experimentalFetchLinkSuggestions: ( search, searchOptions ) =>
@@ -261,6 +288,7 @@ function useBlockEditorSettings( settings, postType, postId ) {
__experimentalSetIsInserterOpened: setIsInserterOpened,
} ),
[
+ allowedBlockTypes,
allowRightClickOverrides,
focusMode,
forceDisableFocusMode,
diff --git a/packages/editor/src/hooks/pattern-partial-syncing.js b/packages/editor/src/hooks/pattern-partial-syncing.js
index 40bd1e16dfc00d..0ddfea8d9d8e36 100644
--- a/packages/editor/src/hooks/pattern-partial-syncing.js
+++ b/packages/editor/src/hooks/pattern-partial-syncing.js
@@ -5,7 +5,6 @@ import { addFilter } from '@wordpress/hooks';
import { privateApis as patternsPrivateApis } from '@wordpress/patterns';
import { createHigherOrderComponent } from '@wordpress/compose';
import { useBlockEditingMode } from '@wordpress/block-editor';
-import { hasBlockSupport } from '@wordpress/blocks';
import { useSelect } from '@wordpress/data';
/**
@@ -31,43 +30,41 @@ const {
*/
const withPartialSyncingControls = createHigherOrderComponent(
( BlockEdit ) => ( props ) => {
- const blockEditingMode = useBlockEditingMode();
- const hasCustomFieldsSupport = hasBlockSupport(
- props.name,
- '__experimentalConnections',
- false
- );
- const isEditingPattern = useSelect(
- ( select ) =>
- select( editorStore ).getCurrentPostType() ===
- PATTERN_TYPES.user,
- []
- );
-
- const shouldShowPartialSyncingControls =
- hasCustomFieldsSupport &&
- props.isSelected &&
- isEditingPattern &&
- blockEditingMode === 'default' &&
- Object.keys( PARTIAL_SYNCING_SUPPORTED_BLOCKS ).includes(
- props.name
- );
+ const isSupportedBlock = Object.keys(
+ PARTIAL_SYNCING_SUPPORTED_BLOCKS
+ ).includes( props.name );
return (
<>
- { shouldShowPartialSyncingControls && (
-
+ { props.isSelected && isSupportedBlock && (
+
) }
>
);
}
);
-if ( window.__experimentalPatternPartialSyncing ) {
- addFilter(
- 'editor.BlockEdit',
- 'core/editor/with-partial-syncing-controls',
- withPartialSyncingControls
+// Split into a separate component to avoid a store subscription
+// on every block.
+function ControlsWithStoreSubscription( props ) {
+ const blockEditingMode = useBlockEditingMode();
+ const isEditingPattern = useSelect(
+ ( select ) =>
+ select( editorStore ).getCurrentPostType() === PATTERN_TYPES.user,
+ []
+ );
+
+ return (
+ isEditingPattern &&
+ blockEditingMode === 'default' && (
+
+ )
);
}
+
+addFilter(
+ 'editor.BlockEdit',
+ 'core/editor/with-partial-syncing-controls',
+ withPartialSyncingControls
+);
diff --git a/packages/editor/src/index.js b/packages/editor/src/index.js
index 05c04b8232907c..3f6d7a78d837c0 100644
--- a/packages/editor/src/index.js
+++ b/packages/editor/src/index.js
@@ -1,6 +1,7 @@
/**
* Internal dependencies
*/
+import './bindings';
import './hooks';
export { storeConfig, store } from './store';
diff --git a/packages/editor/src/private-apis.js b/packages/editor/src/private-apis.js
index 5f8fc7ccf73185..16c27b1b57c193 100644
--- a/packages/editor/src/private-apis.js
+++ b/packages/editor/src/private-apis.js
@@ -4,28 +4,32 @@
import EditorCanvas from './components/editor-canvas';
import { ExperimentalEditorProvider } from './components/provider';
import { lock } from './lock-unlock';
+import EnablePluginDocumentSettingPanelOption from './components/preferences-modal/enable-plugin-document-setting-panel';
import { EntitiesSavedStatesExtensible } from './components/entities-saved-states';
import useBlockEditorSettings from './components/provider/use-block-editor-settings';
import DocumentTools from './components/document-tools';
import InserterSidebar from './components/inserter-sidebar';
import ListViewSidebar from './components/list-view-sidebar';
+import PluginPostExcerpt from './components/post-excerpt/plugin';
import PostPanelRow from './components/post-panel-row';
import PostViewLink from './components/post-view-link';
import PreviewDropdown from './components/preview-dropdown';
-import PluginPostExcerpt from './components/post-excerpt/plugin';
+import PreferencesModal from './components/preferences-modal';
export const privateApis = {};
lock( privateApis, {
DocumentTools,
EditorCanvas,
ExperimentalEditorProvider,
+ EnablePluginDocumentSettingPanelOption,
EntitiesSavedStatesExtensible,
InserterSidebar,
ListViewSidebar,
+ PluginPostExcerpt,
PostPanelRow,
PostViewLink,
PreviewDropdown,
- PluginPostExcerpt,
+ PreferencesModal,
// This is a temporary private API while we're updating the site editor to use EditorProvider.
useBlockEditorSettings,
diff --git a/packages/editor/src/store/defaults.js b/packages/editor/src/store/defaults.js
index 686888f91de3d5..e4f86b3a7dfb21 100644
--- a/packages/editor/src/store/defaults.js
+++ b/packages/editor/src/store/defaults.js
@@ -9,6 +9,7 @@ import { SETTINGS_DEFAULTS } from '@wordpress/block-editor';
* @property {boolean|Array} allowedBlockTypes Allowed block types
* @property {boolean} richEditingEnabled Whether rich editing is enabled or not
* @property {boolean} codeEditingEnabled Whether code editing is enabled or not
+ * @property {boolean} fontLibraryEnabled Whether the font library is enabled or not.
* @property {boolean} enableCustomFields Whether the WordPress custom fields are enabled or not.
* true = the user has opted to show the Custom Fields panel at the bottom of the editor.
* false = the user has opted to hide the Custom Fields panel at the bottom of the editor.
@@ -26,6 +27,7 @@ export const EDITOR_SETTINGS_DEFAULTS = {
richEditingEnabled: true,
codeEditingEnabled: true,
+ fontLibraryEnabled: true,
enableCustomFields: undefined,
defaultRenderingMode: 'post-only',
};
diff --git a/packages/editor/src/store/private-actions.js b/packages/editor/src/store/private-actions.js
index 7ddeab5f35734c..0d7c0a2186421b 100644
--- a/packages/editor/src/store/private-actions.js
+++ b/packages/editor/src/store/private-actions.js
@@ -4,6 +4,7 @@
import { store as coreStore } from '@wordpress/core-data';
import { __ } from '@wordpress/i18n';
import { store as noticesStore } from '@wordpress/notices';
+import { store as preferencesStore } from '@wordpress/preferences';
/**
* Returns an action object used to set which template is currently being used/edited.
@@ -59,3 +60,51 @@ export const createTemplate =
}
);
};
+
+/**
+ * Update the provided block types to be visible.
+ *
+ * @param {string[]} blockNames Names of block types to show.
+ */
+export const showBlockTypes =
+ ( blockNames ) =>
+ ( { registry } ) => {
+ const existingBlockNames =
+ registry
+ .select( preferencesStore )
+ .get( 'core', 'hiddenBlockTypes' ) ?? [];
+
+ const newBlockNames = existingBlockNames.filter(
+ ( type ) =>
+ ! (
+ Array.isArray( blockNames ) ? blockNames : [ blockNames ]
+ ).includes( type )
+ );
+
+ registry
+ .dispatch( preferencesStore )
+ .set( 'core', 'hiddenBlockTypes', newBlockNames );
+ };
+
+/**
+ * Update the provided block types to be hidden.
+ *
+ * @param {string[]} blockNames Names of block types to hide.
+ */
+export const hideBlockTypes =
+ ( blockNames ) =>
+ ( { registry } ) => {
+ const existingBlockNames =
+ registry
+ .select( preferencesStore )
+ .get( 'core', 'hiddenBlockTypes' ) ?? [];
+
+ const mergedBlockNames = new Set( [
+ ...existingBlockNames,
+ ...( Array.isArray( blockNames ) ? blockNames : [ blockNames ] ),
+ ] );
+
+ registry
+ .dispatch( preferencesStore )
+ .set( 'core', 'hiddenBlockTypes', [ ...mergedBlockNames ] );
+ };
diff --git a/packages/editor/src/store/private-selectors.js b/packages/editor/src/store/private-selectors.js
index e276859f884038..e8e7bcfd183536 100644
--- a/packages/editor/src/store/private-selectors.js
+++ b/packages/editor/src/store/private-selectors.js
@@ -30,7 +30,7 @@ export const getInsertionPoint = createRegistrySelector(
if ( getRenderingMode( state ) === 'template-locked' ) {
const [ postContentClientId ] =
- select( blockEditorStore ).__experimentalGetGlobalBlocksByName(
+ select( blockEditorStore ).getBlocksByName(
'core/post-content'
);
if ( postContentClientId ) {
diff --git a/packages/editor/src/style.scss b/packages/editor/src/style.scss
index ff5a55a3881f99..450c61fd0bb7e6 100644
--- a/packages/editor/src/style.scss
+++ b/packages/editor/src/style.scss
@@ -1,4 +1,5 @@
@import "./components/autocompleters/style.scss";
+@import "./components/block-manager/style.scss";
@import "./components/document-bar/style.scss";
@import "./components/document-outline/style.scss";
@import "./components/document-tools/style.scss";
@@ -29,4 +30,3 @@
@import "./components/preview-dropdown/style.scss";
@import "./components/table-of-contents/style.scss";
@import "./components/template-validation-notice/style.scss";
-@import "./components/editor-canvas/style.scss";
diff --git a/packages/element/CHANGELOG.md b/packages/element/CHANGELOG.md
index e57fc1e022e451..f3edc7994bf7aa 100644
--- a/packages/element/CHANGELOG.md
+++ b/packages/element/CHANGELOG.md
@@ -2,6 +2,8 @@
## Unreleased
+- Started exporting the `PureComponent` React API ([#58076](https://github.com/WordPress/gutenberg/pull/58076)).
+
## 5.26.0 (2024-01-10)
## 5.25.0 (2023-12-13)
diff --git a/packages/element/README.md b/packages/element/README.md
index 96aeeea37a462e..5636fdda56a525 100755
--- a/packages/element/README.md
+++ b/packages/element/README.md
@@ -262,6 +262,12 @@ const placeholderLabel = Platform.select( {
} );
```
+### PureComponent
+
+_Related_
+
+-
+
### RawHTML
Component used as equivalent of Fragment with unescaped HTML, in cases where it is desirable to render dangerous HTML without needing a wrapper element. To preserve additional props, a `div` wrapper _will_ be created if any props aside from `children` are passed.
diff --git a/packages/element/src/react.js b/packages/element/src/react.js
index 6882f9ad3344a5..14bd4a66d2e0de 100644
--- a/packages/element/src/react.js
+++ b/packages/element/src/react.js
@@ -13,6 +13,7 @@ import {
Fragment,
isValidElement,
memo,
+ PureComponent,
StrictMode,
useCallback,
useContext,
@@ -238,6 +239,11 @@ export { lazy };
*/
export { Suspense };
+/**
+ * @see https://reactjs.org/docs/react-api.html#reactpurecomponent
+ */
+export { PureComponent };
+
/**
* Concatenate two or more React children objects.
*
diff --git a/packages/format-library/src/link/inline.js b/packages/format-library/src/link/inline.js
index 58da13eb51ab6b..e477765009d1c8 100644
--- a/packages/format-library/src/link/inline.js
+++ b/packages/format-library/src/link/inline.js
@@ -11,16 +11,17 @@ import {
insert,
isCollapsed,
applyFormat,
- useAnchor,
removeFormat,
slice,
replace,
split,
concat,
+ useAnchor,
} from '@wordpress/rich-text';
import {
__experimentalLinkControl as LinkControl,
store as blockEditorStore,
+ useCachedTruthy,
} from '@wordpress/block-editor';
import { useSelect } from '@wordpress/data';
@@ -29,7 +30,6 @@ import { useSelect } from '@wordpress/data';
*/
import { createLinkFormat, isValidHref, getFormatBoundary } from './utils';
import { link as settings } from './index';
-import useLinkInstanceKey from './use-link-instance-key';
const LINK_SETTINGS = [
...LinkControl.DEFAULT_LINK_SETTINGS,
@@ -90,12 +90,9 @@ function InlineLinkUI( {
}
function onChangeLink( nextValue ) {
- // LinkControl calls `onChange` immediately upon the toggling a setting.
- // Before merging the next value with the current link value, check if
- // the setting was toggled.
- const didToggleSetting =
- linkValue.opensInNewTab !== nextValue.opensInNewTab &&
- nextValue.url === undefined;
+ const hasLink = linkValue?.url;
+ const isNewLink = ! hasLink;
+
// Merge the next value with the current link value.
nextValue = {
...linkValue,
@@ -178,17 +175,16 @@ function InlineLinkUI( {
newValue = concat( valBefore, newValAfter );
}
- newValue.start = newValue.end;
-
- // Hides the Link UI.
- newValue.activeFormats = [];
onChange( newValue );
}
- // Focus should only be shifted back to the formatted segment when the
- // URL is submitted.
- if ( ! didToggleSetting ) {
- stopAddingLink();
+ // Focus should only be returned to the rich text on submit if this link is not
+ // being created for the first time. If it is then focus should remain within the
+ // Link UI because it should remain open for the user to modify the link they have
+ // just created.
+ if ( ! isNewLink ) {
+ const returnFocusToRichText = true;
+ stopAddingLink( returnFocusToRichText );
}
if ( ! isValidHref( newUrl ) ) {
@@ -210,11 +206,14 @@ function InlineLinkUI( {
settings,
} );
- // Generate a string based key that is unique to this anchor reference.
- // This is used to force re-mount the LinkControl component to avoid
- // potential stale state bugs caused by the component not being remounted
- // See https://github.com/WordPress/gutenberg/pull/34742.
- const forceRemountKey = useLinkInstanceKey( popoverAnchor );
+ // As you change the link by interacting with the Link UI
+ // the return value of document.getSelection jumps to the field you're editing,
+ // not the highlighted text. Given that useAnchor uses document.getSelection,
+ // it will return null, since it can't find the element within the Link UI.
+ // This caches the last truthy value of the selection anchor reference.
+ // This ensures the Popover is positioned correctly on initial submission of the link.
+ const cachedRect = useCachedTruthy( popoverAnchor.getBoundingClientRect() );
+ popoverAnchor.getBoundingClientRect = () => cachedRect;
// Focus should only be moved into the Popover when the Link is being created or edited.
// When the Link is in "preview" mode focus should remain on the rich text because at
@@ -257,10 +256,10 @@ function InlineLinkUI( {
onClose={ stopAddingLink }
onFocusOutside={ () => stopAddingLink( false ) }
placement="bottom"
+ offset={ 10 }
shift
>
);
diff --git a/packages/interactivity-router/.npmrc b/packages/interactivity-router/.npmrc
new file mode 100644
index 00000000000000..43c97e719a5a82
--- /dev/null
+++ b/packages/interactivity-router/.npmrc
@@ -0,0 +1 @@
+package-lock=false
diff --git a/packages/interactivity-router/CHANGELOG.md b/packages/interactivity-router/CHANGELOG.md
new file mode 100644
index 00000000000000..5c4b2529a9ca66
--- /dev/null
+++ b/packages/interactivity-router/CHANGELOG.md
@@ -0,0 +1,7 @@
+
+
+## Unreleased
+
+### Breaking changes
+
+- Initial version. ([57924](https://github.com/WordPress/gutenberg/pull/57924))
diff --git a/packages/interactivity-router/README.md b/packages/interactivity-router/README.md
new file mode 100644
index 00000000000000..94b88e80886c90
--- /dev/null
+++ b/packages/interactivity-router/README.md
@@ -0,0 +1,76 @@
+# Interactivity Router
+
+> **Note**
+> This package is a extension of the API shared at [Proposal: The Interactivity API – A better developer experience in building interactive blocks](https://make.wordpress.org/core/2023/03/30/proposal-the-interactivity-api-a-better-developer-experience-in-building-interactive-blocks/). As part of an [Open Source project](https://developer.wordpress.org/block-editor/getting-started/faq/#the-gutenberg-project) we encourage participation in helping shape this API and the [discussions in GitHub](https://github.com/WordPress/gutenberg/discussions/categories/interactivity-api) is the best place to engage.
+
+This package defines an Interactivity API store with the `core/router` namespace, exposing state and actions like `navigate` and `prefetch` to handle client-side navigations.
+
+## Usage
+
+The package is intended to be imported dynamically in the `view.js` files of interactive blocks.
+
+```js
+import { store } from '@wordpress/interactivity';
+
+store( 'myblock', {
+ actions: {
+ *navigate( e ) {
+ e.preventDefault();
+ const { actions } = yield import(
+ '@wordpress/interactivity-router'
+ );
+ yield actions.navigate( e.target.href );
+ },
+ },
+} );
+```
+
+## Frequently Asked Questions
+
+At this point, some of the questions you have about the Interactivity API may be:
+
+### What is this?
+
+This is the base of a new standard to create interactive blocks. Read [the proposal](https://make.wordpress.org/core/2023/03/30/proposal-the-interactivity-api-a-better-developer-experience-in-building-interactive-blocks/) to learn more about this.
+
+### Can I use it?
+
+You can test it, but it's still very experimental.
+
+### How do I get started?
+
+The best place to start with the Interactivity API is this [**Getting started guide**](https://github.com/WordPress/gutenberg/blob/trunk/packages/interactivity/docs/1-getting-started.md). There you'll will find a very quick start guide and the current requirements of the Interactivity API.
+
+### Where can I ask questions?
+
+The [“Interactivity API” category](https://github.com/WordPress/gutenberg/discussions/categories/interactivity-api) in Gutenberg repo discussions is the best place to ask questions about the Interactivity API.
+
+### Where can I share my feedback about the API?
+
+The [“Interactivity API” category](https://github.com/WordPress/gutenberg/discussions/categories/interactivity-api) in Gutenberg repo discussions is also the best place to share your feedback about the Interactivity API.
+
+## Installation
+
+Install the module:
+
+```bash
+npm install @wordpress/interactivity --save
+```
+
+_This package assumes that your code will run in an **ES2015+** environment. If you're using an environment that has limited or no support for such language features and APIs, you should include [the polyfill shipped in `@wordpress/babel-preset-default`](https://github.com/WordPress/gutenberg/tree/HEAD/packages/babel-preset-default#polyfill) in your code._
+
+## Docs & Examples
+
+**[Interactivity API Documentation](https://github.com/WordPress/gutenberg/tree/trunk/packages/interactivity/docs)** is the best place to learn about this proposal. Although it's still in progress, some key pages are already available:
+
+- **[Getting Started Guide](https://github.com/WordPress/gutenberg/blob/trunk/packages/interactivity/docs/1-getting-started.md)**: Follow this Getting Started guide to learn how to scaffold a new project and create your first interactive blocks.
+- **[API Reference](https://github.com/WordPress/gutenberg/blob/trunk/packages/interactivity/docs/2-api-reference.md)**: Check this page for technical detailed explanations and examples of the directives and the store.
+
+Here you have some more resources to learn/read more about the Interactivity API:
+
+- **[Interactivity API Discussions](https://github.com/WordPress/gutenberg/discussions/52882)**
+- [Proposal: The Interactivity API – A better developer experience in building interactive blocks](https://make.wordpress.org/core/2023/03/30/proposal-the-interactivity-api-a-better-developer-experience-in-building-interactive-blocks/)
+- Developer Hours sessions ([Americas](https://www.youtube.com/watch?v=RXNoyP2ZiS8&t=664s) & [APAC/EMEA](https://www.youtube.com/watch?v=6ghbrhyAcvA))
+- [wpmovies.dev](http://wpmovies.dev/) demo and its [wp-movies-demo](https://github.com/WordPress/wp-movies-demo) repo
+
+
diff --git a/packages/interactivity-router/package.json b/packages/interactivity-router/package.json
new file mode 100644
index 00000000000000..9afb103676c6b3
--- /dev/null
+++ b/packages/interactivity-router/package.json
@@ -0,0 +1,34 @@
+{
+ "name": "@wordpress/interactivity-router",
+ "version": "0.1.0",
+ "description": "Package that exposes state and actions from the `core/router` store, part of the Interactivity API.",
+ "author": "The WordPress Contributors",
+ "license": "GPL-2.0-or-later",
+ "keywords": [
+ "wordpress",
+ "gutenberg",
+ "interactivity"
+ ],
+ "homepage": "https://github.com/WordPress/gutenberg/tree/HEAD/packages/interactivity-router/README.md",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/WordPress/gutenberg.git",
+ "directory": "packages/interactivity-router"
+ },
+ "bugs": {
+ "url": "https://github.com/WordPress/gutenberg/labels/%5BFeature%5D%20Interactivity%20API"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "main": "build/index.js",
+ "module": "build-module/index.js",
+ "react-native": "src/index",
+ "types": "build-types",
+ "dependencies": {
+ "@wordpress/interactivity": "file:../interactivity"
+ },
+ "publishConfig": {
+ "access": "public"
+ }
+}
diff --git a/packages/interactivity-router/src/index.js b/packages/interactivity-router/src/index.js
new file mode 100644
index 00000000000000..7396c21e9638d1
--- /dev/null
+++ b/packages/interactivity-router/src/index.js
@@ -0,0 +1,160 @@
+/**
+ * WordPress dependencies
+ */
+import {
+ render,
+ directivePrefix,
+ toVdom,
+ getRegionRootFragment,
+ store,
+} from '@wordpress/interactivity';
+
+// The cache of visited and prefetched pages.
+const pages = new Map();
+
+// Helper to remove domain and hash from the URL. We are only interesting in
+// caching the path and the query.
+const cleanUrl = ( url ) => {
+ const u = new URL( url, window.location );
+ return u.pathname + u.search;
+};
+
+// Fetch a new page and convert it to a static virtual DOM.
+const fetchPage = async ( url, { html } ) => {
+ try {
+ if ( ! html ) {
+ const res = await window.fetch( url );
+ if ( res.status !== 200 ) return false;
+ html = await res.text();
+ }
+ const dom = new window.DOMParser().parseFromString( html, 'text/html' );
+ return regionsToVdom( dom );
+ } catch ( e ) {
+ return false;
+ }
+};
+
+// Return an object with VDOM trees of those HTML regions marked with a
+// `router-region` directive.
+const regionsToVdom = ( dom ) => {
+ const regions = {};
+ const attrName = `data-${ directivePrefix }-router-region`;
+ dom.querySelectorAll( `[${ attrName }]` ).forEach( ( region ) => {
+ const id = region.getAttribute( attrName );
+ regions[ id ] = toVdom( region );
+ } );
+ const title = dom.querySelector( 'title' )?.innerText;
+ return { regions, title };
+};
+
+// Render all interactive regions contained in the given page.
+const renderRegions = ( page ) => {
+ const attrName = `data-${ directivePrefix }-router-region`;
+ document.querySelectorAll( `[${ attrName }]` ).forEach( ( region ) => {
+ const id = region.getAttribute( attrName );
+ const fragment = getRegionRootFragment( region );
+ render( page.regions[ id ], fragment );
+ } );
+ if ( page.title ) {
+ document.title = page.title;
+ }
+};
+
+// Variable to store the current navigation.
+let navigatingTo = '';
+
+// Listen to the back and forward buttons and restore the page if it's in the
+// cache.
+window.addEventListener( 'popstate', async () => {
+ const url = cleanUrl( window.location ); // Remove hash.
+ const page = pages.has( url ) && ( await pages.get( url ) );
+ if ( page ) {
+ renderRegions( page );
+ } else {
+ window.location.reload();
+ }
+} );
+
+// Cache the current regions.
+pages.set(
+ cleanUrl( window.location ),
+ Promise.resolve( regionsToVdom( document ) )
+);
+
+export const { state, actions } = store( 'core/router', {
+ actions: {
+ /**
+ * Navigates to the specified page.
+ *
+ * This function normalizes the passed href, fetchs the page HTML if
+ * needed, and updates any interactive regions whose contents have
+ * changed. It also creates a new entry in the browser session history.
+ *
+ * @param {string} href The page href.
+ * @param {Object} [options] Options object.
+ * @param {boolean} [options.force] If true, it forces re-fetching the
+ * URL.
+ * @param {string} [options.html] HTML string to be used instead of
+ * fetching the requested URL.
+ * @param {boolean} [options.replace] If true, it replaces the current
+ * entry in the browser session
+ * history.
+ * @param {number} [options.timeout] Time until the navigation is
+ * aborted, in milliseconds. Default
+ * is 10000.
+ *
+ * @return {Promise} Promise that resolves once the navigation is
+ * completed or aborted.
+ */
+ *navigate( href, options = {} ) {
+ const url = cleanUrl( href );
+ navigatingTo = href;
+ actions.prefetch( url, options );
+
+ // Create a promise that resolves when the specified timeout ends.
+ // The timeout value is 10 seconds by default.
+ const timeoutPromise = new Promise( ( resolve ) =>
+ setTimeout( resolve, options.timeout ?? 10000 )
+ );
+
+ const page = yield Promise.race( [
+ pages.get( url ),
+ timeoutPromise,
+ ] );
+
+ // Once the page is fetched, the destination URL could have changed
+ // (e.g., by clicking another link in the meantime). If so, bail
+ // out, and let the newer execution to update the HTML.
+ if ( navigatingTo !== href ) return;
+
+ if ( page ) {
+ renderRegions( page );
+ window.history[
+ options.replace ? 'replaceState' : 'pushState'
+ ]( {}, '', href );
+ } else {
+ window.location.assign( href );
+ yield new Promise( () => {} );
+ }
+ },
+
+ /**
+ * Prefetchs the page with the passed URL.
+ *
+ * The function normalizes the URL and stores internally the fetch
+ * promise, to avoid triggering a second fetch for an ongoing request.
+ *
+ * @param {string} url The page URL.
+ * @param {Object} [options] Options object.
+ * @param {boolean} [options.force] Force fetching the URL again.
+ * @param {string} [options.html] HTML string to be used instead of
+ * fetching the requested URL.
+ */
+ prefetch( url, options = {} ) {
+ url = cleanUrl( url );
+ if ( options.force || ! pages.has( url ) ) {
+ pages.set( url, fetchPage( url, options ) );
+ }
+ },
+ },
+} );
diff --git a/packages/interactivity-router/tsconfig.json b/packages/interactivity-router/tsconfig.json
new file mode 100644
index 00000000000000..9008be9879c07f
--- /dev/null
+++ b/packages/interactivity-router/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "declarationDir": "build-types",
+ "checkJs": false,
+ "strict": false
+ },
+ "references": [ { "path": "../interactivity" } ],
+ "include": [ "src/**/*" ]
+}
diff --git a/packages/interactivity/CHANGELOG.md b/packages/interactivity/CHANGELOG.md
index 7dabdb7117fe90..61f76482090f98 100644
--- a/packages/interactivity/CHANGELOG.md
+++ b/packages/interactivity/CHANGELOG.md
@@ -2,6 +2,29 @@
## Unreleased
+### Enhancements
+
+- Prevent the usage of Preact components in `wp-text`. ([#57879](https://github.com/WordPress/gutenberg/pull/57879))
+- Update `preact`, `@preact/signals` and `deepsignal` dependencies. ([#57891](https://github.com/WordPress/gutenberg/pull/57891))
+- Export `withScope()` and allow to use it with asynchronous operations. ([#58013](https://github.com/WordPress/gutenberg/pull/58013))
+
+### New Features
+
+- Add the `data-wp-run` directive along with the `useInit` and `useWatch` hooks. ([#57805](https://github.com/WordPress/gutenberg/pull/57805))
+- Add `wp-data-on-window` and `wp-data-on-document` directives. ([#57931](https://github.com/WordPress/gutenberg/pull/57931))
+- Add the `data-wp-each` directive to render lists of items using a template. ([57859](https://github.com/WordPress/gutenberg/pull/57859))
+
+### Breaking Changes
+
+- Remove `data-wp-slot` and `data-wp-fill`. ([#57854](https://github.com/WordPress/gutenberg/pull/57854))
+- Remove `wp-data-navigation-link` directive. ([#57853](https://github.com/WordPress/gutenberg/pull/57853))
+- Remove unused `state` and rename `props` to `attributes` in `getElement()`. ([#57974](https://github.com/WordPress/gutenberg/pull/57974))
+- Convert `navigate` and `prefetch` function to actions of the new `core/router` store, available when importing the `@wordpress/interactivity-router` module. ([#57924](https://github.com/WordPress/gutenberg/pull/57924))
+
+### Bug Fix
+
+- Prevent `wp-data-on=""` from creating `onDefault` handlers. ([#57925](https://github.com/WordPress/gutenberg/pull/57925))
+
## 3.2.0 (2024-01-10)
### Bug Fix
diff --git a/packages/interactivity/docs/2-api-reference.md b/packages/interactivity/docs/2-api-reference.md
index 3c8f179861f525..bae15e9a7fcf2f 100644
--- a/packages/interactivity/docs/2-api-reference.md
+++ b/packages/interactivity/docs/2-api-reference.md
@@ -20,9 +20,13 @@ DOM elements are connected to data stored in the state and context through direc
- [`wp-style`](#wp-style) ![](https://img.shields.io/badge/ATTRIBUTES-afd2e3.svg)
- [`wp-text`](#wp-text) ![](https://img.shields.io/badge/CONTENT-afd2e3.svg)
- [`wp-on`](#wp-on) ![](https://img.shields.io/badge/EVENT_HANDLERS-afd2e3.svg)
+ - [`wp-on-window`](#wp-on-window) ![](https://img.shields.io/badge/EVENT_HANDLERS-afd2e3.svg)
+ - [`wp-on-document`](#wp-on-document) ![](https://img.shields.io/badge/EVENT_HANDLERS-afd2e3.svg)
- [`wp-watch`](#wp-watch) ![](https://img.shields.io/badge/SIDE_EFFECTS-afd2e3.svg)
- [`wp-init`](#wp-init) ![](https://img.shields.io/badge/SIDE_EFFECTS-afd2e3.svg)
+ - [`wp-run`](#wp-run) ![](https://img.shields.io/badge/SIDE_EFFECTS-afd2e3.svg)
- [`wp-key`](#wp-key) ![](https://img.shields.io/badge/TEMPLATING-afd2e3.svg)
+ - [`wp-each`](#wp-each) ![](https://img.shields.io/badge/TEMPLATING-afd2e3.svg)
- [Values of directives are references to store properties](#values-of-directives-are-references-to-store-properties)
- [The store](#the-store)
- [Elements of the store](#elements-of-the-store)
@@ -54,7 +58,7 @@ _Example of directives used in the HTML markup_
>
Toggle
-
+
This element is now visible!
@@ -67,13 +71,13 @@ Directives can also be injected dynamically using the [HTML Tag Processor](https
With directives, we can directly manage behavior related to things such as side effects, state, event handlers, attributes or content.
-#### `wp-interactive`
+#### `wp-interactive`
The `wp-interactive` directive "activates" the interactivity for the DOM element and its children through the Interactivity API (directives and store). It includes a namespace to reference a specific store.
```html
-
@@ -87,13 +91,13 @@ The `wp-interactive` directive "activates" the interactivity for the DOM element
> **Note**
> The use of `data-wp-interactive` is a requirement for the Interactivity API "engine" to work. In the following examples the `data-wp-interactive` has not been added for the sake of simplicity. Also, the `data-wp-interactive` directive will be injected automatically in the future.
-#### `wp-context`
+#### `wp-context`
It provides a **local** state available to a specific HTML node and its children.
The `wp-context` directive accepts a stringified JSON as a value.
-_Example of `wp-context` directive_
+_Example of `wp-context` directive_
```php
//render.php
@@ -138,13 +142,13 @@ Different contexts can be defined at different levels, and deeper levels will me
```
-#### `wp-bind`
+#### `wp-bind`
It allows setting HTML attributes on elements based on a boolean or string value.
> This directive follows the syntax `data-wp-bind--attribute`.
-_Example of `wp-bind` directive_
+_Example of `wp-bind` directive_
```html
@@ -182,10 +186,10 @@ store( "myPlugin", {
The `wp-bind` directive is executed:
-- When the element is created.
+- When the element is created.
- Each time there's a change on any of the properties of the `state` or `context` involved in getting the final value of the directive (inside the callback or the expression passed as reference).
-When `wp-bind` directive references a callback to get its final value:
+When `wp-bind` directive references a callback to get its final value:
- The `wp-bind` directive will be executed each time there's a change on any of the properties of the `state` or `context` used inside this callback.
- The returned value in the callback function is used to change the value of the associated attribute.
@@ -197,24 +201,24 @@ The `wp-bind` will do different things over the DOM element is applied, dependin
- If the value is a string, the attribute is added with its value assigned: ``.
-#### `wp-class`
+#### `wp-class`
It adds or removes a class to an HTML element, depending on a boolean value.
> This directive follows the syntax `data-wp-class--classname`.
-_Example of `wp-class` directive_
+_Example of `wp-class` directive_
```html
-
Option 1
-
This directive follows the syntax `data-wp-style--css-property`.
-_Example of `wp-style` directive_
+_Example of `wp-style` directive_
```html
@@ -295,7 +299,7 @@ The value received by the directive is used to add or remove the style attribute
- If the value is `false`, the style attribute is removed: `
`.
- If the value is a string, the attribute is added with its value assigned: `
`.
-#### `wp-text`
+#### `wp-text`
It sets the inner text of an HTML element.
@@ -332,13 +336,13 @@ The `wp-text` directive is executed:
The returned value is used to change the inner content of the element: `
value
`.
-#### `wp-on`
+#### `wp-on`
-It runs code on dispatched DOM events like `click` or `keyup`.
+It runs code on dispatched DOM events like `click` or `keyup`.
> The syntax of this directive is `data-wp-on--[event]` (like `data-wp-on--click` or `data-wp-on--keyup`).
-_Example of `wp-on` directive_
+_Example of `wp-on` directive_
```php
@@ -362,20 +366,86 @@ store( "myPlugin", {
-The `wp-on` directive is executed each time the associated event is triggered.
+The `wp-on` directive is executed each time the associated event is triggered.
The callback passed as the reference receives [the event](https://developer.mozilla.org/en-US/docs/Web/API/Event) (`event`), and the returned value by this callback is ignored.
-#### `wp-watch`
+#### `wp-on-window`
+
+It allows to attach global window events like `resize`, `copy`, `focus` and then execute a defined callback when those happen.
+
+[List of supported window events.](https://developer.mozilla.org/en-US/docs/Web/API/Window#events)
+
+> The syntax of this directive is `data-wp-on-window--[window-event]` (like `data-wp-on-window--resize`
+or `data-wp-on-window--languagechange`).
+
+_Example of `wp-on-window` directive_
+
+```php
+
+```
+
+
+ See store used with the directive above
+
+```js
+store( "myPlugin", {
+ callbacks: {
+ logWidth() {
+ console.log( 'Window width: ', window.innerWidth );
+ },
+ },
+} );
+```
+
+
+
+
+The callback passed as the reference receives [the event](https://developer.mozilla.org/en-US/docs/Web/API/Event) (`event`), and the returned value by this callback is ignored. When the element is removed from the DOM, the event listener is also removed.
+
+#### `wp-on-document`
+
+It allows to attach global document events like `scroll`, `mousemove`, `keydown` and then execute a defined callback when those happen.
+
+[List of supported document events.](https://developer.mozilla.org/en-US/docs/Web/API/Document#events)
-It runs a callback **when the node is created and runs it again when the state or context changes**.
+> The syntax of this directive is `data-wp-on-document--[document-event]` (like `data-wp-on-document--keydown`
+or `data-wp-on-document--selectionchange`).
+
+_Example of `wp-on-document` directive_
+
+```php
+
+```
+
+
+ See store used with the directive above
+
+```js
+store( "myPlugin", {
+ callbacks: {
+ logKeydown(event) {
+ console.log( 'Key pressed: ', event.key );
+ },
+ },
+} );
+```
+
+
+
+
+The callback passed as the reference receives [the event](https://developer.mozilla.org/en-US/docs/Web/API/Event) (`event`), and the returned value by this callback is ignored. When the element is removed from the DOM, the event listener is also removed.
+
+#### `wp-watch`
+
+It runs a callback **when the node is created and runs it again when the state or context changes**.
You can attach several side effects to the same DOM element by using the syntax `data-wp-watch--[unique-id]`. _The unique id doesn't need to be unique globally, it just needs to be different than the other unique ids of the `wp-watch` directives of that DOM element._
_Example of `wp-watch` directive_
```html
-
@@ -395,14 +465,14 @@ store( "myPlugin", {
const context = getContext();
context.counter++;
},
- decreaseCounter: () => {
+ decreaseCounter: () => {
const context = getContext();
context.counter--;
},
},
callbacks: {
logCounter: () => {
- const { counter } = getContext();
+ const { counter } = getContext();
console.log("Counter is " + counter + " at " + new Date() );
},
},
@@ -426,13 +496,13 @@ As a reference, some use cases for this directive may be:
- Setting the focus on an element with `.focus()`.
- Changing the state or context when certain conditions are met.
-#### `wp-init`
+#### `wp-init`
It runs a callback **only when the node is created**.
You can attach several `wp-init` to the same DOM element by using the syntax `data-wp-init--[unique-id]`. _The unique id doesn't need to be unique globally, it just needs to be different than the other unique ids of the `wp-init` directives of that DOM element._
-_Example of `data-wp-init` directive_
+_Example of `data-wp-init` directive_
```html
@@ -443,8 +513,8 @@ _Example of `data-wp-init` directive_
_Example of several `wp-init` directives on the same DOM element_
```html
-
```
When the list is re-rendered, the Interactivity API will match elements by their keys to determine if an item was added/removed/reordered. Elements without keys might be recreated unnecessarily.
+
+#### `wp-each`
+
+The `wp-each` directive is intended to render a list of elements. The directive can be used in `
` tags, being the value a path to an array stored in the global state or the context. The content inside the `` tag is the template used to render each of the items.
+
+Each item is included in the context under the `item` name by default, so directives inside the template can access the current item.
+
+For example, let's consider the following HTML.
+
+```html
+
+```
+
+It would generate the following output:
+
+```html
+
+```
+
+The prop that holds the item in the context can be changed by passing a suffix to the directive name. In the following example, we change the default prop `item` to `greeting`.
+
+```html
+
+```
+
+By default, it uses each element as the key for the rendered nodes, but you can also specify a path to retrieve the key if necessary, e.g., when the list contains objects.
+
+For that, you must use `data-wp-each-key` in the `` tag and not `data-wp-key` inside the template content. This is because `data-wp-each` creates
+a context provider wrapper around each rendered item, and those wrappers are the ones that need the `key` property.
+
+```html
+
+```
+
+For server-side rendered lists, another directive called `data-wp-each-child` ensures hydration works as expected. This directive is added automatically when the directive is processed on the server.
+
+```html
+
+
+
+
+ hello
+ hola
+ olá
+
+```
+
### Values of directives are references to store properties
The value assigned to a directive is a string pointing to a specific state, action, or side effect.
@@ -507,7 +705,7 @@ const { state } = store( "myPlugin", {
currentVideo: '',
get isPlaying() {
return state.currentVideo !== '';
- }
+ }
},
} );
```
@@ -528,7 +726,7 @@ In the example below, we get `state.isPlaying` from `otherPlugin` instead of `my
```html
-
@@ -542,13 +740,13 @@ The store is used to create the logic (actions, side effects…) linked to the d
### Elements of the store
-#### State
+#### State
It defines data available to the HTML nodes of the page. It is important to differentiate between two ways to define the data:
- **Global state**: It is defined using the `store()` function with the `state` property, and the data is available to all the HTML nodes of the page.
- **Context/Local State**: It is defined using the `data-wp-context` directive in an HTML node, and the data is available to that HTML node and its children. It can be accessed using the `getContext` function inside of an action, derived state or side effect.
-
+
```html
@@ -577,11 +775,11 @@ const { state } = store( "myPlugin", {
} )
```
-#### Actions
+#### Actions
Usually triggered by the `data-wp-on` directive (using event listeners) or other actions.
-#### Side Effects
+#### Side Effects
Automatically react to state changes. Usually triggered by `data-wp-watch` or `data-wp-init` directives.
@@ -590,7 +788,7 @@ Automatically react to state changes. Usually triggered by `data-wp-watch` or `d
They return a computed version of the state. They can access both `state` and `context`.
```js
-// view.js
+// view.js
const { state } = store( "myPlugin", {
state: {
amount: 34,
@@ -641,7 +839,7 @@ const { state } = store( "myPlugin", {
> **Note**
> All `store()` calls with the same namespace return the same references, i.e., the same `state`, `actions`, etc., containing the result of merging all the store parts passed.
-- To access the context inside an action, derived state, or side effect, you can use the `getContext` function.
+- To access the context inside an action, derived state, or side effect, you can use the `getContext` function.
- To access the reference, you can use the `getElement` function.
```js
@@ -683,7 +881,7 @@ This approach enables some functionalities that make directives flexible and pow
*In the `view.js` file of each block* we can define both the state and the elements of the store referencing functions like actions, side effects or derived state.
-The `store` method used to set the store in javascript can be imported from `@wordpress/interactivity`.
+The `store` method used to set the store in javascript can be imported from `@wordpress/interactivity`.
```js
// store
@@ -711,11 +909,11 @@ store( "myPlugin", {
> **Note**
> We will rename `wp_store` to `wp_initial_state` in a future version.
-The state can also be initialized on the server using the `wp_store()` function. You would typically do this in the `render.php` file of your block (the `render.php` templates were [introduced](https://make.wordpress.org/core/2022/10/12/block-api-changes-in-wordpress-6-1/) in WordPress 6.1).
+The state can also be initialized on the server using the `wp_store()` function. You would typically do this in the `render.php` file of your block (the `render.php` templates were [introduced](https://make.wordpress.org/core/2022/10/12/block-api-changes-in-wordpress-6-1/) in WordPress 6.1).
The state defined on the server with `wp_store()` gets merged with the stores defined in the view.js files.
-The `wp_store` function receives an [associative array](https://www.php.net/manual/en/language.types.array.php) as a parameter.
+The `wp_store` function receives an [associative array](https://www.php.net/manual/en/language.types.array.php) as a parameter.
_Example of store initialized from the server with a `state` = `{ someValue: 123 }`_
diff --git a/packages/interactivity/package.json b/packages/interactivity/package.json
index 455af38b67fcaf..35c8dd2d85bd8d 100644
--- a/packages/interactivity/package.json
+++ b/packages/interactivity/package.json
@@ -26,9 +26,9 @@
"react-native": "src/index",
"types": "build-types",
"dependencies": {
- "@preact/signals": "^1.1.3",
- "deepsignal": "^1.3.6",
- "preact": "^10.13.2"
+ "@preact/signals": "^1.2.2",
+ "deepsignal": "^1.4.0",
+ "preact": "^10.19.3"
},
"publishConfig": {
"access": "public"
diff --git a/packages/interactivity/src/directives.js b/packages/interactivity/src/directives.js
index 0793dc0cc5d5ba..250d3bde6084c9 100644
--- a/packages/interactivity/src/directives.js
+++ b/packages/interactivity/src/directives.js
@@ -1,23 +1,15 @@
/**
* External dependencies
*/
-import {
- useContext,
- useMemo,
- useEffect,
- useRef,
- useLayoutEffect,
-} from 'preact/hooks';
+import { useContext, useMemo, useRef } from 'preact/hooks';
import { deepSignal, peek } from 'deepsignal';
/**
* Internal dependencies
*/
import { createPortal } from './portals';
-import { useSignalEffect } from './utils';
-import { directive } from './hooks';
-import { SlotProvider, Slot, Fill } from './slots';
-import { navigate } from './router';
+import { useWatch, useInit } from './utils';
+import { directive, getScope, getEvaluate } from './hooks';
const isObject = ( item ) =>
item && typeof item === 'object' && ! Array.isArray( item );
@@ -36,6 +28,64 @@ const mergeDeepSignals = ( target, source, overwrite ) => {
}
};
+const newRule =
+ /(?:([\u0080-\uFFFF\w-%@]+) *:? *([^{;]+?);|([^;}{]*?) *{)|(}\s*)/g;
+const ruleClean = /\/\*[^]*?\*\/| +/g;
+const ruleNewline = /\n+/g;
+const empty = ' ';
+
+/**
+ * Convert a css style string into a object.
+ *
+ * Made by Cristian Bote (@cristianbote) for Goober.
+ * https://unpkg.com/browse/goober@2.1.13/src/core/astish.js
+ *
+ * @param {string} val CSS string.
+ * @return {Object} CSS object.
+ */
+const cssStringToObject = ( val ) => {
+ const tree = [ {} ];
+ let block, left;
+
+ while ( ( block = newRule.exec( val.replace( ruleClean, '' ) ) ) ) {
+ if ( block[ 4 ] ) {
+ tree.shift();
+ } else if ( block[ 3 ] ) {
+ left = block[ 3 ].replace( ruleNewline, empty ).trim();
+ tree.unshift( ( tree[ 0 ][ left ] = tree[ 0 ][ left ] || {} ) );
+ } else {
+ tree[ 0 ][ block[ 1 ] ] = block[ 2 ]
+ .replace( ruleNewline, empty )
+ .trim();
+ }
+ }
+
+ return tree[ 0 ];
+};
+
+/**
+ * Creates a directive that adds an event listener to the global window or
+ * document object.
+ *
+ * @param {string} type 'window' or 'document'
+ * @return {void}
+ */
+const getGlobalEventDirective =
+ ( type ) =>
+ ( { directives, evaluate } ) => {
+ directives[ `on-${ type }` ]
+ .filter( ( { suffix } ) => suffix !== 'default' )
+ .forEach( ( entry ) => {
+ useInit( () => {
+ const cb = ( event ) => evaluate( entry, event );
+ const globalVar = type === 'window' ? window : document;
+ globalVar.addEventListener( entry.suffix, cb );
+ return () =>
+ globalVar.removeEventListener( entry.suffix, cb );
+ }, [] );
+ } );
+ };
+
export default () => {
// data-wp-context
directive(
@@ -75,26 +125,34 @@ export default () => {
// data-wp-watch--[name]
directive( 'watch', ( { directives: { watch }, evaluate } ) => {
watch.forEach( ( entry ) => {
- useSignalEffect( () => evaluate( entry ) );
+ useWatch( () => evaluate( entry ) );
} );
} );
// data-wp-init--[name]
directive( 'init', ( { directives: { init }, evaluate } ) => {
init.forEach( ( entry ) => {
- useEffect( () => evaluate( entry ), [] );
+ // TODO: Replace with useEffect to prevent unneeded scopes.
+ useInit( () => evaluate( entry ) );
} );
} );
// data-wp-on--[event]
directive( 'on', ( { directives: { on }, element, evaluate } ) => {
- on.forEach( ( entry ) => {
- element.props[ `on${ entry.suffix }` ] = ( event ) => {
- evaluate( entry, event );
- };
- } );
+ on.filter( ( { suffix } ) => suffix !== 'default' ).forEach(
+ ( entry ) => {
+ element.props[ `on${ entry.suffix }` ] = ( event ) => {
+ evaluate( entry, event );
+ };
+ }
+ );
} );
+ // data-wp-on-window--[event]
+ directive( 'on-window', getGlobalEventDirective( 'window' ) );
+ // data-wp-on-document--[event]
+ directive( 'on-document', getGlobalEventDirective( 'document' ) );
+
// data-wp-class--[classname]
directive(
'class',
@@ -118,55 +176,22 @@ export default () => {
? `${ currentClass } ${ name }`
: name;
- useEffect( () => {
- // This seems necessary because Preact doesn't change the class
- // names on the hydration, so we have to do it manually. It doesn't
- // need deps because it only needs to do it the first time.
+ useInit( () => {
+ /*
+ * This seems necessary because Preact doesn't change the class
+ * names on the hydration, so we have to do it manually. It doesn't
+ * need deps because it only needs to do it the first time.
+ */
if ( ! result ) {
element.ref.current.classList.remove( name );
} else {
element.ref.current.classList.add( name );
}
- }, [] );
+ } );
} );
}
);
- const newRule =
- /(?:([\u0080-\uFFFF\w-%@]+) *:? *([^{;]+?);|([^;}{]*?) *{)|(}\s*)/g;
- const ruleClean = /\/\*[^]*?\*\/| +/g;
- const ruleNewline = /\n+/g;
- const empty = ' ';
-
- /**
- * Convert a css style string into a object.
- *
- * Made by Cristian Bote (@cristianbote) for Goober.
- * https://unpkg.com/browse/goober@2.1.13/src/core/astish.js
- *
- * @param {string} val CSS string.
- * @return {Object} CSS object.
- */
- const cssStringToObject = ( val ) => {
- const tree = [ {} ];
- let block, left;
-
- while ( ( block = newRule.exec( val.replace( ruleClean, '' ) ) ) ) {
- if ( block[ 4 ] ) {
- tree.shift();
- } else if ( block[ 3 ] ) {
- left = block[ 3 ].replace( ruleNewline, empty ).trim();
- tree.unshift( ( tree[ 0 ][ left ] = tree[ 0 ][ left ] || {} ) );
- } else {
- tree[ 0 ][ block[ 1 ] ] = block[ 2 ]
- .replace( ruleNewline, empty )
- .trim();
- }
- }
-
- return tree[ 0 ];
- };
-
// data-wp-style--[style-key]
directive( 'style', ( { directives: { style }, element, evaluate } ) => {
style
@@ -182,16 +207,18 @@ export default () => {
if ( ! result ) delete element.props.style[ key ];
else element.props.style[ key ] = result;
- useEffect( () => {
- // This seems necessary because Preact doesn't change the styles on
- // the hydration, so we have to do it manually. It doesn't need deps
- // because it only needs to do it the first time.
+ useInit( () => {
+ /*
+ * This seems necessary because Preact doesn't change the styles on
+ * the hydration, so we have to do it manually. It doesn't need deps
+ * because it only needs to do it the first time.
+ */
if ( ! result ) {
element.ref.current.style.removeProperty( key );
} else {
element.ref.current.style[ key ] = result;
}
- }, [] );
+ } );
} );
} );
@@ -202,36 +229,37 @@ export default () => {
const attribute = entry.suffix;
const result = evaluate( entry );
element.props[ attribute ] = result;
- // Preact doesn't handle the `role` attribute properly, as it doesn't remove it when `null`.
- // We need this workaround until the following issue is solved:
- // https://github.com/preactjs/preact/issues/4136
- useLayoutEffect( () => {
- if (
- attribute === 'role' &&
- ( result === null || result === undefined )
- ) {
- element.ref.current.removeAttribute( attribute );
- }
- }, [ attribute, result ] );
- // This seems necessary because Preact doesn't change the attributes
- // on the hydration, so we have to do it manually. It doesn't need
- // deps because it only needs to do it the first time.
- useEffect( () => {
+ /*
+ * This is necessary because Preact doesn't change the attributes on the
+ * hydration, so we have to do it manually. It only needs to do it the
+ * first time. After that, Preact will handle the changes.
+ */
+ useInit( () => {
const el = element.ref.current;
- // We set the value directly to the corresponding
- // HTMLElement instance property excluding the following
- // special cases.
- // We follow Preact's logic: https://github.com/preactjs/preact/blob/ea49f7a0f9d1ff2c98c0bdd66aa0cbc583055246/src/diff/props.js#L110-L129
+ /*
+ * We set the value directly to the corresponding HTMLElement instance
+ * property excluding the following special cases. We follow Preact's
+ * logic: https://github.com/preactjs/preact/blob/ea49f7a0f9d1ff2c98c0bdd66aa0cbc583055246/src/diff/props.js#L110-L129
+ */
if (
attribute !== 'width' &&
attribute !== 'height' &&
attribute !== 'href' &&
attribute !== 'list' &&
attribute !== 'form' &&
- // Default value in browsers is `-1` and an empty string is
- // cast to `0` instead
+ /*
+ * The value for `tabindex` follows the parsing rules for an
+ * integer. If that fails, or if the attribute isn't present, then
+ * the browsers should "follow platform conventions to determine if
+ * the element should be considered as a focusable area",
+ * practically meaning that most elements get a default of `-1` (not
+ * focusable), but several also get a default of `0` (focusable in
+ * order after all elements with a positive `tabindex` value).
+ *
+ * @see https://html.spec.whatwg.org/#tabindex-value
+ */
attribute !== 'tabIndex' &&
attribute !== 'download' &&
attribute !== 'rowSpan' &&
@@ -247,10 +275,12 @@ export default () => {
return;
} catch ( err ) {}
}
- // aria- and data- attributes have no boolean representation.
- // A `false` value is different from the attribute not being
- // present, so we can't remove it.
- // We follow Preact's logic: https://github.com/preactjs/preact/blob/ea49f7a0f9d1ff2c98c0bdd66aa0cbc583055246/src/diff/props.js#L131C24-L136
+ /*
+ * aria- and data- attributes have no boolean representation.
+ * A `false` value is different from the attribute not being
+ * present, so we can't remove it.
+ * We follow Preact's logic: https://github.com/preactjs/preact/blob/ea49f7a0f9d1ff2c98c0bdd66aa0cbc583055246/src/diff/props.js#L131C24-L136
+ */
if (
result !== null &&
result !== undefined &&
@@ -260,53 +290,11 @@ export default () => {
} else {
el.removeAttribute( attribute );
}
- }, [] );
+ } );
}
);
} );
- // data-wp-navigation-link
- directive(
- 'navigation-link',
- ( {
- directives: { 'navigation-link': navigationLink },
- props: { href },
- element,
- } ) => {
- const { value: link } = navigationLink.find(
- ( { suffix } ) => suffix === 'default'
- );
-
- useEffect( () => {
- // Prefetch the page if it is in the directive options.
- if ( link?.prefetch ) {
- // prefetch( href );
- }
- } );
-
- // Don't do anything if it's falsy.
- if ( link !== false ) {
- element.props.onclick = async ( event ) => {
- event.preventDefault();
-
- // Fetch the page (or return it from cache).
- await navigate( href );
-
- // Update the scroll, depending on the option. True by default.
- if ( link?.scroll === 'smooth' ) {
- window.scrollTo( {
- top: 0,
- left: 0,
- behavior: 'smooth',
- } );
- } else if ( link?.scroll !== false ) {
- window.scrollTo( 0, 0 );
- }
- };
- }
- }
- );
-
// data-wp-ignore
directive(
'ignore',
@@ -330,64 +318,62 @@ export default () => {
// data-wp-text
directive( 'text', ( { directives: { text }, element, evaluate } ) => {
const entry = text.find( ( { suffix } ) => suffix === 'default' );
- element.props.children = evaluate( entry );
+ try {
+ const result = evaluate( entry );
+ element.props.children =
+ typeof result === 'object' ? null : result.toString();
+ } catch ( e ) {
+ element.props.children = null;
+ }
+ } );
+
+ // data-wp-run
+ directive( 'run', ( { directives: { run }, evaluate } ) => {
+ run.forEach( ( entry ) => evaluate( entry ) );
} );
- // data-wp-slot
+ // data-wp-each--[item]
directive(
- 'slot',
- ( { directives: { slot }, props: { children }, element } ) => {
- const { value } = slot.find(
- ( { suffix } ) => suffix === 'default'
- );
- const name = typeof value === 'string' ? value : value.name;
- const position = value.position || 'children';
+ 'each',
+ ( {
+ directives: { each, 'each-key': eachKey },
+ context: inheritedContext,
+ element,
+ evaluate,
+ } ) => {
+ if ( element.type !== 'template' ) return;
+
+ const { Provider } = inheritedContext;
+ const inheritedValue = useContext( inheritedContext );
+
+ const [ entry ] = each;
+ const { namespace, suffix } = entry;
+
+ const list = evaluate( entry );
+ return list.map( ( item ) => {
+ const mergedContext = deepSignal( {} );
+
+ const itemProp = suffix === 'default' ? 'item' : suffix;
+ const newValue = deepSignal( {
+ [ namespace ]: { [ itemProp ]: item },
+ } );
+ mergeDeepSignals( newValue, inheritedValue );
+ mergeDeepSignals( mergedContext, newValue, true );
+
+ const scope = { ...getScope(), context: mergedContext };
+ const key = eachKey
+ ? getEvaluate( { scope } )( eachKey[ 0 ] )
+ : item;
- if ( position === 'before' ) {
- return (
- <>
-
- { children }
- >
- );
- }
- if ( position === 'after' ) {
return (
- <>
- { children }
-
- >
+
+ { element.props.content }
+
);
- }
- if ( position === 'replace' ) {
- return
{ children } ;
- }
- if ( position === 'children' ) {
- element.props.children = (
-
{ element.props.children }
- );
- }
- },
- { priority: 4 }
- );
-
- // data-wp-fill
- directive(
- 'fill',
- ( { directives: { fill }, props: { children }, evaluate } ) => {
- const entry = fill.find( ( { suffix } ) => suffix === 'default' );
- const slot = evaluate( entry );
- return
{ children } ;
+ } );
},
- { priority: 4 }
+ { priority: 20 }
);
- // data-wp-slot-provider
- directive(
- 'slot-provider',
- ( { props: { children } } ) => (
-
{ children }
- ),
- { priority: 4 }
- );
+ directive( 'each-child', () => null );
};
diff --git a/packages/interactivity/src/hooks.tsx b/packages/interactivity/src/hooks.tsx
index 14f0dc6683b460..c133eb9981880b 100644
--- a/packages/interactivity/src/hooks.tsx
+++ b/packages/interactivity/src/hooks.tsx
@@ -3,7 +3,6 @@
*/
import { h, options, createContext, cloneElement } from 'preact';
import { useRef, useCallback, useContext } from 'preact/hooks';
-import { deepSignal } from 'deepsignal';
import type { VNode, Context, RefObject } from 'preact';
/**
@@ -60,8 +59,7 @@ interface Scope {
evaluate: Evaluate;
context: Context< any >;
ref: RefObject< HTMLElement >;
- state: any;
- props: any;
+ attributes: h.JSX.HTMLAttributes;
}
interface Evaluate {
@@ -142,11 +140,10 @@ export const getElement = () => {
'Cannot call `getElement()` outside getters and actions used by directives.'
);
}
- const { ref, state, props } = getScope();
+ const { ref, attributes } = getScope();
return Object.freeze( {
ref: ref.current,
- state,
- props: deepImmutable( props ),
+ attributes: deepImmutable( attributes ),
} );
};
@@ -159,6 +156,8 @@ export const resetScope = () => {
scopeStack.pop();
};
+export const getNamespace = () => namespaceStack.slice( -1 )[ 0 ];
+
export const setNamespace = ( namespace: string ) => {
namespaceStack.push( namespace );
};
@@ -262,7 +261,7 @@ const resolve = ( path, namespace ) => {
};
// Generate the evaluate function.
-const getEvaluate: GetEvaluate =
+export const getEvaluate: GetEvaluate =
( { scope } ) =>
( entry, ...args ) => {
let { value: path, namespace } = entry;
@@ -313,12 +312,12 @@ const Directives = ( {
scope.context = useContext( context );
/* eslint-disable react-hooks/rules-of-hooks */
scope.ref = previousScope?.ref || useRef( null );
- scope.state = previousScope?.state || useRef( deepSignal( {} ) ).current;
/* eslint-enable react-hooks/rules-of-hooks */
- // Create a fresh copy of the vnode element and add the props to the scope.
+ // Create a fresh copy of the vnode element and add the props to the scope,
+ // named as attributes (HTML Attributes).
element = cloneElement( element, { ref: scope.ref } );
- scope.props = element.props;
+ scope.attributes = element.props;
// Recursively render the wrapper for the next priority level.
const children =
diff --git a/packages/interactivity/src/index.js b/packages/interactivity/src/index.js
index 6c7b98e8e7a79e..5d9165dc9920ee 100644
--- a/packages/interactivity/src/index.js
+++ b/packages/interactivity/src/index.js
@@ -2,13 +2,25 @@
* Internal dependencies
*/
import registerDirectives from './directives';
-import { init } from './router';
+import { init } from './init';
export { store } from './store';
-export { directive, getContext, getElement } from './hooks';
-export { navigate, prefetch } from './router';
-export { h as createElement } from 'preact';
-export { useEffect, useContext, useMemo } from 'preact/hooks';
+export { directive, getContext, getElement, getNamespace } from './hooks';
+export {
+ withScope,
+ useWatch,
+ useInit,
+ useEffect,
+ useLayoutEffect,
+ useCallback,
+ useMemo,
+} from './utils';
+export { directivePrefix } from './constants';
+export { toVdom } from './vdom';
+export { getRegionRootFragment } from './init';
+
+export { h as createElement, cloneElement, render } from 'preact';
+export { useContext, useState, useRef } from 'preact/hooks';
export { deepSignal } from 'deepsignal';
document.addEventListener( 'DOMContentLoaded', async () => {
diff --git a/packages/interactivity/src/init.js b/packages/interactivity/src/init.js
new file mode 100644
index 00000000000000..d749003b86f49d
--- /dev/null
+++ b/packages/interactivity/src/init.js
@@ -0,0 +1,35 @@
+/**
+ * External dependencies
+ */
+import { hydrate } from 'preact';
+/**
+ * Internal dependencies
+ */
+import { toVdom, hydratedIslands } from './vdom';
+import { createRootFragment } from './utils';
+import { directivePrefix } from './constants';
+
+// Keep the same root fragment for each interactive region node.
+const regionRootFragments = new WeakMap();
+export const getRegionRootFragment = ( region ) => {
+ if ( ! regionRootFragments.has( region ) ) {
+ regionRootFragments.set(
+ region,
+ createRootFragment( region.parentElement, region )
+ );
+ }
+ return regionRootFragments.get( region );
+};
+
+// Initialize the router with the initial DOM.
+export const init = async () => {
+ document
+ .querySelectorAll( `[data-${ directivePrefix }-interactive]` )
+ .forEach( ( node ) => {
+ if ( ! hydratedIslands.has( node ) ) {
+ const fragment = getRegionRootFragment( node );
+ const vdom = toVdom( node );
+ hydrate( vdom, fragment );
+ }
+ } );
+};
diff --git a/packages/interactivity/src/router.js b/packages/interactivity/src/router.js
deleted file mode 100644
index 1082d43ff3a6a6..00000000000000
--- a/packages/interactivity/src/router.js
+++ /dev/null
@@ -1,177 +0,0 @@
-/**
- * External dependencies
- */
-import { hydrate, render } from 'preact';
-/**
- * Internal dependencies
- */
-import { toVdom, hydratedIslands } from './vdom';
-import { createRootFragment } from './utils';
-import { directivePrefix } from './constants';
-
-// The cache of visited and prefetched pages.
-const pages = new Map();
-
-// Keep the same root fragment for each interactive region node.
-const regionRootFragments = new WeakMap();
-const getRegionRootFragment = ( region ) => {
- if ( ! regionRootFragments.has( region ) ) {
- regionRootFragments.set(
- region,
- createRootFragment( region.parentElement, region )
- );
- }
- return regionRootFragments.get( region );
-};
-
-// Helper to remove domain and hash from the URL. We are only interesting in
-// caching the path and the query.
-const cleanUrl = ( url ) => {
- const u = new URL( url, window.location );
- return u.pathname + u.search;
-};
-
-// Fetch a new page and convert it to a static virtual DOM.
-const fetchPage = async ( url, { html } ) => {
- try {
- if ( ! html ) {
- const res = await window.fetch( url );
- if ( res.status !== 200 ) return false;
- html = await res.text();
- }
- const dom = new window.DOMParser().parseFromString( html, 'text/html' );
- return regionsToVdom( dom );
- } catch ( e ) {
- return false;
- }
-};
-
-// Return an object with VDOM trees of those HTML regions marked with a
-// `navigation-id` directive.
-const regionsToVdom = ( dom ) => {
- const regions = {};
- const attrName = `data-${ directivePrefix }-navigation-id`;
- dom.querySelectorAll( `[${ attrName }]` ).forEach( ( region ) => {
- const id = region.getAttribute( attrName );
- regions[ id ] = toVdom( region );
- } );
- const title = dom.querySelector( 'title' )?.innerText;
- return { regions, title };
-};
-
-/**
- * Prefetchs the page with the passed URL.
- *
- * The function normalizes the URL and stores internally the fetch promise, to
- * avoid triggering a second fetch for an ongoing request.
- *
- * @param {string} url The page URL.
- * @param {Object} [options] Options object.
- * @param {boolean} [options.force] Force fetching the URL again.
- * @param {string} [options.html] HTML string to be used instead of fetching
- * the requested URL.
- */
-export const prefetch = ( url, options = {} ) => {
- url = cleanUrl( url );
- if ( options.force || ! pages.has( url ) ) {
- pages.set( url, fetchPage( url, options ) );
- }
-};
-
-// Render all interactive regions contained in the given page.
-const renderRegions = ( page ) => {
- const attrName = `data-${ directivePrefix }-navigation-id`;
- document.querySelectorAll( `[${ attrName }]` ).forEach( ( region ) => {
- const id = region.getAttribute( attrName );
- const fragment = getRegionRootFragment( region );
- render( page.regions[ id ], fragment );
- } );
- if ( page.title ) {
- document.title = page.title;
- }
-};
-
-// Variable to store the current navigation.
-let navigatingTo = '';
-
-/**
- * Navigates to the specified page.
- *
- * This function normalizes the passed href, fetchs the page HTML if needed, and
- * updates any interactive regions whose contents have changed. It also creates
- * a new entry in the browser session history.
- *
- * @param {string} href The page href.
- * @param {Object} [options] Options object.
- * @param {boolean} [options.force] If true, it forces re-fetching the URL.
- * @param {string} [options.html] HTML string to be used instead of fetching
- * the requested URL.
- * @param {boolean} [options.replace] If true, it replaces the current entry in
- * the browser session history.
- * @param {number} [options.timeout] Time until the navigation is aborted, in
- * milliseconds. Default is 10000.
- *
- * @return {Promise} Promise that resolves once the navigation is completed or
- * aborted.
- */
-export const navigate = async ( href, options = {} ) => {
- const url = cleanUrl( href );
- navigatingTo = href;
- prefetch( url, options );
-
- // Create a promise that resolves when the specified timeout ends. The
- // timeout value is 10 seconds by default.
- const timeoutPromise = new Promise( ( resolve ) =>
- setTimeout( resolve, options.timeout ?? 10000 )
- );
-
- const page = await Promise.race( [ pages.get( url ), timeoutPromise ] );
-
- // Once the page is fetched, the destination URL could have changed (e.g.,
- // by clicking another link in the meantime). If so, bail out, and let the
- // newer execution to update the HTML.
- if ( navigatingTo !== href ) return;
-
- if ( page ) {
- renderRegions( page );
- window.history[ options.replace ? 'replaceState' : 'pushState' ](
- {},
- '',
- href
- );
- } else {
- window.location.assign( href );
- await new Promise( () => {} );
- }
-};
-
-// Listen to the back and forward buttons and restore the page if it's in the
-// cache.
-window.addEventListener( 'popstate', async () => {
- const url = cleanUrl( window.location ); // Remove hash.
- const page = pages.has( url ) && ( await pages.get( url ) );
- if ( page ) {
- renderRegions( page );
- } else {
- window.location.reload();
- }
-} );
-
-// Initialize the router with the initial DOM.
-export const init = async () => {
- document
- .querySelectorAll( `[data-${ directivePrefix }-interactive]` )
- .forEach( ( node ) => {
- if ( ! hydratedIslands.has( node ) ) {
- const fragment = getRegionRootFragment( node );
- const vdom = toVdom( node );
- hydrate( vdom, fragment );
- }
- } );
-
- // Cache the current regions.
- pages.set(
- cleanUrl( window.location ),
- Promise.resolve( regionsToVdom( document ) )
- );
-};
diff --git a/packages/interactivity/src/slots.js b/packages/interactivity/src/slots.js
deleted file mode 100644
index e8bc6ddfa368f5..00000000000000
--- a/packages/interactivity/src/slots.js
+++ /dev/null
@@ -1,38 +0,0 @@
-/**
- * External dependencies
- */
-import { createContext } from 'preact';
-import { useContext, useEffect } from 'preact/hooks';
-import { signal } from '@preact/signals';
-
-const slotsContext = createContext();
-
-export const Fill = ( { slot, children } ) => {
- const slots = useContext( slotsContext );
-
- useEffect( () => {
- if ( slot ) {
- slots.value = { ...slots.value, [ slot ]: children };
- return () => {
- slots.value = { ...slots.value, [ slot ]: null };
- };
- }
- }, [ slots, slot, children ] );
-
- return !! slot ? null : children;
-};
-
-export const SlotProvider = ( { children } ) => {
- return (
- // TODO: We can change this to use deepsignal once this PR is merged.
- // https://github.com/luisherranz/deepsignal/pull/38
-
- { children }
-
- );
-};
-
-export const Slot = ( { name, children } ) => {
- const slots = useContext( slotsContext );
- return slots.value[ name ] || children;
-};
diff --git a/packages/interactivity/src/store.ts b/packages/interactivity/src/store.ts
index 8463d1a0a51323..5177c72cfda462 100644
--- a/packages/interactivity/src/store.ts
+++ b/packages/interactivity/src/store.ts
@@ -36,12 +36,12 @@ const deepMerge = ( target: any, source: any ) => {
const parseInitialState = () => {
const storeTag = document.querySelector(
- `script[type="application/json"]#wp-interactivity-initial-state`
+ `script[type="application/json"]#wp-interactivity-data`
);
if ( ! storeTag?.textContent ) return {};
try {
- const initialState = JSON.parse( storeTag.textContent );
- if ( isObject( initialState ) ) return initialState;
+ const { state } = JSON.parse( storeTag.textContent );
+ if ( isObject( state ) ) return state;
throw Error( 'Parsed state is not an object' );
} catch ( e ) {
// eslint-disable-next-line no-console
diff --git a/packages/interactivity/src/utils.js b/packages/interactivity/src/utils.js
index 10b53104fb9c89..84e04803cea4f5 100644
--- a/packages/interactivity/src/utils.js
+++ b/packages/interactivity/src/utils.js
@@ -1,9 +1,26 @@
/**
* External dependencies
*/
-import { useEffect } from 'preact/hooks';
+import {
+ useMemo as _useMemo,
+ useCallback as _useCallback,
+ useEffect as _useEffect,
+ useLayoutEffect as _useLayoutEffect,
+} from 'preact/hooks';
import { effect } from '@preact/signals';
+/**
+ * Internal dependencies
+ */
+import {
+ getScope,
+ setScope,
+ resetScope,
+ getNamespace,
+ setNamespace,
+ resetNamespace,
+} from './hooks';
+
const afterNextFrame = ( callback ) => {
return new Promise( ( resolve ) => {
const done = () => {
@@ -38,7 +55,7 @@ function createFlusher( compute, notify ) {
// implementation comes from this PR, but we added short-cirtuiting to avoid
// infinite loops: https://github.com/preactjs/signals/pull/290
export function useSignalEffect( callback ) {
- useEffect( () => {
+ _useEffect( () => {
let eff = null;
let isExecuting = false;
const notify = async () => {
@@ -53,6 +70,148 @@ export function useSignalEffect( callback ) {
}, [] );
}
+/**
+ * Returns the passed function wrapped with the current scope so it is
+ * accessible whenever the function runs. This is primarily to make the scope
+ * available inside hook callbacks.
+ *
+ * @param {Function} func The passed function.
+ * @return {Function} The wrapped function.
+ */
+export const withScope = ( func ) => {
+ const scope = getScope();
+ const ns = getNamespace();
+ if ( func?.constructor?.name === 'GeneratorFunction' ) {
+ return async ( ...args ) => {
+ const gen = func( ...args );
+ let value;
+ let it;
+ while ( true ) {
+ setNamespace( ns );
+ setScope( scope );
+ try {
+ it = gen.next( value );
+ } finally {
+ resetNamespace();
+ resetScope();
+ }
+ try {
+ value = await it.value;
+ } catch ( e ) {
+ gen.throw( e );
+ }
+ if ( it.done ) break;
+ }
+ return value;
+ };
+ }
+ return ( ...args ) => {
+ setNamespace( ns );
+ setScope( scope );
+ try {
+ return func( ...args );
+ } finally {
+ resetNamespace();
+ resetScope();
+ }
+ };
+};
+
+/**
+ * Accepts a function that contains imperative code which runs whenever any of
+ * the accessed _reactive_ properties (e.g., values from the global state or the
+ * context) is modified.
+ *
+ * This hook makes the element's scope available so functions like
+ * `getElement()` and `getContext()` can be used inside the passed callback.
+ *
+ * @param {Function} callback The hook callback.
+ */
+export function useWatch( callback ) {
+ useSignalEffect( withScope( callback ) );
+}
+
+/**
+ * Accepts a function that contains imperative code which runs only after the
+ * element's first render, mainly useful for intialization logic.
+ *
+ * This hook makes the element's scope available so functions like
+ * `getElement()` and `getContext()` can be used inside the passed callback.
+ *
+ * @param {Function} callback The hook callback.
+ */
+export function useInit( callback ) {
+ _useEffect( withScope( callback ), [] );
+}
+
+/**
+ * Accepts a function that contains imperative, possibly effectful code. The
+ * effects run after browser paint, without blocking it.
+ *
+ * This hook is equivalent to Preact's `useEffect` and makes the element's scope
+ * available so functions like `getElement()` and `getContext()` can be used
+ * inside the passed callback.
+ *
+ * @param {Function} callback Imperative function that can return a cleanup
+ * function.
+ * @param {any[]} inputs If present, effect will only activate if the
+ * values in the list change (using `===`).
+ */
+export function useEffect( callback, inputs ) {
+ _useEffect( withScope( callback ), inputs );
+}
+
+/**
+ * Accepts a function that contains imperative, possibly effectful code. Use
+ * this to read layout from the DOM and synchronously re-render.
+ *
+ * This hook is equivalent to Preact's `useLayoutEffect` and makes the element's
+ * scope available so functions like `getElement()` and `getContext()` can be
+ * used inside the passed callback.
+ *
+ * @param {Function} callback Imperative function that can return a cleanup
+ * function.
+ * @param {any[]} inputs If present, effect will only activate if the
+ * values in the list change (using `===`).
+ */
+export function useLayoutEffect( callback, inputs ) {
+ _useLayoutEffect( withScope( callback ), inputs );
+}
+
+/**
+ * Returns a memoized version of the callback that only changes if one of the
+ * inputs has changed (using `===`).
+ *
+ * This hook is equivalent to Preact's `useCallback` and makes the element's
+ * scope available so functions like `getElement()` and `getContext()` can be
+ * used inside the passed callback.
+ *
+ * @param {Function} callback Imperative function that can return a cleanup
+ * function.
+ * @param {any[]} inputs If present, effect will only activate if the
+ * values in the list change (using `===`).
+ */
+export function useCallback( callback, inputs ) {
+ _useCallback( withScope( callback ), inputs );
+}
+
+/**
+ * Pass a factory function and an array of inputs. `useMemo` will only recompute
+ * the memoized value when one of the inputs has changed.
+ *
+ * This hook is equivalent to Preact's `useMemo` and makes the element's scope
+ * available so functions like `getElement()` and `getContext()` can be used
+ * inside the passed factory function.
+ *
+ * @param {Function} factory Imperative function that can return a cleanup
+ * function.
+ * @param {any[]} inputs If present, effect will only activate if the
+ * values in the list change (using `===`).
+ */
+export function useMemo( factory, inputs ) {
+ _useMemo( withScope( factory ), inputs );
+}
+
// For wrapperless hydration.
// See https://gist.github.com/developit/f4c67a2ede71dc2fab7f357f39cff28c
export const createRootFragment = ( parent, replaceNode ) => {
diff --git a/packages/interactivity/src/vdom.js b/packages/interactivity/src/vdom.js
index 860a3149e6ffd6..4a7cfff9f9d0df 100644
--- a/packages/interactivity/src/vdom.js
+++ b/packages/interactivity/src/vdom.js
@@ -43,7 +43,7 @@ export function toVdom( root ) {
);
function walk( node ) {
- const { attributes, nodeType } = node;
+ const { attributes, nodeType, localName } = node;
if ( nodeType === 3 ) return [ node.data ];
if ( nodeType === 4 ) {
@@ -93,7 +93,7 @@ export function toVdom( root ) {
if ( ignore && ! island )
return [
- h( node.localName, {
+ h( localName, {
...props,
innerHTML: node.innerHTML,
__directives: { ignore: true },
@@ -118,20 +118,26 @@ export function toVdom( root ) {
);
}
- let child = treeWalker.firstChild();
- if ( child ) {
- while ( child ) {
- const [ vnode, nextChild ] = walk( child );
- if ( vnode ) children.push( vnode );
- child = nextChild || treeWalker.nextSibling();
+ if ( localName === 'template' ) {
+ props.content = [ ...node.content.childNodes ].map( ( childNode ) =>
+ toVdom( childNode )
+ );
+ } else {
+ let child = treeWalker.firstChild();
+ if ( child ) {
+ while ( child ) {
+ const [ vnode, nextChild ] = walk( child );
+ if ( vnode ) children.push( vnode );
+ child = nextChild || treeWalker.nextSibling();
+ }
+ treeWalker.parentNode();
}
- treeWalker.parentNode();
}
// Restore previous namespace.
if ( island ) namespaces.pop();
- return [ h( node.localName, props, children ) ];
+ return [ h( localName, props, children ) ];
}
return walk( treeWalker.currentNode );
diff --git a/packages/interface/lock-unlock.js b/packages/interface/lock-unlock.js
new file mode 100644
index 00000000000000..b6e29bb71c7c02
--- /dev/null
+++ b/packages/interface/lock-unlock.js
@@ -0,0 +1,10 @@
+/**
+ * WordPress dependencies
+ */
+import { __dangerousOptInToUnstableAPIsOnlyForCoreModules } from '@wordpress/private-apis';
+
+export const { lock, unlock } =
+ __dangerousOptInToUnstableAPIsOnlyForCoreModules(
+ 'I know using unstable features means my theme or plugin will inevitably break in the next version of WordPress.',
+ '@wordpress/interface'
+ );
diff --git a/packages/interface/package.json b/packages/interface/package.json
index 3323631e4b5833..feebc542d3a781 100644
--- a/packages/interface/package.json
+++ b/packages/interface/package.json
@@ -42,6 +42,7 @@
"@wordpress/icons": "file:../icons",
"@wordpress/plugins": "file:../plugins",
"@wordpress/preferences": "file:../preferences",
+ "@wordpress/private-apis": "file:../private-apis",
"@wordpress/viewport": "file:../viewport",
"classnames": "^2.3.1"
},
diff --git a/packages/interface/src/components/index.js b/packages/interface/src/components/index.js
index c9c2d09b3b3ab0..6f986e138838e1 100644
--- a/packages/interface/src/components/index.js
+++ b/packages/interface/src/components/index.js
@@ -6,8 +6,4 @@ export { default as PinnedItems } from './pinned-items';
export { default as MoreMenuDropdown } from './more-menu-dropdown';
export { default as MoreMenuFeatureToggle } from './more-menu-feature-toggle';
export { default as ActionItem } from './action-item';
-export { default as PreferencesModal } from './preferences-modal';
-export { default as PreferencesModalTabs } from './preferences-modal-tabs';
-export { default as PreferencesModalSection } from './preferences-modal-section';
-export { default as ___unstablePreferencesModalBaseOption } from './preferences-modal-base-option';
export { default as NavigableRegion } from './navigable-region';
diff --git a/packages/interface/src/components/interface-skeleton/index.js b/packages/interface/src/components/interface-skeleton/index.js
index baf98d153ed870..8d6a060259a292 100644
--- a/packages/interface/src/components/interface-skeleton/index.js
+++ b/packages/interface/src/components/interface-skeleton/index.js
@@ -11,7 +11,7 @@ import {
__unstableUseNavigateRegions as useNavigateRegions,
__unstableMotion as motion,
} from '@wordpress/components';
-import { __ } from '@wordpress/i18n';
+import { __, _x } from '@wordpress/i18n';
import { useMergeRefs } from '@wordpress/compose';
/**
@@ -68,7 +68,7 @@ function InterfaceSkeleton(
const defaultLabels = {
/* translators: accessibility text for the top bar landmark region. */
- header: __( 'Header' ),
+ header: _x( 'Header', 'header landmark area' ),
/* translators: accessibility text for the content landmark region. */
body: __( 'Content' ),
/* translators: accessibility text for the secondary sidebar landmark region. */
diff --git a/packages/interface/src/components/pinned-items/style.scss b/packages/interface/src/components/pinned-items/style.scss
index 66062b7fa3dbbf..420b94e2994b16 100644
--- a/packages/interface/src/components/pinned-items/style.scss
+++ b/packages/interface/src/components/pinned-items/style.scss
@@ -27,7 +27,4 @@
// Gap between pinned items.
gap: $grid-unit-10;
-
- // Account for larger grid from parent container gap.
- margin-right: -$grid-unit-05;
}
diff --git a/packages/interface/src/components/preferences-modal-section/index.js b/packages/interface/src/components/preferences-modal-section/index.js
deleted file mode 100644
index 8ea2ca2652d6df..00000000000000
--- a/packages/interface/src/components/preferences-modal-section/index.js
+++ /dev/null
@@ -1,19 +0,0 @@
-const Section = ( { description, title, children } ) => (
-
-
-
- { title }
-
- { description && (
-
- { description }
-
- ) }
-
-
- { children }
-
-
-);
-
-export default Section;
diff --git a/packages/interface/src/components/preferences-modal-tabs/style.scss b/packages/interface/src/components/preferences-modal-tabs/style.scss
deleted file mode 100644
index 04b71f0a773a20..00000000000000
--- a/packages/interface/src/components/preferences-modal-tabs/style.scss
+++ /dev/null
@@ -1,49 +0,0 @@
-$vertical-tabs-width: 160px;
-
-.interface-preferences__tabs {
- .components-tab-panel__tabs {
- position: absolute;
- top: $header-height + $grid-unit-30;
- // Aligns button text instead of button box.
- left: $grid-unit-20;
- width: $vertical-tabs-width;
-
- .components-tab-panel__tabs-item {
- border-radius: $radius-block-ui;
- font-weight: 400;
-
- &.is-active {
- background: $gray-100;
- box-shadow: none;
- font-weight: 500;
- }
-
- &.is-active::after {
- content: none;
- }
-
- &:focus:not(:disabled) {
- box-shadow: inset 0 0 0 var(--wp-admin-border-width-focus) var(--wp-admin-theme-color);
- // Windows high contrast mode.
- outline: 2px solid transparent;
- }
-
- &:focus-visible::before {
- content: none;
- }
- }
- }
-
- .components-tab-panel__tab-content {
- padding-left: $grid-unit-30;
- margin-left: $vertical-tabs-width;
- }
-}
-
-@media (max-width: #{ ($break-medium - 1) }) {
- // Keep the navigator component from overflowing the modal content area
- // to ensure that sticky position elements stick where intended.
- .interface-preferences__provider {
- height: 100%;
- }
-}
diff --git a/packages/interface/src/style.scss b/packages/interface/src/style.scss
index be111640a0a796..e6950de411156a 100644
--- a/packages/interface/src/style.scss
+++ b/packages/interface/src/style.scss
@@ -4,7 +4,3 @@
@import "./components/interface-skeleton/style.scss";
@import "./components/more-menu-dropdown/style.scss";
@import "./components/pinned-items/style.scss";
-@import "./components/preferences-modal/style.scss";
-@import "./components/preferences-modal-tabs/style.scss";
-@import "./components/preferences-modal-section/style.scss";
-@import "./components/preferences-modal-base-option/style.scss";
diff --git a/packages/notices/src/store/actions.js b/packages/notices/src/store/actions.js
index 445eed10650b61..fb1c4243073511 100644
--- a/packages/notices/src/store/actions.js
+++ b/packages/notices/src/store/actions.js
@@ -330,7 +330,7 @@ export function removeNotice( id, context = DEFAULT_CONTEXT ) {
* const notices = useSelect( ( select ) =>
* select( noticesStore ).getNotices()
* );
- * const { removeNotices } = useDispatch( noticesStore );
+ * const { removeAllNotices } = useDispatch( noticesStore );
* return (
* <>
*
diff --git a/packages/patterns/src/components/partial-syncing-controls.js b/packages/patterns/src/components/partial-syncing-controls.js
index d20bd1d347012e..f5ac19bc05f3d7 100644
--- a/packages/patterns/src/components/partial-syncing-controls.js
+++ b/packages/patterns/src/components/partial-syncing-controls.js
@@ -19,7 +19,7 @@ function PartialSyncingControls( { name, attributes, setAttributes } ) {
const syncedAttributes = PARTIAL_SYNCING_SUPPORTED_BLOCKS[ name ];
const attributeSources = Object.keys( syncedAttributes ).map(
( attributeName ) =>
- attributes.connections?.attributes?.[ attributeName ]?.source
+ attributes.metadata?.bindings?.[ attributeName ]?.source?.name
);
const isConnectedToOtherSources = attributeSources.every(
( source ) => source && source !== 'pattern_attributes'
@@ -30,52 +30,58 @@ function PartialSyncingControls( { name, attributes, setAttributes } ) {
return null;
}
- function updateConnections( isChecked ) {
- let updatedConnections = {
- ...attributes.connections,
- attributes: { ...attributes.connections?.attributes },
+ function updateBindings( isChecked ) {
+ let updatedBindings = {
+ ...attributes?.metadata?.bindings,
};
if ( ! isChecked ) {
for ( const attributeName of Object.keys( syncedAttributes ) ) {
if (
- updatedConnections.attributes[ attributeName ]?.source ===
+ updatedBindings[ attributeName ]?.source?.name ===
'pattern_attributes'
) {
- delete updatedConnections.attributes[ attributeName ];
+ delete updatedBindings[ attributeName ];
}
}
- if ( ! Object.keys( updatedConnections.attributes ).length ) {
- delete updatedConnections.attributes;
- }
- if ( ! Object.keys( updatedConnections ).length ) {
- updatedConnections = undefined;
+ if ( ! Object.keys( updatedBindings ).length ) {
+ updatedBindings = undefined;
}
setAttributes( {
- connections: updatedConnections,
+ metadata: {
+ ...attributes.metadata,
+ bindings: updatedBindings,
+ },
} );
return;
}
for ( const attributeName of Object.keys( syncedAttributes ) ) {
- if ( ! updatedConnections.attributes[ attributeName ] ) {
- updatedConnections.attributes[ attributeName ] = {
- source: 'pattern_attributes',
+ if ( ! updatedBindings[ attributeName ] ) {
+ updatedBindings[ attributeName ] = {
+ source: {
+ name: 'pattern_attributes',
+ },
};
}
}
if ( typeof attributes.metadata?.id === 'string' ) {
- setAttributes( { connections: updatedConnections } );
+ setAttributes( {
+ metadata: {
+ ...attributes.metadata,
+ bindings: updatedBindings,
+ },
+ } );
return;
}
const id = nanoid( 6 );
setAttributes( {
- connections: updatedConnections,
metadata: {
...attributes.metadata,
id,
+ bindings: updatedBindings,
},
} );
}
@@ -93,7 +99,7 @@ function PartialSyncingControls( { name, attributes, setAttributes } ) {
( source ) => source === 'pattern_attributes'
) }
onChange={ ( isChecked ) => {
- updateConnections( isChecked );
+ updateBindings( isChecked );
} }
/>
diff --git a/packages/patterns/src/constants.js b/packages/patterns/src/constants.js
index 3e533d834fd75c..9ce2aff2fc46d4 100644
--- a/packages/patterns/src/constants.js
+++ b/packages/patterns/src/constants.js
@@ -23,4 +23,15 @@ export const PATTERN_SYNC_TYPES = {
// TODO: This should not be hardcoded. Maybe there should be a config and/or an UI.
export const PARTIAL_SYNCING_SUPPORTED_BLOCKS = {
'core/paragraph': { content: __( 'Content' ) },
+ 'core/heading': { content: __( 'Content' ) },
+ 'core/button': {
+ text: __( 'Text' ),
+ url: __( 'URL' ),
+ linkTarget: __( 'Link Target' ),
+ },
+ 'core/image': {
+ url: __( 'URL' ),
+ title: __( 'Title' ),
+ alt: __( 'Alt Text' ),
+ },
};
diff --git a/packages/patterns/src/store/actions.js b/packages/patterns/src/store/actions.js
index dfa0326a709416..7b9090ae4de66c 100644
--- a/packages/patterns/src/store/actions.js
+++ b/packages/patterns/src/store/actions.js
@@ -2,7 +2,7 @@
* WordPress dependencies
*/
-import { parse } from '@wordpress/blocks';
+import { cloneBlock } from '@wordpress/blocks';
import { store as coreStore } from '@wordpress/core-data';
import { store as blockEditorStore } from '@wordpress/block-editor';
@@ -90,25 +90,37 @@ export const createPatternFromFile =
export const convertSyncedPatternToStatic =
( clientId ) =>
( { registry } ) => {
- const oldBlock = registry
+ const patternBlock = registry
.select( blockEditorStore )
.getBlock( clientId );
- const pattern = registry
- .select( 'core' )
- .getEditedEntityRecord(
- 'postType',
- 'wp_block',
- oldBlock.attributes.ref
- );
- const newBlocks = parse(
- typeof pattern.content === 'function'
- ? pattern.content( pattern )
- : pattern.content
- );
+ function cloneBlocksAndRemoveBindings( blocks ) {
+ return blocks.map( ( block ) => {
+ let metadata = block.attributes.metadata;
+ if ( metadata ) {
+ metadata = { ...metadata };
+ delete metadata.id;
+ delete metadata.bindings;
+ }
+ return cloneBlock(
+ block,
+ {
+ metadata:
+ metadata && Object.keys( metadata ).length > 0
+ ? metadata
+ : undefined,
+ },
+ cloneBlocksAndRemoveBindings( block.innerBlocks )
+ );
+ } );
+ }
+
registry
.dispatch( blockEditorStore )
- .replaceBlocks( oldBlock.clientId, newBlocks );
+ .replaceBlocks(
+ patternBlock.clientId,
+ cloneBlocksAndRemoveBindings( patternBlock.innerBlocks )
+ );
};
/**
diff --git a/packages/preferences-persistence/src/migrations/preferences-package-data/convert-editor-settings.js b/packages/preferences-persistence/src/migrations/preferences-package-data/convert-editor-settings.js
index 84542937563acd..01986803579b61 100644
--- a/packages/preferences-persistence/src/migrations/preferences-package-data/convert-editor-settings.js
+++ b/packages/preferences-persistence/src/migrations/preferences-package-data/convert-editor-settings.js
@@ -7,8 +7,10 @@ export default function convertEditorSettings( data ) {
const settingsToMoveToCore = [
'allowRightClickOverrides',
'distractionFree',
+ 'editorMode',
'fixedToolbar',
'focusMode',
+ 'hiddenBlockTypes',
'inactivePanels',
'keepCaretInsideBlock',
'mostUsedBlocks',
diff --git a/packages/preferences-persistence/src/migrations/preferences-package-data/test/index.js b/packages/preferences-persistence/src/migrations/preferences-package-data/test/index.js
index ffa39e630f5099..fefde528402ca9 100644
--- a/packages/preferences-persistence/src/migrations/preferences-package-data/test/index.js
+++ b/packages/preferences-persistence/src/migrations/preferences-package-data/test/index.js
@@ -43,7 +43,12 @@ describe( 'convertPreferencesPackageData', () => {
.toMatchInlineSnapshot( `
{
"core": {
+ "editorMode": "visual",
"fixedToolbar": true,
+ "hiddenBlockTypes": [
+ "core/audio",
+ "core/cover",
+ ],
"inactivePanels": [],
"openPanels": [
"post-status",
@@ -54,12 +59,7 @@ describe( 'convertPreferencesPackageData', () => {
"welcomeGuide": false,
},
"core/edit-post": {
- "editorMode": "visual",
"fullscreenMode": false,
- "hiddenBlockTypes": [
- "core/audio",
- "core/cover",
- ],
"pinnedItems": {
"my-sidebar-plugin/title-sidebar": false,
},
diff --git a/packages/preferences/package.json b/packages/preferences/package.json
index 6363485fdbbc71..7cab99bfab0329 100644
--- a/packages/preferences/package.json
+++ b/packages/preferences/package.json
@@ -31,10 +31,13 @@
"@babel/runtime": "^7.16.0",
"@wordpress/a11y": "file:../a11y",
"@wordpress/components": "file:../components",
+ "@wordpress/compose": "file:../compose",
"@wordpress/data": "file:../data",
+ "@wordpress/deprecated": "file:../deprecated",
"@wordpress/element": "file:../element",
"@wordpress/i18n": "file:../i18n",
"@wordpress/icons": "file:../icons",
+ "@wordpress/private-apis": "file:../private-apis",
"classnames": "^2.3.1"
},
"peerDependencies": {
diff --git a/packages/interface/src/components/preferences-modal-base-option/README.md b/packages/preferences/src/components/preference-base-option/README.md
similarity index 69%
rename from packages/interface/src/components/preferences-modal-base-option/README.md
rename to packages/preferences/src/components/preference-base-option/README.md
index 03c89960b6850c..8ff438ba322dc5 100644
--- a/packages/interface/src/components/preferences-modal-base-option/README.md
+++ b/packages/preferences/src/components/preference-base-option/README.md
@@ -1,17 +1,15 @@
-#__unstablePreferencesModalBaseOption
+# PreferenceBaseOption
-`__unstablePreferencesModalBaseOption` renders a toggle meant to be used with `PreferencesModal`.
+`PreferenceBaseOption` renders a toggle meant to be used within `PreferencesModal`.
This component implements a `ToggleControl` component from the `@wordpress/components` package.
-**It is an unstable component so is subject to breaking changes at any moment. Use at own risk.**
-
## Example
```jsx
function MyEditorPreferencesOption() {
return (
- <__unstablePreferencesModalBaseOption
+
) }
-
+
)
}
```
diff --git a/packages/interface/src/components/preferences-modal-base-option/index.js b/packages/preferences/src/components/preference-base-option/index.js
similarity index 86%
rename from packages/interface/src/components/preferences-modal-base-option/index.js
rename to packages/preferences/src/components/preference-base-option/index.js
index 92346630d98aa2..f2d1b4936c97f8 100644
--- a/packages/interface/src/components/preferences-modal-base-option/index.js
+++ b/packages/preferences/src/components/preference-base-option/index.js
@@ -5,7 +5,7 @@ import { ToggleControl } from '@wordpress/components';
function BaseOption( { help, label, isChecked, onChange, children } ) {
return (
-
+
{},
...remainingProps
@@ -21,11 +25,14 @@ export default function EnableFeature( props ) {
onToggle();
toggle( scope, featureName );
};
+
return (
-
);
}
+
+export default PreferenceToggleControl;
diff --git a/packages/interface/src/components/preferences-modal-section/README.md b/packages/preferences/src/components/preferences-modal-section/README.md
similarity index 94%
rename from packages/interface/src/components/preferences-modal-section/README.md
rename to packages/preferences/src/components/preferences-modal-section/README.md
index 6e78ca05c0aa81..4b032b5db04975 100644
--- a/packages/interface/src/components/preferences-modal-section/README.md
+++ b/packages/preferences/src/components/preferences-modal-section/README.md
@@ -1,3 +1,4 @@
+# PreferencesModalSection
`PreferencesModalSection` renders a section (as a fieldset) meant to be used with `PreferencesModal`.
diff --git a/packages/preferences/src/components/preferences-modal-section/index.js b/packages/preferences/src/components/preferences-modal-section/index.js
new file mode 100644
index 00000000000000..1a3f6f64ef4e75
--- /dev/null
+++ b/packages/preferences/src/components/preferences-modal-section/index.js
@@ -0,0 +1,15 @@
+const Section = ( { description, title, children } ) => (
+
+
+ { title }
+ { description && (
+
+ { description }
+
+ ) }
+
+ { children }
+
+);
+
+export default Section;
diff --git a/packages/interface/src/components/preferences-modal-section/style.scss b/packages/preferences/src/components/preferences-modal-section/style.scss
similarity index 50%
rename from packages/interface/src/components/preferences-modal-section/style.scss
rename to packages/preferences/src/components/preferences-modal-section/style.scss
index a1259af3430d56..2f6480dd71dab7 100644
--- a/packages/interface/src/components/preferences-modal-section/style.scss
+++ b/packages/preferences/src/components/preferences-modal-section/style.scss
@@ -1,4 +1,4 @@
-.interface-preferences-modal__section {
+.preferences-modal__section {
margin: 0 0 2.5rem 0;
&:last-child {
@@ -6,23 +6,23 @@
}
}
-.interface-preferences-modal__section-legend {
+.preferences-modal__section-legend {
margin-bottom: $grid-unit-10;
}
-.interface-preferences-modal__section-title {
+.preferences-modal__section-title {
font-size: 0.9rem;
font-weight: 600;
margin-top: 0;
}
-.interface-preferences-modal__section-description {
+.preferences-modal__section-description {
margin: -$grid-unit-10 0 $grid-unit-10 0;
font-size: $helptext-font-size;
font-style: normal;
color: $gray-700;
}
-.interface-preferences-modal__section:has(.interface-preferences-modal__section-content:empty) {
+.preferences-modal__section:has(.preferences-modal__section-content:empty) {
display: none;
}
diff --git a/packages/interface/src/components/preferences-modal-tabs/README.md b/packages/preferences/src/components/preferences-modal-tabs/README.md
similarity index 100%
rename from packages/interface/src/components/preferences-modal-tabs/README.md
rename to packages/preferences/src/components/preferences-modal-tabs/README.md
diff --git a/packages/interface/src/components/preferences-modal-tabs/index.js b/packages/preferences/src/components/preferences-modal-tabs/index.js
similarity index 75%
rename from packages/interface/src/components/preferences-modal-tabs/index.js
rename to packages/preferences/src/components/preferences-modal-tabs/index.js
index bc8f7358b834d4..4797b50985f03a 100644
--- a/packages/interface/src/components/preferences-modal-tabs/index.js
+++ b/packages/preferences/src/components/preferences-modal-tabs/index.js
@@ -13,15 +13,22 @@ import {
__experimentalText as Text,
__experimentalTruncate as Truncate,
FlexItem,
- TabPanel,
Card,
CardHeader,
CardBody,
+ privateApis as componentsPrivateApis,
} from '@wordpress/components';
-import { useMemo, useCallback, useState } from '@wordpress/element';
+import { useMemo, useState } from '@wordpress/element';
import { chevronLeft, chevronRight, Icon } from '@wordpress/icons';
import { isRTL, __ } from '@wordpress/i18n';
+/**
+ * Internal dependencies
+ */
+import { unlock } from '../../lock-unlock';
+
+const { Tabs } = unlock( componentsPrivateApis );
+
const PREFERENCES_MENU = 'preferences-menu';
export default function PreferencesModalTabs( { sections } ) {
@@ -32,7 +39,7 @@ export default function PreferencesModalTabs( { sections } ) {
const [ activeMenu, setActiveMenu ] = useState( PREFERENCES_MENU );
/**
* Create helper objects from `sections` for easier data handling.
- * `tabs` is used for creating the `TabPanel` and `sectionsContentMap`
+ * `tabs` is used for creating the `Tabs` and `sectionsContentMap`
* is used for easier access to active tab's content.
*/
const { tabs, sectionsContentMap } = useMemo( () => {
@@ -53,32 +60,47 @@ export default function PreferencesModalTabs( { sections } ) {
return mappedTabs;
}, [ sections ] );
- const getCurrentTab = useCallback(
- ( tab ) => sectionsContentMap[ tab.name ] || null,
- [ sectionsContentMap ]
- );
-
let modalContent;
// We render different components based on the viewport size.
if ( isLargeViewport ) {
modalContent = (
-
- { getCurrentTab }
-
+
+
+
+ { tabs.map( ( tab ) => (
+
+ { tab.title }
+
+ ) ) }
+
+ { tabs.map( ( tab ) => (
+
+ { sectionsContentMap[ tab.name ] || null }
+
+ ) ) }
+
+
);
} else {
modalContent = (
diff --git a/packages/preferences/src/components/preferences-modal-tabs/style.scss b/packages/preferences/src/components/preferences-modal-tabs/style.scss
new file mode 100644
index 00000000000000..d3afd4174cd0cb
--- /dev/null
+++ b/packages/preferences/src/components/preferences-modal-tabs/style.scss
@@ -0,0 +1,48 @@
+$vertical-tabs-width: 160px;
+
+.preferences__tabs-tablist {
+ position: absolute;
+ top: $header-height + $grid-unit-30;
+ // Aligns button text instead of button box.
+ left: $grid-unit-20;
+ width: $vertical-tabs-width;
+
+}
+
+.preferences__tabs-tab {
+ border-radius: $radius-block-ui;
+ font-weight: 400;
+
+ &[aria-selected="true"] {
+ background: $gray-100;
+ box-shadow: none;
+ font-weight: 500;
+ }
+
+ &[aria-selected="true"]::after {
+ content: none;
+ }
+
+ &[role="tab"]:focus:not(:disabled) {
+ box-shadow: inset 0 0 0 var(--wp-admin-border-width-focus) var(--wp-admin-theme-color);
+ // Windows high contrast mode.
+ outline: 2px solid transparent;
+ }
+
+ &:focus-visible::before {
+ content: none;
+ }
+}
+
+.preferences__tabs-tabpanel {
+ padding-left: $grid-unit-30;
+ margin-left: $vertical-tabs-width;
+}
+
+@media (max-width: #{ ($break-medium - 1) }) {
+ // Keep the navigator component from overflowing the modal content area
+ // to ensure that sticky position elements stick where intended.
+ .preferences__provider {
+ height: 100%;
+ }
+}
diff --git a/packages/interface/src/components/preferences-modal/README.md b/packages/preferences/src/components/preferences-modal/README.md
similarity index 96%
rename from packages/interface/src/components/preferences-modal/README.md
rename to packages/preferences/src/components/preferences-modal/README.md
index 4327a59a7905ae..4814fc12e5898a 100644
--- a/packages/interface/src/components/preferences-modal/README.md
+++ b/packages/preferences/src/components/preferences-modal/README.md
@@ -4,7 +4,7 @@
This component implements a `Modal` component from the `@wordpress/components` package.
-Sections passed to this component should use `PreferencesModalSection` component from the `@wordpress/interface` package.
+Sections passed to this component should use `PreferencesModalSection` component from the `@wordpress/preferences` package.
## Example
diff --git a/packages/interface/src/components/preferences-modal/index.js b/packages/preferences/src/components/preferences-modal/index.js
similarity index 87%
rename from packages/interface/src/components/preferences-modal/index.js
rename to packages/preferences/src/components/preferences-modal/index.js
index 99bb9d3c25dcf4..8dc4f1020c036f 100644
--- a/packages/interface/src/components/preferences-modal/index.js
+++ b/packages/preferences/src/components/preferences-modal/index.js
@@ -7,7 +7,7 @@ import { __ } from '@wordpress/i18n';
export default function PreferencesModal( { closeModal, children } ) {
return (
diff --git a/packages/interface/src/components/preferences-modal/style.scss b/packages/preferences/src/components/preferences-modal/style.scss
similarity index 94%
rename from packages/interface/src/components/preferences-modal/style.scss
rename to packages/preferences/src/components/preferences-modal/style.scss
index 4e70f9f817e644..ca185ac4258e0e 100644
--- a/packages/interface/src/components/preferences-modal/style.scss
+++ b/packages/preferences/src/components/preferences-modal/style.scss
@@ -1,4 +1,4 @@
-.interface-preferences-modal {
+.preferences-modal {
// To keep modal dimensions consistent as subsections are navigated, width
// and height are used instead of max-(width/height).
@include break-small() {
diff --git a/packages/preferences/src/index.js b/packages/preferences/src/index.js
index 72531a0824c178..856b93b21105d8 100644
--- a/packages/preferences/src/index.js
+++ b/packages/preferences/src/index.js
@@ -1,2 +1,3 @@
export * from './components';
export { store } from './store';
+export * from './private-apis';
diff --git a/packages/preferences/src/lock-unlock.js b/packages/preferences/src/lock-unlock.js
new file mode 100644
index 00000000000000..981f244881ed03
--- /dev/null
+++ b/packages/preferences/src/lock-unlock.js
@@ -0,0 +1,9 @@
+/**
+ * WordPress dependencies
+ */
+import { __dangerousOptInToUnstableAPIsOnlyForCoreModules } from '@wordpress/private-apis';
+export const { lock, unlock } =
+ __dangerousOptInToUnstableAPIsOnlyForCoreModules(
+ 'I know using unstable features means my theme or plugin will inevitably break in the next version of WordPress.',
+ '@wordpress/preferences'
+ );
diff --git a/packages/preferences/src/private-apis.js b/packages/preferences/src/private-apis.js
new file mode 100644
index 00000000000000..110d134f86b822
--- /dev/null
+++ b/packages/preferences/src/private-apis.js
@@ -0,0 +1,18 @@
+/**
+ * Internal dependencies
+ */
+import PreferenceBaseOption from './components/preference-base-option';
+import PreferenceToggleControl from './components/preference-toggle-control';
+import PreferencesModal from './components/preferences-modal';
+import PreferencesModalSection from './components/preferences-modal-section';
+import PreferencesModalTabs from './components/preferences-modal-tabs';
+import { lock } from './lock-unlock';
+
+export const privateApis = {};
+lock( privateApis, {
+ PreferenceBaseOption,
+ PreferenceToggleControl,
+ PreferencesModal,
+ PreferencesModalSection,
+ PreferencesModalTabs,
+} );
diff --git a/packages/preferences/src/store/index.js b/packages/preferences/src/store/index.js
index 0c2421966a0d79..7e57dac5e712ca 100644
--- a/packages/preferences/src/store/index.js
+++ b/packages/preferences/src/store/index.js
@@ -12,7 +12,7 @@ import * as selectors from './selectors';
import { STORE_NAME } from './constants';
/**
- * Store definition for the interface namespace.
+ * Store definition for the preferences namespace.
*
* @see https://github.com/WordPress/gutenberg/blob/HEAD/packages/data/README.md#createReduxStore
*
diff --git a/packages/preferences/src/store/selectors.js b/packages/preferences/src/store/selectors.js
index 58b4d18bc362aa..e249acf7bbdd33 100644
--- a/packages/preferences/src/store/selectors.js
+++ b/packages/preferences/src/store/selectors.js
@@ -1,3 +1,43 @@
+/**
+ * WordPress dependencies
+ */
+import deprecated from '@wordpress/deprecated';
+
+const withDeprecatedKeys = ( originalGet ) => ( state, scope, name ) => {
+ const settingsToMoveToCore = [
+ 'allowRightClickOverrides',
+ 'distractionFree',
+ 'editorMode',
+ 'fixedToolbar',
+ 'focusMode',
+ 'hiddenBlockTypes',
+ 'inactivePanels',
+ 'keepCaretInsideBlock',
+ 'mostUsedBlocks',
+ 'openPanels',
+ 'showBlockBreadcrumbs',
+ 'showIconLabels',
+ 'showListViewByDefault',
+ ];
+
+ if (
+ settingsToMoveToCore.includes( name ) &&
+ [ 'core/edit-post', 'core/edit-site' ].includes( scope )
+ ) {
+ deprecated(
+ `wp.data.select( 'core/preferences' ).get( '${ scope }', '${ name }' )`,
+ {
+ since: '6.5',
+ alternative: `wp.data.select( 'core/preferences' ).get( 'core', '${ name }' )`,
+ }
+ );
+
+ return originalGet( state, 'core', name );
+ }
+
+ return originalGet( state, scope, name );
+};
+
/**
* Returns a boolean indicating whether a prefer is active for a particular
* scope.
@@ -8,7 +48,7 @@
*
* @return {*} Is the feature enabled?
*/
-export function get( state, scope, name ) {
+export const get = withDeprecatedKeys( ( state, scope, name ) => {
const value = state.preferences[ scope ]?.[ name ];
return value !== undefined ? value : state.defaults[ scope ]?.[ name ];
-}
+} );
diff --git a/packages/preferences/src/style.scss b/packages/preferences/src/style.scss
new file mode 100644
index 00000000000000..c6769c44b00ba9
--- /dev/null
+++ b/packages/preferences/src/style.scss
@@ -0,0 +1,4 @@
+@import "./components/preference-base-option/style.scss";
+@import "./components/preferences-modal/style.scss";
+@import "./components/preferences-modal-tabs/style.scss";
+@import "./components/preferences-modal-section/style.scss";
diff --git a/packages/private-apis/src/implementation.js b/packages/private-apis/src/implementation.js
index 619478cf76386d..a31fd91ce094dd 100644
--- a/packages/private-apis/src/implementation.js
+++ b/packages/private-apis/src/implementation.js
@@ -25,7 +25,9 @@ const CORE_MODULES_USING_PRIVATE_APIS = [
'@wordpress/edit-widgets',
'@wordpress/editor',
'@wordpress/format-library',
+ '@wordpress/interface',
'@wordpress/patterns',
+ '@wordpress/preferences',
'@wordpress/reusable-blocks',
'@wordpress/router',
'@wordpress/dataviews',
diff --git a/packages/react-native-aztec/android/build.gradle b/packages/react-native-aztec/android/build.gradle
index 18093ff1c2c136..6c1f4e5b02cce3 100644
--- a/packages/react-native-aztec/android/build.gradle
+++ b/packages/react-native-aztec/android/build.gradle
@@ -36,7 +36,7 @@ plugins {
// import the `readReactNativeVersion()` function
apply from: 'https://gist.githubusercontent.com/hypest/742448b9588b3a0aa580a5e80ae95bdf/raw/8eb62d40ee7a5104d2fcaeff21ce6f29bd93b054/readReactNativeVersion.gradle'
-group = 'org.wordpress-mobile.gutenberg-mobile'
+group = 'org.wordpress.gutenberg-mobile'
// The sample build uses multiple directories to
// keep boilerplate and common code separate from
@@ -119,7 +119,7 @@ project.afterEvaluate {
ReactNativeAztecPublication(MavenPublication) {
artifact bundleReleaseAar
- groupId 'org.wordpress-mobile.gutenberg-mobile'
+ groupId 'org.wordpress.gutenberg-mobile'
artifactId 'react-native-aztec'
// version is set by 'publish-to-s3' plugin
diff --git a/packages/react-native-aztec/package.json b/packages/react-native-aztec/package.json
index f91b214758b49a..bcc9613fddfe7a 100644
--- a/packages/react-native-aztec/package.json
+++ b/packages/react-native-aztec/package.json
@@ -1,6 +1,6 @@
{
"name": "@wordpress/react-native-aztec",
- "version": "1.110.0",
+ "version": "1.111.1",
"description": "Aztec view for react-native.",
"private": true,
"author": "The WordPress Contributors",
diff --git a/packages/react-native-bridge/android/react-native-bridge/build.gradle b/packages/react-native-bridge/android/react-native-bridge/build.gradle
index 7800be076c842a..c54e73b9a4b6a1 100644
--- a/packages/react-native-bridge/android/react-native-bridge/build.gradle
+++ b/packages/react-native-bridge/android/react-native-bridge/build.gradle
@@ -25,7 +25,7 @@ plugins {
apply from: 'https://gist.githubusercontent.com/hypest/742448b9588b3a0aa580a5e80ae95bdf/raw/8eb62d40ee7a5104d2fcaeff21ce6f29bd93b054/readReactNativeVersion.gradle'
apply from: '../extractPackageVersion.gradle'
-group='org.wordpress-mobile.gutenberg-mobile'
+group='org.wordpress.gutenberg-mobile'
def buildAssetsFolder = 'build/assets'
@@ -93,8 +93,8 @@ dependencies {
// Published by `wordpress-mobile/react-native-libraries-publisher`
// See the documentation for this value in `build.gradle.kts` of `wordpress-mobile/react-native-libraries-publisher`
- def reactNativeLibrariesPublisherVersion = "v3"
- def reactNativeLibrariesGroupId = "org.wordpress-mobile.react-native-libraries.$reactNativeLibrariesPublisherVersion"
+ def reactNativeLibrariesPublisherVersion = "v4"
+ def reactNativeLibrariesGroupId = "org.wordpress.react-native-libraries.$reactNativeLibrariesPublisherVersion"
implementation "$reactNativeLibrariesGroupId:react-native-get-random-values:${extractPackageVersion(packageJson, 'react-native-get-random-values', 'dependencies')}"
implementation "$reactNativeLibrariesGroupId:react-native-safe-area-context:${extractPackageVersion(packageJson, 'react-native-safe-area-context', 'dependencies')}"
implementation "$reactNativeLibrariesGroupId:react-native-screens:${extractPackageVersion(packageJson, 'react-native-screens', 'dependencies')}"
@@ -110,7 +110,7 @@ dependencies {
runtimeOnly "com.facebook.react:hermes-android:$rnVersion"
if (willPublishReactNativeBridgeBinary) {
- implementation "org.wordpress-mobile.gutenberg-mobile:react-native-aztec:$reactNativeAztecVersion"
+ implementation "org.wordpress.gutenberg-mobile:react-native-aztec:$reactNativeAztecVersion"
} else {
api project(':@wordpress_react-native-aztec')
}
@@ -122,7 +122,7 @@ project.afterEvaluate {
ReactNativeBridgePublication(MavenPublication) {
artifact bundleReleaseAar
- groupId 'org.wordpress-mobile.gutenberg-mobile'
+ groupId 'org.wordpress.gutenberg-mobile'
artifactId 'react-native-gutenberg-bridge'
// version is set by 'publish-to-s3' plugin
diff --git a/packages/react-native-bridge/package.json b/packages/react-native-bridge/package.json
index 276e536dbf929f..5008c428ad2b21 100644
--- a/packages/react-native-bridge/package.json
+++ b/packages/react-native-bridge/package.json
@@ -1,6 +1,6 @@
{
"name": "@wordpress/react-native-bridge",
- "version": "1.110.0",
+ "version": "1.111.1",
"description": "Native bridge library used to integrate the block editor into a native App.",
"private": true,
"author": "The WordPress Contributors",
diff --git a/packages/react-native-editor/CHANGELOG.md b/packages/react-native-editor/CHANGELOG.md
index 381ce0e93e4454..88d7c1d089aff1 100644
--- a/packages/react-native-editor/CHANGELOG.md
+++ b/packages/react-native-editor/CHANGELOG.md
@@ -10,7 +10,18 @@ For each user feature we should also add a importance categorization label to i
-->
## Unreleased
+
+## 1.111.1
+- [**] Video block: Fix logic for displaying empty state based on source presence [#58015]
+- [**] Fix crash when RichText values are not defined [#58088]
+
+## 1.111.0
- [**] Image block media uploads display a custom error message when there is no internet connection [#56937]
+- [*] Fix missing custom color indicator for custom gradients [#57605]
+- [**] Display a notice when a network connection unavailable [#56934]
+
+## 1.110.1
+- [**] Fix crash when RichText values are not defined [#58088]
## 1.110.0
- [*] [internal] Move InserterButton from components package to block-editor package [#56494]
diff --git a/packages/react-native-editor/__device-tests__/README.md b/packages/react-native-editor/__device-tests__/README.md
index e917a297a491c7..719adbbcf94265 100644
--- a/packages/react-native-editor/__device-tests__/README.md
+++ b/packages/react-native-editor/__device-tests__/README.md
@@ -6,6 +6,7 @@ The Mobile Gutenberg (MG) project maintains a suite of automated end-to-end (E2E
1. Complete the [React Native Getting Started](https://reactnative.dev/docs/environment-setup) guide for both iOS and Android, which covers setting up Xcode, Android Studio, the Android SDK.
1. Open [Xcode settings](https://developer.apple.com/documentation/xcode/installing-additional-simulator-runtimes#Install-and-manage-Simulator-runtimes-in-settings) to install the iOS 16.2 simulator runtime.
+1. Install [`jq`](https://jqlang.github.io/jq/download/) via [Homebrew](https://brew.sh/) or your preferred package manager.
1. `npm run native test:e2e:setup`
## Running Tests
diff --git a/packages/react-native-editor/__device-tests__/helpers/utils.js b/packages/react-native-editor/__device-tests__/helpers/utils.js
index 8af4a0b1a60d71..5af85b9e6847d0 100644
--- a/packages/react-native-editor/__device-tests__/helpers/utils.js
+++ b/packages/react-native-editor/__device-tests__/helpers/utils.js
@@ -306,19 +306,11 @@ const clickBeginningOfElement = async ( driver, element ) => {
.perform();
};
-// Long press to activate context menu.
-const longPressMiddleOfElement = async (
+async function longPressElement(
driver,
element,
- waitTime = 1000,
- customElementSize
-) => {
- const location = await element.getLocation();
- const size = customElementSize || ( await element.getSize() );
-
- const x = location.x + size.width / 2;
- const y = location.y + size.height / 2;
-
+ { waitTime = 1000, offset = { x: 0, y: 0 } } = {}
+) {
// Focus on the element first, otherwise on iOS it fails to open the context menu.
// We can't do it all in one action because it detects it as a force press and it
// is not supported by the simulator.
@@ -331,16 +323,43 @@ const longPressMiddleOfElement = async (
.up()
.perform();
+ const location = await element.getLocation();
+ const size = await element.getSize();
+
+ let offsetX = offset.x;
+ if ( typeof offset.x === 'function' ) {
+ offsetX = offset.x( size.width );
+ }
+ let offsetY = offset.y;
+ if ( typeof offset.y === 'function' ) {
+ offsetY = offset.y( size.height );
+ }
+
// Long-press
await driver
.action( 'pointer', {
parameters: { pointerType: 'touch' },
} )
- .move( { x, y } )
+ .move( { x: location.x + offsetX, y: location.y + offsetY } )
.down()
.pause( waitTime )
.up()
.perform();
+}
+
+// Long press to activate context menu.
+const longPressMiddleOfElement = async (
+ driver,
+ element,
+ { waitTime = 1000 } = {}
+) => {
+ await longPressElement( driver, element, {
+ waitTime,
+ offset: {
+ x: ( width ) => width / 2,
+ y: ( height ) => height / 2,
+ },
+ } );
};
const tapStatusBariOS = async ( driver ) => {
@@ -717,6 +736,7 @@ module.exports = {
isElementVisible,
isLocalEnvironment,
launchApp,
+ longPressElement,
longPressMiddleOfElement,
selectTextFromElement,
setupDriver,
diff --git a/packages/react-native-editor/__device-tests__/pages/editor-page.js b/packages/react-native-editor/__device-tests__/pages/editor-page.js
index b00be20458e802..fe3f410601a8dc 100644
--- a/packages/react-native-editor/__device-tests__/pages/editor-page.js
+++ b/packages/react-native-editor/__device-tests__/pages/editor-page.js
@@ -15,6 +15,8 @@ const {
clickIfClickable,
launchApp,
tapStatusBariOS,
+ longPressElement,
+ longPressMiddleOfElement,
} = require( '../helpers/utils' );
const ADD_BLOCK_ID = isAndroid() ? 'Add block' : 'add-block-button';
@@ -105,6 +107,47 @@ class EditorPage {
await typeString( this.driver, block, text, clear );
}
+ async pasteClipboardToTextBlock( element, { timeout = 1000 } = {} ) {
+ if ( this.driver.isAndroid ) {
+ await longPressMiddleOfElement( this.driver, element );
+ } else {
+ await longPressElement( this.driver, element );
+ }
+
+ if ( this.driver.isAndroid ) {
+ // Long pressing seemingly results in drag-and-drop blurring the input, so
+ // we tap again to re-focus the input.
+ await this.driver
+ .action( 'pointer', {
+ parameters: { pointerType: 'touch' },
+ } )
+ .move( { origin: element } )
+ .down()
+ .up()
+ .perform();
+
+ const location = await element.getLocation();
+ const approximatePasteMenuLocation = {
+ x: location.x + 30,
+ y: location.y - 120,
+ };
+ await this.driver
+ .action( 'pointer', {
+ parameters: { pointerType: 'touch' },
+ } )
+ .move( approximatePasteMenuLocation )
+ .down()
+ .up()
+ .perform();
+ } else {
+ const pasteMenuItem = await this.driver.$(
+ '//XCUIElementTypeMenuItem[@name="Paste"]'
+ );
+ await pasteMenuItem.waitForDisplayed( { timeout } );
+ await pasteMenuItem.click();
+ }
+ }
+
// Finds the wd element for new block that was added and sets the element attribute
// and accessibilityId attributes on this object and selects the block
// position uses one based numbering.
diff --git a/packages/react-native-editor/android/app/build.gradle b/packages/react-native-editor/android/app/build.gradle
index ecb63589bcf1c4..514c645354232d 100644
--- a/packages/react-native-editor/android/app/build.gradle
+++ b/packages/react-native-editor/android/app/build.gradle
@@ -132,7 +132,7 @@ android {
dependencies {
def packageJson = '../../package.json'
- implementation "org.wordpress-mobile.gutenberg-mobile:react-native-bridge"
+ implementation "org.wordpress.gutenberg-mobile:react-native-bridge"
implementation 'androidx.appcompat:appcompat:1.2.0'
implementation "com.google.android.material:material:1.9.0"
// The version of react-native is set by the React Native Gradle Plugin
@@ -145,8 +145,8 @@ dependencies {
// Published by `wordpress-mobile/react-native-libraries-publisher`
// See the documentation for this value in `build.gradle.kts` of `wordpress-mobile/react-native-libraries-publisher`
- def reactNativeLibrariesPublisherVersion = "v3"
- def reactNativeLibrariesGroupId = "org.wordpress-mobile.react-native-libraries.$reactNativeLibrariesPublisherVersion"
+ def reactNativeLibrariesPublisherVersion = "v4"
+ def reactNativeLibrariesGroupId = "org.wordpress.react-native-libraries.$reactNativeLibrariesPublisherVersion"
implementation "$reactNativeLibrariesGroupId:react-native-get-random-values:${extractPackageVersion(packageJson, 'react-native-get-random-values', 'dependencies')}"
implementation "$reactNativeLibrariesGroupId:react-native-safe-area-context:${extractPackageVersion(packageJson, 'react-native-safe-area-context', 'dependencies')}"
implementation "$reactNativeLibrariesGroupId:react-native-screens:${extractPackageVersion(packageJson, 'react-native-screens', 'dependencies')}"
diff --git a/packages/react-native-editor/android/build.gradle b/packages/react-native-editor/android/build.gradle
index 7baee554d330c7..f08019a79c9007 100644
--- a/packages/react-native-editor/android/build.gradle
+++ b/packages/react-native-editor/android/build.gradle
@@ -7,7 +7,7 @@ buildscript {
compileSdkVersion = 34
targetSdkVersion = 33
supportLibVersion = '28.0.0'
-
+
// We use NDK 23 which has both M1 support and is the side-by-side NDK version from AGP.
ndkVersion = "23.1.7779620"
}
@@ -30,8 +30,7 @@ allprojects {
content {
includeGroup "org.wordpress"
includeGroup "org.wordpress.aztec"
- includeGroup "org.wordpress-mobile"
- includeGroupByRegex "org.wordpress-mobile.react-native-libraries.*"
+ includeGroupByRegex "org.wordpress.react-native-libraries.*"
}
}
maven { url 'https://www.jitpack.io' }
diff --git a/packages/react-native-editor/ios/Podfile.lock b/packages/react-native-editor/ios/Podfile.lock
index cf40d621834bda..3852f073303b3b 100644
--- a/packages/react-native-editor/ios/Podfile.lock
+++ b/packages/react-native-editor/ios/Podfile.lock
@@ -13,7 +13,7 @@ PODS:
- ReactCommon/turbomodule/core (= 0.71.11)
- fmt (6.2.1)
- glog (0.3.5)
- - Gutenberg (1.110.0):
+ - Gutenberg (1.111.1):
- React-Core (= 0.71.11)
- React-CoreModules (= 0.71.11)
- React-RCTImage (= 0.71.11)
@@ -429,7 +429,7 @@ PODS:
- React-RCTImage
- RNSVG (13.9.0):
- React-Core
- - RNTAztecView (1.110.0):
+ - RNTAztecView (1.111.1):
- React-Core
- WordPress-Aztec-iOS (= 1.19.9)
- SDWebImage (5.11.1):
@@ -610,14 +610,14 @@ EXTERNAL SOURCES:
:path: "../../../node_modules/react-native/ReactCommon/yoga"
SPEC CHECKSUMS:
- boost: 57d2868c099736d80fcd648bf211b4431e51a558
+ boost: 7dcd2de282d72e344012f7d6564d024930a6a440
BVLinearGradient: fbe308a1d19a8133f69e033abc85d8008644f5e3
DoubleConversion: 5189b271737e1565bdce30deb4a08d647e3f5f54
FBLazyVector: c511d4cd0210f416cb5c289bd5ae6b36d909b048
FBReactNativeSpec: f07662560742d82a5b73cee116c70b0b49bcc220
fmt: ff9d55029c625d3757ed641535fd4a75fedc7ce9
glog: 04b94705f318337d7ead9e6d17c019bd9b1f6b1b
- Gutenberg: 758124df95be2159a16909fcf00e289b9299fa39
+ Gutenberg: c251b1260cafc0efee13c29c7b4a22e399e17be2
hermes-engine: 34c863b446d0135b85a6536fa5fd89f48196f848
libevent: 4049cae6c81cdb3654a443be001fb9bdceff7913
libwebp: 60305b2e989864154bd9be3d772730f08fc6a59c
@@ -662,7 +662,7 @@ SPEC CHECKSUMS:
RNReanimated: d4f363f4987ae0ade3e36ff81c94e68261bf4b8d
RNScreens: 68fd1060f57dd1023880bf4c05d74784b5392789
RNSVG: 53c661b76829783cdaf9b7a57258f3d3b4c28315
- RNTAztecView: 75ea6f071cbdd0f0afe83de7b93c0691a2bebd21
+ RNTAztecView: 7f2d1f97d07c00efd7f24a687fd2d0c5aec44669
SDWebImage: a7f831e1a65eb5e285e3fb046a23fcfbf08e696d
SDWebImageWebPCoder: 908b83b6adda48effe7667cd2b7f78c897e5111d
WordPress-Aztec-iOS: fbebd569c61baa252b3f5058c0a2a9a6ada686bb
diff --git a/packages/react-native-editor/package.json b/packages/react-native-editor/package.json
index 0a8ceed231ae4f..f2a57980c86ec8 100644
--- a/packages/react-native-editor/package.json
+++ b/packages/react-native-editor/package.json
@@ -1,6 +1,6 @@
{
"name": "@wordpress/react-native-editor",
- "version": "1.110.0",
+ "version": "1.111.1",
"description": "Mobile WordPress gutenberg editor.",
"author": "The WordPress Contributors",
"license": "GPL-2.0-or-later",
diff --git a/packages/readable-js-assets-webpack-plugin/test/__snapshots__/build.js.snap b/packages/readable-js-assets-webpack-plugin/test/__snapshots__/build.js.snap
index e5a7d6f6accc84..c033e572e8e5e4 100644
--- a/packages/readable-js-assets-webpack-plugin/test/__snapshots__/build.js.snap
+++ b/packages/readable-js-assets-webpack-plugin/test/__snapshots__/build.js.snap
@@ -1,7 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ReadableJsAssetsWebpackPlugin should produce the expected output: Asset file index.js should match snapshot 1`] = `
-"/******/ (function() { // webpackBootstrap
+"/******/ (() => { // webpackBootstrap
var __webpack_exports__ = {};
function notMinified() {
// eslint-disable-next-line no-console
@@ -16,7 +16,7 @@ notMinified();
exports[`ReadableJsAssetsWebpackPlugin should produce the expected output: Asset file index.min.js should match snapshot 1`] = `"console.log("hello");"`;
exports[`ReadableJsAssetsWebpackPlugin should produce the expected output: Asset file view.js should match snapshot 1`] = `
-"/******/ (function() { // webpackBootstrap
+"/******/ (() => { // webpackBootstrap
var __webpack_exports__ = {};
function notMinified() {
// eslint-disable-next-line no-console
diff --git a/phpcs.xml.dist b/phpcs.xml.dist
index 21f3fcb8baee16..ecc800f9892416 100644
--- a/phpcs.xml.dist
+++ b/phpcs.xml.dist
@@ -58,9 +58,15 @@
./vendor/*
./test/php/gutenberg-coding-standards/*
+
+ ./lib/compat/wordpress-*/html-api/*.php
+
/phpunit/*
+ packages/block-library/src/navigation/index.php
@@ -115,6 +121,8 @@
+
+
diff --git a/phpunit/block-supports/typography-test.php b/phpunit/block-supports/typography-test.php
index f6d5344222678c..ec7df51317a3f4 100644
--- a/phpunit/block-supports/typography-test.php
+++ b/phpunit/block-supports/typography-test.php
@@ -933,6 +933,16 @@ public function data_get_computed_fluid_typography_value() {
),
'expected_output' => 'clamp(50px, 3.125rem + ((1vw - 3.2px) * 7.353), 100px)',
),
+ 'returns `null` when maximum and minimum viewport width are equal' => array(
+ 'args' => array(
+ 'minimum_viewport_width' => '800px',
+ 'maximum_viewport_width' => '800px',
+ 'minimum_font_size' => '50px',
+ 'maximum_font_size' => '100px',
+ 'scale_factor' => 1,
+ ),
+ 'expected_output' => null,
+ ),
'returns `null` when `maximum_viewport_width` is an unsupported unit' => array(
'args' => array(
'minimum_viewport_width' => '320px',
diff --git a/phpunit/blocks/block-navigation-link-variations-test.php b/phpunit/blocks/block-navigation-link-variations-test.php
index c5082e0f4c878f..8eef5c393148b0 100644
--- a/phpunit/blocks/block-navigation-link-variations-test.php
+++ b/phpunit/blocks/block-navigation-link-variations-test.php
@@ -1,6 +1,6 @@
true,
)
);
+ register_post_type(
+ 'private_custom_book',
+ array(
+ 'labels' => array(
+ 'item_link' => 'Custom Book',
+ ),
+ 'public' => false,
+ 'show_in_rest' => true,
+ 'show_in_nav_menus' => false,
+ )
+ );
register_taxonomy(
'book_type',
'custom_book',
@@ -35,40 +46,131 @@ public function set_up() {
'show_in_nav_menus' => true,
)
);
+ register_taxonomy(
+ 'private_book_type',
+ 'private_custom_book',
+ array(
+ 'labels' => array(
+ 'item_link' => 'Book Type',
+ ),
+ 'show_in_nav_menus' => false,
+ )
+ );
}
public function tear_down() {
unregister_post_type( 'custom_book' );
+ unregister_post_type( 'private_custom_book' );
unregister_taxonomy( 'book_type' );
+ unregister_taxonomy( 'private_book_type' );
+ unregister_post_type( 'temp_custom_book' );
+ unregister_taxonomy( 'temp_book_type' );
parent::tear_down();
}
/**
- * @covers ::register_block_core_navigation_link_post_type_variation
+ * @covers ::block_core_navigation_link_register_post_type_variation
*/
public function test_navigation_link_variations_custom_post_type() {
$registry = WP_Block_Type_Registry::get_instance();
$nav_link_block = $registry->get_registered( 'core/navigation-link' );
$this->assertNotEmpty( $nav_link_block->variations, 'Block has no variations' );
$variation = $this->get_variation_by_name( 'custom_book', $nav_link_block->variations );
- $this->assertIsArray( $variation, 'Block variation is not an array' );
+ $this->assertIsArray( $variation, 'Block variation does not exist' );
$this->assertArrayHasKey( 'title', $variation, 'Block variation has no title' );
$this->assertEquals( 'Custom Book', $variation['title'], 'Variation title is different than the post type label' );
}
/**
- * @covers ::register_block_core_navigation_link_taxonomy_variation
+ * @covers ::block_core_navigation_link_register_post_type_variation
+ */
+ public function test_navigation_link_variations_private_custom_post_type() {
+ $registry = WP_Block_Type_Registry::get_instance();
+ $nav_link_block = $registry->get_registered( 'core/navigation-link' );
+ $this->assertNotEmpty( $nav_link_block->variations, 'Block has no variations' );
+ $variation = $this->get_variation_by_name( 'private_custom_book', $nav_link_block->variations );
+ $this->assertEmpty( $variation, 'Block variation for private post type exists.' );
+ }
+
+ /**
+ * @covers ::block_core_navigation_link_register_taxonomy_variation
*/
public function test_navigation_link_variations_custom_taxonomy() {
$registry = WP_Block_Type_Registry::get_instance();
$nav_link_block = $registry->get_registered( 'core/navigation-link' );
$this->assertNotEmpty( $nav_link_block->variations, 'Block has no variations' );
$variation = $this->get_variation_by_name( 'book_type', $nav_link_block->variations );
- $this->assertIsArray( $variation, 'Block variation is not an array' );
+ $this->assertIsArray( $variation, 'Block variation does not exist' );
$this->assertArrayHasKey( 'title', $variation, 'Block variation has no title' );
$this->assertEquals( 'Book Type', $variation['title'], 'Variation title is different than the post type label' );
}
+ /**
+ * @covers ::block_core_navigation_link_register_taxonomy_variation
+ */
+ public function test_navigation_link_variations_private_custom_taxonomy() {
+ $registry = WP_Block_Type_Registry::get_instance();
+ $nav_link_block = $registry->get_registered( 'core/navigation-link' );
+ $this->assertNotEmpty( $nav_link_block->variations, 'Block has no variations' );
+ $variation = $this->get_variation_by_name( 'private_book_type', $nav_link_block->variations );
+ $this->assertEmpty( $variation, 'Block variation for private taxonomy exists.' );
+ }
+
+ /**
+ * @covers ::block_core_navigation_link_unregister_post_type_variation
+ */
+ public function test_navigation_link_variations_unregister_post_type() {
+ register_post_type(
+ 'temp_custom_book',
+ array(
+ 'labels' => array(
+ 'item_link' => 'Custom Book',
+ ),
+ 'public' => true,
+ 'show_in_rest' => true,
+ 'show_in_nav_menus' => true,
+ )
+ );
+
+ $registry = WP_Block_Type_Registry::get_instance();
+ $nav_link_block = $registry->get_registered( 'core/navigation-link' );
+ $this->assertNotEmpty( $nav_link_block->variations, 'Block has no variations' );
+ $variation = $this->get_variation_by_name( 'temp_custom_book', $nav_link_block->variations );
+ $this->assertIsArray( $variation, 'Block variation does not exist' );
+
+ unregister_post_type( 'temp_custom_book' );
+
+ $variation = $this->get_variation_by_name( 'temp_custom_book', $nav_link_block->variations );
+ $this->assertEmpty( $variation, 'Block variation still exists' );
+ }
+
+ /**
+ * @covers ::block_core_navigation_link_unregister_taxonomy_variation
+ */
+ public function test_navigation_link_variations_unregister_taxonomy() {
+ register_taxonomy(
+ 'temp_book_type',
+ 'custom_book',
+ array(
+ 'labels' => array(
+ 'item_link' => 'Book Type',
+ ),
+ 'show_in_nav_menus' => true,
+ )
+ );
+
+ $registry = WP_Block_Type_Registry::get_instance();
+ $nav_link_block = $registry->get_registered( 'core/navigation-link' );
+ $this->assertNotEmpty( $nav_link_block->variations, 'Block has no variations' );
+ $variation = $this->get_variation_by_name( 'temp_book_type', $nav_link_block->variations );
+ $this->assertIsArray( $variation, 'Block variation does not exist' );
+
+ unregister_taxonomy( 'temp_book_type' );
+
+ $variation = $this->get_variation_by_name( 'temp_book_type', $nav_link_block->variations );
+ $this->assertEmpty( $variation, 'Block variation still exists' );
+ }
+
/**
* Get a variation by its name from an array of variations.
*
diff --git a/phpunit/blocks/render-block-navigation-test.php b/phpunit/blocks/render-block-navigation-test.php
index b8266d510ad6c6..c9b552d17f4e46 100644
--- a/phpunit/blocks/render-block-navigation-test.php
+++ b/phpunit/blocks/render-block-navigation-test.php
@@ -66,23 +66,23 @@ public function test_block_core_navigation_get_post_ids_from_block_with_submenu(
}
/**
- * @covers :: gutengberg_block_core_navigation_block_contains_core_navigation
+ * @covers :: block_core_navigation_block_contains_core_navigation
*/
- public function test_gutenberg_block_core_navigation_block_contains_core_navigation() {
+ public function test_block_core_navigation_block_contains_core_navigation() {
$parsed_blocks = parse_blocks( '' );
$inner_blocks = new WP_Block_List( $parsed_blocks );
- $this->assertTrue( gutenberg_block_core_navigation_block_contains_core_navigation( $inner_blocks ) );
+ $this->assertTrue( block_core_navigation_block_contains_core_navigation( $inner_blocks ) );
}
- public function test_gutenberg_block_core_navigation_block_contains_core_navigation_deep() {
+ public function test_block_core_navigation_block_contains_core_navigation_deep() {
$parsed_blocks = parse_blocks( '' );
$inner_blocks = new WP_Block_List( $parsed_blocks );
- $this->assertTrue( gutenberg_block_core_navigation_block_contains_core_navigation( $inner_blocks ) );
+ $this->assertTrue( block_core_navigation_block_contains_core_navigation( $inner_blocks ) );
}
- public function test_gutenberg_block_core_navigation_block_contains_core_navigation_no_navigation() {
+ public function test_block_core_navigation_block_contains_core_navigation_no_navigation() {
$parsed_blocks = parse_blocks( '' );
$inner_blocks = new WP_Block_List( $parsed_blocks );
- $this->assertFalse( gutenberg_block_core_navigation_block_contains_core_navigation( $inner_blocks ) );
+ $this->assertFalse( block_core_navigation_block_contains_core_navigation( $inner_blocks ) );
}
}
diff --git a/phpunit/blocks/render-query-test.php b/phpunit/blocks/render-query-test.php
index 796c4ae098867c..76dbf537e78684 100644
--- a/phpunit/blocks/render-query-test.php
+++ b/phpunit/blocks/render-query-test.php
@@ -69,7 +69,7 @@ public function test_rendering_query_with_enhanced_pagination() {
$p->next_tag( array( 'class_name' => 'wp-block-query' ) );
$this->assertSame( '{"loadingText":"Loading page, please wait.","loadedText":"Page Loaded."}', $p->get_attribute( 'data-wp-context' ) );
- $this->assertSame( 'query-0', $p->get_attribute( 'data-wp-navigation-id' ) );
+ $this->assertSame( 'query-0', $p->get_attribute( 'data-wp-router-region' ) );
$this->assertSame( '{"namespace":"core/query"}', $p->get_attribute( 'data-wp-interactive' ) );
$p->next_tag( array( 'class_name' => 'wp-block-post' ) );
@@ -127,7 +127,7 @@ public function test_rendering_query_with_enhanced_pagination_auto_disabled_when
$p = new WP_HTML_Tag_Processor( $output );
$p->next_tag( array( 'class_name' => 'wp-block-query' ) );
- $this->assertSame( 'query-0', $p->get_attribute( 'data-wp-navigation-id' ) );
+ $this->assertSame( 'query-0', $p->get_attribute( 'data-wp-router-region' ) );
$this->assertSame( 'true', $p->get_attribute( 'data-wp-navigation-disabled' ) );
}
@@ -205,7 +205,7 @@ public function test_rendering_query_with_enhanced_pagination_auto_disabled_when
$p = new WP_HTML_Tag_Processor( $output );
$p->next_tag( array( 'class_name' => 'wp-block-query' ) );
- $this->assertSame( 'query-0', $p->get_attribute( 'data-wp-navigation-id' ) );
+ $this->assertSame( 'query-0', $p->get_attribute( 'data-wp-router-region' ) );
$this->assertSame( 'true', $p->get_attribute( 'data-wp-navigation-disabled' ) );
}
@@ -254,17 +254,17 @@ public function test_rendering_nested_queries_with_enhanced_pagination_auto_disa
// Query 0 contains a plugin block inside query-2 -> disabled.
$p->next_tag( array( 'class_name' => 'wp-block-query' ) );
- $this->assertSame( 'query-0', $p->get_attribute( 'data-wp-navigation-id' ) );
+ $this->assertSame( 'query-0', $p->get_attribute( 'data-wp-router-region' ) );
$this->assertSame( 'true', $p->get_attribute( 'data-wp-navigation-disabled' ) );
// Query 1 does not contain a plugin block -> enabled.
$p->next_tag( array( 'class_name' => 'wp-block-query' ) );
- $this->assertSame( 'query-1', $p->get_attribute( 'data-wp-navigation-id' ) );
+ $this->assertSame( 'query-1', $p->get_attribute( 'data-wp-router-region' ) );
$this->assertSame( null, $p->get_attribute( 'data-wp-navigation-disabled' ) );
// Query 2 contains a plugin block -> disabled.
$p->next_tag( array( 'class_name' => 'wp-block-query' ) );
- $this->assertSame( 'query-2', $p->get_attribute( 'data-wp-navigation-id' ) );
+ $this->assertSame( 'query-2', $p->get_attribute( 'data-wp-router-region' ) );
$this->assertSame( 'true', $p->get_attribute( 'data-wp-navigation-disabled' ) );
}
}
diff --git a/phpunit/class-wp-navigation-block-renderer-test.php b/phpunit/class-wp-navigation-block-renderer-test.php
index 124e0fe91bd1e6..6bcf08c179e90b 100644
--- a/phpunit/class-wp-navigation-block-renderer-test.php
+++ b/phpunit/class-wp-navigation-block-renderer-test.php
@@ -25,7 +25,7 @@ public function test_gutenberg_default_block_is_enclosed_in_li_tags() {
$navigation_link_block = new WP_Block( $parsed_block, $context );
// Setup an empty testing instance of `WP_Navigation_Block_Renderer` and save the original.
- $reflection = new ReflectionClass( 'WP_Navigation_Block_Renderer' );
+ $reflection = new ReflectionClass( 'WP_Navigation_Block_Renderer_Gutenberg' );
$method = $reflection->getMethod( 'get_markup_for_inner_block' );
$method->setAccessible( true );
// Invoke the private method.
@@ -53,7 +53,7 @@ public function test_gutenberg_get_markup_for_inner_block_site_title() {
$site_title_block = new WP_Block( $parsed_block, $context );
// Setup an empty testing instance of `WP_Navigation_Block_Renderer` and save the original.
- $reflection = new ReflectionClass( 'WP_Navigation_Block_Renderer' );
+ $reflection = new ReflectionClass( 'WP_Navigation_Block_Renderer_Gutenberg' );
$method = $reflection->getMethod( 'get_markup_for_inner_block' );
$method->setAccessible( true );
// Invoke the private method.
@@ -71,7 +71,7 @@ public function test_gutenberg_get_markup_for_inner_block_site_title() {
* @covers WP_Navigation_Block_Renderer::get_inner_blocks_from_navigation_post
*/
public function test_gutenberg_get_inner_blocks_from_navigation_post_returns_empty_block_list() {
- $reflection = new ReflectionClass( 'WP_Navigation_Block_Renderer' );
+ $reflection = new ReflectionClass( 'WP_Navigation_Block_Renderer_Gutenberg' );
$method = $reflection->getMethod( 'get_inner_blocks_from_navigation_post' );
$method->setAccessible( true );
$attributes = array( 'ref' => 0 );
diff --git a/phpunit/class-wp-theme-json-test.php b/phpunit/class-wp-theme-json-test.php
index 89900d45893d91..95d7c92507a622 100644
--- a/phpunit/class-wp-theme-json-test.php
+++ b/phpunit/class-wp-theme-json-test.php
@@ -2451,4 +2451,28 @@ public function test_resolve_variables() {
$this->assertEquals( $small_font, $styles['blocks']['core/quote']['variations']['plain']['typography']['fontSize'], 'Block variations: font-size' );
$this->assertEquals( $secondary_color, $styles['blocks']['core/quote']['variations']['plain']['color']['background'], 'Block variations: color' );
}
+
+ public function test_get_stylesheet_custom_root_selector() {
+ $theme_json = new WP_Theme_JSON_Gutenberg(
+ array(
+ 'version' => WP_Theme_JSON_Gutenberg::LATEST_SCHEMA,
+ 'styles' => array(
+ 'color' => array(
+ 'text' => 'teal',
+ ),
+ ),
+ ),
+ 'default'
+ );
+
+ $options = array( 'root_selector' => '.custom' );
+ $actual = $theme_json->get_stylesheet( array( 'styles' ), null, $options );
+
+ // Results also include root site blocks styles which hard code
+ // `body { margin: 0;}`.
+ $this->assertEquals(
+ 'body { margin: 0;}.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }.custom{color: teal;}',
+ $actual
+ );
+ }
}
diff --git a/phpunit/data/fonts/OpenSans-Regular.otf b/phpunit/data/fonts/OpenSans-Regular.otf
new file mode 100644
index 00000000000000..8db0f64c67ddd5
Binary files /dev/null and b/phpunit/data/fonts/OpenSans-Regular.otf differ
diff --git a/phpunit/data/fonts/OpenSans-Regular.ttf b/phpunit/data/fonts/OpenSans-Regular.ttf
new file mode 100644
index 00000000000000..ae716936e9e4c1
Binary files /dev/null and b/phpunit/data/fonts/OpenSans-Regular.ttf differ
diff --git a/phpunit/data/fonts/OpenSans-Regular.woff b/phpunit/data/fonts/OpenSans-Regular.woff
new file mode 100644
index 00000000000000..bd0f824b207d60
Binary files /dev/null and b/phpunit/data/fonts/OpenSans-Regular.woff differ
diff --git a/phpunit/data/fonts/OpenSans-Regular.woff2 b/phpunit/data/fonts/OpenSans-Regular.woff2
new file mode 100644
index 00000000000000..f778f9c8455f26
Binary files /dev/null and b/phpunit/data/fonts/OpenSans-Regular.woff2 differ
diff --git a/phpunit/experimental/interactivity-api/class-wp-directive-processor-test.php b/phpunit/experimental/interactivity-api/class-wp-directive-processor-test.php
deleted file mode 100644
index 2b01cb6251c210..00000000000000
--- a/phpunit/experimental/interactivity-api/class-wp-directive-processor-test.php
+++ /dev/null
@@ -1,132 +0,0 @@
-outside inside
';
-
- public function test_next_balanced_closer_stays_on_void_tag() {
- $tags = new WP_Directive_Processor( self::HTML );
-
- $tags->next_tag( 'img' );
- $result = $tags->next_balanced_closer();
- $this->assertSame( 'IMG', $tags->get_tag() );
- $this->assertFalse( $result );
- }
-
- public function test_next_balanced_closer_proceeds_to_correct_tag() {
- $tags = new WP_Directive_Processor( self::HTML );
-
- $tags->next_tag( 'section' );
- $tags->next_balanced_closer();
- $this->assertSame( 'SECTION', $tags->get_tag() );
- $this->assertTrue( $tags->is_tag_closer() );
- }
-
- public function test_next_balanced_closer_proceeds_to_correct_tag_for_nested_tag() {
- $tags = new WP_Directive_Processor( self::HTML );
-
- $tags->next_tag( 'div' );
- $tags->next_tag( 'div' );
- $tags->next_balanced_closer();
- $this->assertSame( 'DIV', $tags->get_tag() );
- $this->assertTrue( $tags->is_tag_closer() );
- }
-
- public function test_get_inner_html_returns_correct_result() {
- $tags = new WP_Directive_Processor( self::HTML );
-
- $tags->next_tag( 'section' );
- $this->assertSame( '
inside
', $tags->get_inner_html() );
- }
-
- public function test_set_inner_html_on_void_element_has_no_effect() {
- $tags = new WP_Directive_Processor( self::HTML );
-
- $tags->next_tag( 'img' );
- $content = $tags->set_inner_html( 'This is the new img content' );
- $this->assertFalse( $content );
- $this->assertSame( self::HTML, $tags->get_updated_html() );
- }
-
- public function test_set_inner_html_sets_content_correctly() {
- $tags = new WP_Directive_Processor( self::HTML );
-
- $tags->next_tag( 'section' );
- $tags->set_inner_html( 'This is the new section content.' );
- $this->assertSame( '
outside
This is the new section content. ', $tags->get_updated_html() );
- }
-
- public function test_set_inner_html_updates_bookmarks_correctly() {
- $tags = new WP_Directive_Processor( self::HTML );
-
- $tags->next_tag( 'div' );
- $tags->set_bookmark( 'start' );
- $tags->next_tag( 'img' );
- $this->assertSame( 'IMG', $tags->get_tag() );
- $tags->set_bookmark( 'after' );
- $tags->seek( 'start' );
-
- $tags->set_inner_html( 'This is the new div content.' );
- $this->assertSame( '
This is the new div content.
inside
', $tags->get_updated_html() );
- $tags->seek( 'after' );
- $this->assertSame( 'IMG', $tags->get_tag() );
- }
-
- public function test_set_inner_html_subsequent_updates_on_the_same_tag_work() {
- $tags = new WP_Directive_Processor( self::HTML );
-
- $tags->next_tag( 'section' );
- $tags->set_inner_html( 'This is the new section content.' );
- $tags->set_inner_html( 'This is the even newer section content.' );
- $this->assertSame( '
outside
This is the even newer section content. ', $tags->get_updated_html() );
- }
-
- public function test_set_inner_html_followed_by_set_attribute_works() {
- $tags = new WP_Directive_Processor( self::HTML );
-
- $tags->next_tag( 'section' );
- $tags->set_inner_html( 'This is the new section content.' );
- $tags->set_attribute( 'id', 'thesection' );
- $this->assertSame( '
outside
This is the new section content. ', $tags->get_updated_html() );
- }
-
- public function test_set_inner_html_preceded_by_set_attribute_works() {
- $tags = new WP_Directive_Processor( self::HTML );
-
- $tags->next_tag( 'section' );
- $tags->set_attribute( 'id', 'thesection' );
- $tags->set_inner_html( 'This is the new section content.' );
- $this->assertSame( '
outside
This is the new section content. ', $tags->get_updated_html() );
- }
-
- /**
- * TODO: Review this, how that the code is in Gutenberg.
- */
- public function test_set_inner_html_invalidates_bookmarks_that_point_to_replaced_content() {
- $this->markTestSkipped( "This requires on bookmark invalidation, which is only in GB's WP 6.3 compat layer." );
-
- $tags = new WP_Directive_Processor( self::HTML );
-
- $tags->next_tag( 'section' );
- $tags->set_bookmark( 'start' );
- $tags->next_tag( 'img' );
- $tags->set_bookmark( 'replaced' );
- $tags->seek( 'start' );
-
- $tags->set_inner_html( 'This is the new section content.' );
- $this->assertSame( '
outside
This is the new section content. ', $tags->get_updated_html() );
-
- $this->expectExceptionMessage( 'Invalid bookmark name' );
- $successful_seek = $tags->seek( 'replaced' );
- $this->assertFalse( $successful_seek );
- }
-}
diff --git a/phpunit/experimental/interactivity-api/class-wp-interactivity-initial-state-test.php b/phpunit/experimental/interactivity-api/class-wp-interactivity-initial-state-test.php
deleted file mode 100644
index a95c3482ec80d1..00000000000000
--- a/phpunit/experimental/interactivity-api/class-wp-interactivity-initial-state-test.php
+++ /dev/null
@@ -1,115 +0,0 @@
-assertEmpty( WP_Interactivity_Initial_State::get_data() );
- }
-
- public function test_initial_state_can_be_merged() {
- $state = array(
- 'a' => 1,
- 'b' => 2,
- 'nested' => array(
- 'c' => 3,
- ),
- );
- WP_Interactivity_Initial_State::merge_state( 'core', $state );
- $this->assertSame( $state, WP_Interactivity_Initial_State::get_state( 'core' ) );
- }
-
- public function test_initial_state_can_be_extended() {
- WP_Interactivity_Initial_State::merge_state( 'core', array( 'a' => 1 ) );
- WP_Interactivity_Initial_State::merge_state( 'core', array( 'b' => 2 ) );
- WP_Interactivity_Initial_State::merge_state( 'custom', array( 'c' => 3 ) );
- $this->assertSame(
- array(
- 'core' => array(
- 'a' => 1,
- 'b' => 2,
- ),
- 'custom' => array(
- 'c' => 3,
- ),
- ),
- WP_Interactivity_Initial_State::get_data()
- );
- }
-
- public function test_initial_state_existing_props_should_be_overwritten() {
- WP_Interactivity_Initial_State::merge_state( 'core', array( 'a' => 1 ) );
- WP_Interactivity_Initial_State::merge_state( 'core', array( 'a' => 'overwritten' ) );
- $this->assertSame(
- array(
- 'core' => array(
- 'a' => 'overwritten',
- ),
- ),
- WP_Interactivity_Initial_State::get_data()
- );
- }
-
- public function test_initial_state_existing_indexed_arrays_should_be_replaced() {
- WP_Interactivity_Initial_State::merge_state( 'core', array( 'a' => array( 1, 2 ) ) );
- WP_Interactivity_Initial_State::merge_state( 'core', array( 'a' => array( 3, 4 ) ) );
- $this->assertSame(
- array(
- 'core' => array(
- 'a' => array( 3, 4 ),
- ),
- ),
- WP_Interactivity_Initial_State::get_data()
- );
- }
-
- public function test_initial_state_should_be_correctly_rendered() {
- WP_Interactivity_Initial_State::merge_state( 'core', array( 'a' => 1 ) );
- WP_Interactivity_Initial_State::merge_state( 'core', array( 'b' => 2 ) );
- WP_Interactivity_Initial_State::merge_state( 'custom', array( 'c' => 3 ) );
-
- ob_start();
- WP_Interactivity_Initial_State::render();
- $rendered = ob_get_clean();
- $this->assertSame(
- '',
- $rendered
- );
- }
-
- public function test_initial_state_should_also_escape_tags_and_amps() {
- WP_Interactivity_Initial_State::merge_state(
- 'test',
- array(
- 'amps' => 'http://site.test/?foo=1&baz=2&bar=3',
- 'tags' => 'Do not do this: ' .
- '
Welcome to WordPress. This is your first post. Edit or delete it, then start writing!
' .
- '' .
- '' .
- '
Welcome to WordPress.
' .
- '';
-
- $parsed_block = parse_blocks( $block_content )[0];
- $source_block = $parsed_block;
- $rendered_content = render_block( $parsed_block );
- $parsed_block_second = parse_blocks( $block_content )[1];
- $fake_parent_block = array();
-
- // Test that root block is intially emtpy.
- $this->assertEmpty( WP_Directive_Processor::$root_block );
-
- // Test that root block is not added if there is a parent block.
- gutenberg_interactivity_mark_root_blocks( $parsed_block, $source_block, $fake_parent_block );
- $this->assertEmpty( WP_Directive_Processor::$root_block );
-
- // Test that root block is added if there is no parent block.
- gutenberg_interactivity_mark_root_blocks( $parsed_block, $source_block, null );
- $current_root_block = WP_Directive_Processor::$root_block;
- $this->assertNotEmpty( $current_root_block );
-
- // Test that a root block is not added if there is already a root block defined.
- gutenberg_interactivity_mark_root_blocks( $parsed_block_second, $source_block, null );
- $this->assertSame( $current_root_block, WP_Directive_Processor::$root_block );
-
- // Test that root block is removed after processing.
- gutenberg_process_directives_in_root_blocks( $rendered_content, $parsed_block );
- $this->assertEmpty( WP_Directive_Processor::$root_block );
- }
-
- public function test_directive_processing_of_interactive_block() {
- $post_content = '';
- $rendered_blocks = do_blocks( $post_content );
- $p = new WP_HTML_Tag_Processor( $rendered_blocks );
- $p->next_tag( array( 'class_name' => 'level-1-input-1' ) );
- $value = $p->get_attribute( 'value' );
- $this->assertSame( 'level-1', $value );
- $p->next_tag( array( 'class_name' => 'level-1-input-2' ) );
- $value = $p->get_attribute( 'value' );
- $this->assertSame( 'level-1', $value );
- }
-
- public function test_directive_processing_two_interactive_blocks_at_same_level() {
- $post_content = '
';
- $rendered_blocks = do_blocks( $post_content );
- $p = new WP_HTML_Tag_Processor( $rendered_blocks );
- $p->next_tag( array( 'class_name' => 'level-1-input-1' ) );
- $value = $p->get_attribute( 'value' );
- $this->assertSame( 'level-1', $value );
- $p->next_tag( array( 'class_name' => 'level-1-input-2' ) );
- $value = $p->get_attribute( 'value' );
- $this->assertSame( 'level-1', $value );
- $p->next_tag( array( 'class_name' => 'level-2-input-1' ) );
- $value = $p->get_attribute( 'value' );
- $this->assertSame( 'level-2', $value );
- }
-
- public function test_directives_are_processed_at_tag_end() {
- $post_content = '';
- $rendered_blocks = do_blocks( $post_content );
- $p = new WP_HTML_Tag_Processor( $rendered_blocks );
- $p->next_tag( array( 'class_name' => 'level-1-input-1' ) );
- $value = $p->get_attribute( 'value' );
- $this->assertSame( 'level-1', $value );
- $p->next_tag( array( 'class_name' => 'level-2-input-1' ) );
- $value = $p->get_attribute( 'value' );
- $this->assertSame( 'level-2', $value );
- $p->next_tag( array( 'class_name' => 'read-only-input-1' ) );
- $value = $p->get_attribute( 'value' );
- $this->assertSame( 'level-1', $value );
- $p->next_tag( array( 'class_name' => 'level-1-input-2' ) );
- $value = $p->get_attribute( 'value' );
- $this->assertSame( 'level-1', $value );
- }
-
- public function test_non_interactive_children_of_interactive_is_rendered() {
- $post_content = '
Welcome
';
- $rendered_blocks = do_blocks( $post_content );
- $p = new WP_HTML_Tag_Processor( $rendered_blocks );
- $p->next_tag( array( 'class_name' => 'level-1-input-1' ) );
- $value = $p->get_attribute( 'value' );
- $this->assertSame( 'level-1', $value );
- $p->next_tag( array( 'class_name' => 'read-only-input-1' ) );
- $value = $p->get_attribute( 'value' );
- $this->assertSame( 'level-1', $value );
- $p->next_tag();
- $this->assertSame( 'P', $p->get_tag() );
- $p->next_tag( array( 'class_name' => 'level-1-input-2' ) );
- $value = $p->get_attribute( 'value' );
- $this->assertSame( 'level-1', $value );
- }
-
- public function test_non_interactive_blocks_are_not_processed() {
- $post_content = '';
- $rendered_blocks = do_blocks( $post_content );
- $p = new WP_HTML_Tag_Processor( $rendered_blocks );
- $p->next_tag( array( 'class_name' => 'non-interactive-with-directive' ) );
- $value = $p->get_attribute( 'value' );
- $this->assertSame( null, $value );
- }
-
- public function test_non_interactive_blocks_with_manual_inner_block_rendering_are_not_processed() {
- $post_content = '';
- $rendered_blocks = do_blocks( $post_content );
- $p = new WP_HTML_Tag_Processor( $rendered_blocks );
- $p->next_tag( array( 'class_name' => 'non-interactive-with-directive' ) );
- $value = $p->get_attribute( 'value' );
- $this->assertSame( null, $value );
- }
-
- public function test_directives_ordering() {
- $post_content = '';
- $rendered_blocks = do_blocks( $post_content );
- $p = new WP_HTML_Tag_Processor( $rendered_blocks );
- $p->next_tag();
-
- $value = $p->get_attribute( 'class' );
- $this->assertSame( 'other-class some-class', $value );
-
- $value = $p->get_attribute( 'value' );
- $this->assertSame( 'some-value', $value );
-
- $value = $p->get_attribute( 'style' );
- $this->assertSame( 'display: none;', $value );
- }
-
- public function test_evaluate_function_should_access_state() {
- // Init a simple store.
- wp_initial_state(
- 'test',
- array(
- 'number' => 1,
- 'bool' => true,
- 'nested' => array(
- 'string' => 'hi',
- ),
- )
- );
-
- $this->assertSame( 1, gutenberg_interactivity_evaluate_reference( 'state.number', 'test' ) );
- $this->assertTrue( gutenberg_interactivity_evaluate_reference( 'state.bool', 'test' ) );
- $this->assertSame( 'hi', gutenberg_interactivity_evaluate_reference( 'state.nested.string', 'test' ) );
- $this->assertFalse( gutenberg_interactivity_evaluate_reference( '!state.bool', 'test' ) );
- }
-
- public function test_evaluate_function_should_access_passed_context() {
- wp_initial_state(
- 'test',
- array(
- 'number' => 1,
- 'bool' => true,
- 'nested' => array(
- 'string' => 'hi',
- ),
- )
- );
-
- $context = array(
- 'test' => array(
- 'number' => 2,
- 'bool' => false,
- 'nested' => array(
- 'string' => 'bye',
- ),
- ),
- );
-
- $this->assertSame( 2, gutenberg_interactivity_evaluate_reference( 'context.number', 'test', $context ) );
- $this->assertFalse( gutenberg_interactivity_evaluate_reference( 'context.bool', 'test', $context ) );
- $this->assertTrue( gutenberg_interactivity_evaluate_reference( '!context.bool', 'test', $context ) );
- $this->assertSame( 'bye', gutenberg_interactivity_evaluate_reference( 'context.nested.string', 'test', $context ) );
-
- // Defined state is also accessible.
- $this->assertSame( 1, gutenberg_interactivity_evaluate_reference( 'state.number', 'test' ) );
- $this->assertTrue( gutenberg_interactivity_evaluate_reference( 'state.bool', 'test' ) );
- $this->assertSame( 'hi', gutenberg_interactivity_evaluate_reference( 'state.nested.string', 'test' ) );
- }
-
- public function test_evaluate_function_should_return_null_for_unresolved_paths() {
- $this->assertNull( gutenberg_interactivity_evaluate_reference( 'this.property.doesnt.exist', 'myblock' ) );
- }
-
- public function test_evaluate_function_should_execute_anonymous_functions() {
- $this->markTestSkipped( 'Derived state was supported for `wp_store()` but not for `wp_initial_state()` yet.' );
-
- $context = new WP_Directive_Context( array( 'myblock' => array( 'count' => 2 ) ) );
-
- wp_initial_state(
- 'myblock',
- array(
- 'count' => 3,
- 'anonymous_function' => function ( $store ) {
- return $store['state']['count'] + $store['context']['count'];
- },
- // Other types of callables should not be executed.
- 'function_name' => 'gutenberg_test_process_directives_helper_increment',
- 'class_method' => array( $this, 'increment' ),
- 'class_static_method' => array( 'Tests_Process_Directives', 'static_increment' ),
- )
- );
-
- $this->assertSame( 5, gutenberg_interactivity_evaluate_reference( 'state.anonymous_function', 'myblock', $context->get_context() ) );
- $this->assertSame(
- 'gutenberg_test_process_directives_helper_increment',
- gutenberg_interactivity_evaluate_reference( 'state.function_name', 'myblock', $context->get_context() )
- );
- $this->assertSame(
- array( $this, 'increment' ),
- gutenberg_interactivity_evaluate_reference( 'state.class_method', 'myblock', $context->get_context() )
- );
- $this->assertSame(
- array( 'Tests_Process_Directives', 'static_increment' ),
- gutenberg_interactivity_evaluate_reference( 'state.class_static_method', 'myblock', $context->get_context() )
- );
- }
-
- public function test_namespace_should_be_inherited_from_ancestor() {
- /*
- * This function call should be done inside block render functions. We
- * run it here instead just for conveninence.
- */
- wp_initial_state( 'test-1', array( 'text' => 'state' ) );
-
- $post_content = '
-
-
-
-
-
-
- ';
-
- $html = do_blocks( $post_content );
- $tags = new WP_HTML_Tag_Processor( $html );
-
- $tags->next_tag( array( 'class_name' => 'bind-state' ) );
- $this->assertSame( 'state', $tags->get_attribute( 'data-value' ) );
-
- $tags->next_tag( array( 'class_name' => 'bind-context' ) );
- $this->assertSame( 'context', $tags->get_attribute( 'data-value' ) );
- }
-
- public function test_namespace_should_be_inherited_from_same_element() {
- /*
- * This function call should be done inside block render functions. We
- * run it here instead just for conveninence.
- */
- wp_initial_state( 'test-2', array( 'text' => 'state-2' ) );
-
- $post_content = '
-
-
-
-
-
-
- ';
-
- $html = do_blocks( $post_content );
- $tags = new WP_HTML_Tag_Processor( $html );
-
- $tags->next_tag( array( 'class_name' => 'bind-state' ) );
- $this->assertSame( 'state-2', $tags->get_attribute( 'data-value' ) );
-
- $tags->next_tag( array( 'class_name' => 'bind-context' ) );
- $this->assertSame( 'context-2', $tags->get_attribute( 'data-value' ) );
- }
-
- public function test_namespace_should_not_leak_from_descendant() {
- /*
- * This function call should be done inside block render functions. We
- * run it here instead just for conveninence.
- */
- wp_initial_state( 'test-1', array( 'text' => 'state-1' ) );
- wp_initial_state( 'test-2', array( 'text' => 'state-2' ) );
-
- $post_content = '
-
-
-
-
-
- ';
-
- $html = do_blocks( $post_content );
- $tags = new WP_HTML_Tag_Processor( $html );
-
- $tags->next_tag( array( 'class_name' => 'target' ) );
- $this->assertSame( 'state-1', $tags->get_attribute( 'data-state' ) );
- $this->assertSame( 'context-1', $tags->get_attribute( 'data-context' ) );
- }
-
- public function test_namespace_should_not_leak_from_sibling() {
- /*
- * This function call should be done inside block render functions. We
- * run it here instead just for conveninence.
- */
- wp_initial_state( 'test-1', array( 'text' => 'state-1' ) );
- wp_initial_state( 'test-2', array( 'text' => 'state-2' ) );
-
- $post_content = '
-
-
-
-
-
-
- ';
-
- $html = do_blocks( $post_content );
- $tags = new WP_HTML_Tag_Processor( $html );
-
- $tags->next_tag( array( 'class_name' => 'target' ) );
- $this->assertSame( 'state-1', $tags->get_attribute( 'data-from-state' ) );
- $this->assertSame( 'context-1', $tags->get_attribute( 'data-from-context' ) );
- }
-
- public function test_namespace_can_be_overwritten_in_directives() {
- /*
- * This function call should be done inside block render functions. We
- * run it here instead just for conveninence.
- */
- wp_initial_state( 'test-1', array( 'text' => 'state-1' ) );
- wp_initial_state( 'test-2', array( 'text' => 'state-2' ) );
-
- $post_content = '
-
-
-
-
-
- ';
-
- $html = do_blocks( $post_content );
- $tags = new WP_HTML_Tag_Processor( $html );
-
- $tags->next_tag( array( 'class_name' => 'inherited-ns' ) );
- $this->assertSame( 'state-1', $tags->get_attribute( 'data-value' ) );
-
- $tags->next_tag( array( 'class_name' => 'custom-ns' ) );
- $this->assertSame( 'state-2', $tags->get_attribute( 'data-value' ) );
-
- $tags->next_tag( array( 'class_name' => 'mixed-ns' ) );
- $this->assertSame( 'state-1', $tags->get_attribute( 'data-inherited-ns' ) );
- $this->assertSame( 'state-2', $tags->get_attribute( 'data-custom-ns' ) );
- }
-}
diff --git a/phpunit/experimental/interactivity-api/directives/wp-bind-test.php b/phpunit/experimental/interactivity-api/directives/wp-bind-test.php
deleted file mode 100644
index 8fe212bb8ed93a..00000000000000
--- a/phpunit/experimental/interactivity-api/directives/wp-bind-test.php
+++ /dev/null
@@ -1,48 +0,0 @@
-';
- $tags = new WP_HTML_Tag_Processor( $markup );
- $tags->next_tag();
-
- $context_before = new WP_Directive_Context( array( 'myblock' => array( 'imageSource' => './wordpress.png' ) ) );
- $context = $context_before;
- $directive_ns = 'myblock';
- gutenberg_interactivity_process_wp_bind( $tags, $context, $directive_ns );
-
- $this->assertSame(
- '
',
- $tags->get_updated_html()
- );
- $this->assertSame( './wordpress.png', $tags->get_attribute( 'src' ) );
- $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-bind directive changed context' );
- }
-
- public function test_directive_ignores_empty_bound_attribute() {
- $markup = '
';
- $tags = new WP_HTML_Tag_Processor( $markup );
- $tags->next_tag();
-
- $context_before = new WP_Directive_Context( array( 'myblock' => array( 'imageSource' => './wordpress.png' ) ) );
- $context = $context_before;
- $directive_ns = 'myblock';
- gutenberg_interactivity_process_wp_bind( $tags, $context, $directive_ns );
-
- $this->assertSame( $markup, $tags->get_updated_html() );
- $this->assertNull( $tags->get_attribute( 'src' ) );
- $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-bind directive changed context' );
- }
-}
diff --git a/phpunit/experimental/interactivity-api/directives/wp-class-test.php b/phpunit/experimental/interactivity-api/directives/wp-class-test.php
deleted file mode 100644
index f40486647ff8b8..00000000000000
--- a/phpunit/experimental/interactivity-api/directives/wp-class-test.php
+++ /dev/null
@@ -1,103 +0,0 @@
-Test
';
- $tags = new WP_HTML_Tag_Processor( $markup );
- $tags->next_tag();
-
- $context_before = new WP_Directive_Context( array( 'myblock' => array( 'isRed' => true ) ) );
- $context = $context_before;
- $directive_ns = 'myblock';
- gutenberg_interactivity_process_wp_class( $tags, $context, $directive_ns );
-
- $this->assertSame(
- 'Test
',
- $tags->get_updated_html()
- );
- $this->assertStringContainsString( 'red', $tags->get_attribute( 'class' ) );
- $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-class directive changed context' );
- }
-
- public function test_directive_removes_class() {
- $markup = 'Test
';
- $tags = new WP_HTML_Tag_Processor( $markup );
- $tags->next_tag();
-
- $context_before = new WP_Directive_Context( array( 'myblock' => array( 'isBlue' => false ) ) );
- $context = $context_before;
- $directive_ns = 'myblock';
- gutenberg_interactivity_process_wp_class( $tags, $context, $directive_ns );
-
- $this->assertSame(
- 'Test
',
- $tags->get_updated_html()
- );
- $this->assertStringNotContainsString( 'blue', $tags->get_attribute( 'class' ) );
- $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-class directive changed context' );
- }
-
- public function test_directive_removes_empty_class_attribute() {
- $markup = 'Test
';
- $tags = new WP_HTML_Tag_Processor( $markup );
- $tags->next_tag();
-
- $context_before = new WP_Directive_Context( array( 'myblock' => array( 'isBlue' => false ) ) );
- $context = $context_before;
- $directive_ns = 'myblock';
- gutenberg_interactivity_process_wp_class( $tags, $context, $directive_ns );
-
- $this->assertSame(
- // WP_HTML_Tag_Processor has a TODO note to prune whitespace after classname removal.
- 'Test
',
- $tags->get_updated_html()
- );
- $this->assertNull( $tags->get_attribute( 'class' ) );
- $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-class directive changed context' );
- }
-
- public function test_directive_does_not_remove_non_existant_class() {
- $markup = 'Test
';
- $tags = new WP_HTML_Tag_Processor( $markup );
- $tags->next_tag();
-
- $context_before = new WP_Directive_Context( array( 'myblock' => array( 'isBlue' => false ) ) );
- $context = $context_before;
- $directive_ns = 'myblock';
- gutenberg_interactivity_process_wp_class( $tags, $context, $directive_ns );
-
- $this->assertSame(
- 'Test
',
- $tags->get_updated_html()
- );
- $this->assertSame( 'green red', $tags->get_attribute( 'class' ) );
- $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-class directive changed context' );
- }
-
- public function test_directive_ignores_empty_class_name() {
- $markup = 'Test
';
- $tags = new WP_HTML_Tag_Processor( $markup );
- $tags->next_tag();
-
- $context_before = new WP_Directive_Context( array( 'myblock' => array( 'isRed' => true ) ) );
- $context = $context_before;
- $directive_ns = 'myblock';
- gutenberg_interactivity_process_wp_class( $tags, $context, $directive_ns );
-
- $this->assertSame( $markup, $tags->get_updated_html() );
- $this->assertStringNotContainsString( 'red', $tags->get_attribute( 'class' ) );
- $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-class directive changed context' );
- }
-}
diff --git a/phpunit/experimental/interactivity-api/directives/wp-context-test.php b/phpunit/experimental/interactivity-api/directives/wp-context-test.php
deleted file mode 100644
index 788feec95fe7c5..00000000000000
--- a/phpunit/experimental/interactivity-api/directives/wp-context-test.php
+++ /dev/null
@@ -1,230 +0,0 @@
- array( 'open' => false ),
- 'otherblock' => array( 'somekey' => 'somevalue' ),
- )
- );
-
- $ns = 'myblock';
- $markup = '';
- $tags = new WP_HTML_Tag_Processor( $markup );
- $tags->next_tag();
-
- gutenberg_interactivity_process_wp_context( $tags, $context, $ns );
-
- $this->assertSame(
- array(
- 'myblock' => array( 'open' => true ),
- 'otherblock' => array( 'somekey' => 'somevalue' ),
- ),
- $context->get_context()
- );
- }
-
- public function test_directive_resets_context_correctly_upon_closing_tag() {
- $context = new WP_Directive_Context(
- array( 'myblock' => array( 'my-key' => 'original-value' ) )
- );
-
- $context->set_context(
- array( 'myblock' => array( 'my-key' => 'new-value' ) )
- );
-
- $markup = '
';
- $tags = new WP_HTML_Tag_Processor( $markup );
- $tags->next_tag( array( 'tag_closers' => 'visit' ) );
-
- gutenberg_interactivity_process_wp_context( $tags, $context, 'myblock' );
-
- $this->assertSame(
- array( 'my-key' => 'original-value' ),
- $context->get_context()['myblock']
- );
- }
-
- public function test_directive_doesnt_throw_on_malformed_context_objects() {
- $context = new WP_Directive_Context(
- array( 'myblock' => array( 'my-key' => 'some-value' ) )
- );
-
- $markup = '';
- $tags = new WP_HTML_Tag_Processor( $markup );
- $tags->next_tag();
-
- gutenberg_interactivity_process_wp_context( $tags, $context, 'myblock' );
-
- $this->assertSame(
- array( 'my-key' => 'some-value' ),
- $context->get_context()['myblock']
- );
- }
-
- public function test_directive_keeps_working_after_malformed_context_objects() {
- $context = new WP_Directive_Context();
-
- $markup = '
-
- ';
- $tags = new WP_HTML_Tag_Processor( $markup );
-
- // Parent div.
- $tags->next_tag( array( 'tag_closers' => 'visit' ) );
- gutenberg_interactivity_process_wp_context( $tags, $context, 'myblock' );
-
- $this->assertSame(
- array( 'my-key' => 'some-value' ),
- $context->get_context()['myblock']
- );
-
- // Children div.
- $tags->next_tag( array( 'tag_closers' => 'visit' ) );
- gutenberg_interactivity_process_wp_context( $tags, $context, 'myblock' );
-
- // Still the same context.
- $this->assertSame(
- array( 'my-key' => 'some-value' ),
- $context->get_context()['myblock']
- );
-
- // Closing children div.
- $tags->next_tag( array( 'tag_closers' => 'visit' ) );
- gutenberg_interactivity_process_wp_context( $tags, $context, 'myblock' );
-
- // Still the same context.
- $this->assertSame(
- array( 'my-key' => 'some-value' ),
- $context->get_context()['myblock']
- );
-
- // Closing parent div.
- $tags->next_tag( array( 'tag_closers' => 'visit' ) );
- gutenberg_interactivity_process_wp_context( $tags, $context, 'myblock' );
-
- // Now the context is empty.
- $this->assertSame(
- array(),
- $context->get_context()
- );
- }
-
- public function test_directive_keeps_working_with_a_directive_without_value() {
- $context = new WP_Directive_Context();
-
- $markup = '
-
- ';
- $tags = new WP_HTML_Tag_Processor( $markup );
-
- // Parent div.
- $tags->next_tag( array( 'tag_closers' => 'visit' ) );
- gutenberg_interactivity_process_wp_context( $tags, $context, 'myblock' );
-
- $this->assertSame(
- array( 'my-key' => 'some-value' ),
- $context->get_context()['myblock']
- );
-
- // Children div.
- $tags->next_tag( array( 'tag_closers' => 'visit' ) );
- gutenberg_interactivity_process_wp_context( $tags, $context, 'myblock' );
-
- // Still the same context.
- $this->assertSame(
- array( 'my-key' => 'some-value' ),
- $context->get_context()['myblock']
- );
-
- // Closing children div.
- $tags->next_tag( array( 'tag_closers' => 'visit' ) );
- gutenberg_interactivity_process_wp_context( $tags, $context, 'myblock' );
-
- // Still the same context.
- $this->assertSame(
- array( 'my-key' => 'some-value' ),
- $context->get_context()['myblock']
- );
-
- // Closing parent div.
- $tags->next_tag( array( 'tag_closers' => 'visit' ) );
- gutenberg_interactivity_process_wp_context( $tags, $context, 'myblock' );
-
- // Now the context is empty.
- $this->assertSame(
- array(),
- $context->get_context()
- );
- }
-
- public function test_directive_keeps_working_with_an_empty_directive() {
- $context = new WP_Directive_Context();
-
- $markup = '
-
- ';
- $tags = new WP_HTML_Tag_Processor( $markup );
-
- // Parent div.
- $tags->next_tag( array( 'tag_closers' => 'visit' ) );
- gutenberg_interactivity_process_wp_context( $tags, $context, 'myblock' );
-
- $this->assertSame(
- array( 'my-key' => 'some-value' ),
- $context->get_context()['myblock']
- );
-
- // Children div.
- $tags->next_tag( array( 'tag_closers' => 'visit' ) );
- gutenberg_interactivity_process_wp_context( $tags, $context, 'myblock' );
-
- // Still the same context.
- $this->assertSame(
- array( 'my-key' => 'some-value' ),
- $context->get_context()['myblock']
- );
-
- // Closing children div.
- $tags->next_tag( array( 'tag_closers' => 'visit' ) );
- gutenberg_interactivity_process_wp_context( $tags, $context, 'myblock' );
-
- // Still the same context.
- $this->assertSame(
- array( 'my-key' => 'some-value' ),
- $context->get_context()['myblock']
- );
-
- // Closing parent div.
- $tags->next_tag( array( 'tag_closers' => 'visit' ) );
- gutenberg_interactivity_process_wp_context( $tags, $context, 'myblock' );
-
- // Now the context is empty.
- $this->assertSame(
- array(),
- $context->get_context()
- );
- }
-}
diff --git a/phpunit/experimental/interactivity-api/directives/wp-style-test.php b/phpunit/experimental/interactivity-api/directives/wp-style-test.php
deleted file mode 100644
index 9625803ebca78f..00000000000000
--- a/phpunit/experimental/interactivity-api/directives/wp-style-test.php
+++ /dev/null
@@ -1,63 +0,0 @@
-Test
';
- $tags = new WP_HTML_Tag_Processor( $markup );
- $tags->next_tag();
-
- $context_before = new WP_Directive_Context( array( 'myblock' => array( 'color' => 'green' ) ) );
- $context = $context_before;
- gutenberg_interactivity_process_wp_style( $tags, $context, 'myblock' );
-
- $this->assertSame(
- 'Test
',
- $tags->get_updated_html()
- );
- $this->assertStringContainsString( 'color: green;', $tags->get_attribute( 'style' ) );
- $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-style directive changed context' );
- }
-
- public function test_directive_ignores_empty_style() {
- $markup = 'Test
';
- $tags = new WP_HTML_Tag_Processor( $markup );
- $tags->next_tag();
-
- $context_before = new WP_Directive_Context( array( 'myblock' => array( 'color' => 'green' ) ) );
- $context = $context_before;
- gutenberg_interactivity_process_wp_style( $tags, $context, 'myblock' );
-
- $this->assertSame( $markup, $tags->get_updated_html() );
- $this->assertStringNotContainsString( 'color: green;', $tags->get_attribute( 'style' ) );
- $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-style directive changed context' );
- }
-
- public function test_directive_works_without_style_attribute() {
- $markup = 'Test
';
- $tags = new WP_HTML_Tag_Processor( $markup );
- $tags->next_tag();
-
- $context_before = new WP_Directive_Context( array( 'myblock' => array( 'color' => 'green' ) ) );
- $context = $context_before;
- gutenberg_interactivity_process_wp_style( $tags, $context, 'myblock' );
-
- $this->assertSame(
- 'Test
',
- $tags->get_updated_html()
- );
- $this->assertSame( 'color: green;', $tags->get_attribute( 'style' ) );
- $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-style directive changed context' );
- }
-}
diff --git a/phpunit/experimental/interactivity-api/directives/wp-text-test.php b/phpunit/experimental/interactivity-api/directives/wp-text-test.php
deleted file mode 100644
index 9c889a3f0eb68f..00000000000000
--- a/phpunit/experimental/interactivity-api/directives/wp-text-test.php
+++ /dev/null
@@ -1,45 +0,0 @@
- ';
-
- $tags = new WP_Directive_Processor( $markup );
- $tags->next_tag();
-
- $context_before = new WP_Directive_Context( array( 'myblock' => array( 'someText' => 'The HTML tag
produces a line break.' ) ) );
- $context = clone $context_before;
- gutenberg_interactivity_process_wp_text( $tags, $context, 'myblock' );
-
- $expected_markup = '
The HTML tag <br> produces a line break.
';
- $this->assertSame( $expected_markup, $tags->get_updated_html() );
- $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-text directive changed context' );
- }
-
- public function test_directive_overwrites_inner_html_based_on_attribute_value() {
- $markup = '
Lorem ipsum dolor sit.
';
-
- $tags = new WP_Directive_Processor( $markup );
- $tags->next_tag();
-
- $context_before = new WP_Directive_Context( array( 'myblock' => array( 'someText' => 'Honi soit qui mal y pense.' ) ) );
- $context = clone $context_before;
- gutenberg_interactivity_process_wp_text( $tags, $context, 'myblock' );
-
- $expected_markup = '
Honi soit qui mal y pense.
';
- $this->assertSame( $expected_markup, $tags->get_updated_html() );
- $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-text directive changed context' );
- }
-}
diff --git a/phpunit/experimental/modules/class-gutenberg-modules-test.php b/phpunit/experimental/modules/class-gutenberg-modules-test.php
deleted file mode 100644
index a7f6c3b491a53d..00000000000000
--- a/phpunit/experimental/modules/class-gutenberg-modules-test.php
+++ /dev/null
@@ -1,285 +0,0 @@
-registered = new ReflectionProperty( 'Gutenberg_Modules', 'registered' );
- $this->registered->setAccessible( true );
- $this->old_registered = $this->registered->getValue();
- $this->registered->setValue( array() );
- }
-
- public function tear_down() {
- $this->registered->setValue( $this->old_registered );
- parent::tear_down();
- }
-
- public function get_enqueued_modules() {
- $modules_markup = get_echo( array( 'Gutenberg_Modules', 'print_enqueued_modules' ) );
- $p = new WP_HTML_Tag_Processor( $modules_markup );
- $enqueued_modules = array();
-
- while ( $p->next_tag(
- array(
- 'tag' => 'SCRIPT',
- 'type' => 'module',
- )
- ) ) {
- $enqueued_modules[ $p->get_attribute( 'id' ) ] = $p->get_attribute( 'src' );
- }
-
- return $enqueued_modules;
- }
-
- public function get_import_map() {
- $import_map_markup = get_echo( array( 'Gutenberg_Modules', 'print_import_map' ) );
- preg_match( '/