diff --git a/docs/brainstorming/template-compile.md b/docs/brainstorming/template-compile.md new file mode 100644 index 0000000000000..908c95a82138f --- /dev/null +++ b/docs/brainstorming/template-compile.md @@ -0,0 +1,21 @@ +# Template Compile Mode + +**Date:** 2026-02-04 +**Status:** Brainstorming + +--- + +## Two Modes of Template Usage + +### 1. Flush Mode (current) +- One-shot rendering, like `::sprintf` +- Parse and render in a single pass + +### 2. Compile Mode (new idea) +- A `compile()` method that: + - Captures placeholder offsets + - Returns a compiled/prepared template + - Accepts an array of replacements at render time + - Can be rendered multiple times efficiently from a single compilation + +**Use case:** When you need to render the same template structure repeatedly with different data, avoid re-parsing the template each time. diff --git a/docs/brainstorming/template-html-context.md b/docs/brainstorming/template-html-context.md new file mode 100644 index 0000000000000..c0691b0913335 --- /dev/null +++ b/docs/brainstorming/template-html-context.md @@ -0,0 +1,17 @@ +# Template HTML Context + +**Date:** 2026-02-04 +**Status:** Brainstorming + +--- + +## HTML Context Constraints + +### Problem +The HTML processor cannot normalize certain templates like `` because `` cannot exist outside of a `` element. Context matters for valid HTML. + +### Potential Solutions +- Use HTML processor instead of TAG processor +- Add private methods for rendering nested templates +- Processor creates a **fragment parser** at the template replacement location + - Fragment parser handles the child template with proper parent context diff --git a/docs/plans/2026-02-04-template-api-redesign-v2.md b/docs/plans/2026-02-04-template-api-redesign-v2.md new file mode 100644 index 0000000000000..75621fb65d1fe --- /dev/null +++ b/docs/plans/2026-02-04-template-api-redesign-v2.md @@ -0,0 +1,125 @@ +# WP_HTML_Template API Redesign v2 + +**Date:** 2026-02-04 +**Status:** Design Complete + +--- + +## Overview + +A single-class template API with strict error handling. Parse once, bind values, render on demand. + +## Essential Interface + +```php +class WP_HTML_Template { + public static function from(string $template): static; + public function bind(array $replacements): static; + public function render(): string|false; +} +``` + +### Methods + +**`from(string $template): static`** + +Creates a template from a string. + +**`bind(array $replacements): static`** + +Returns a new immutable instance with replacements bound. + +**`render(): string|false`** + +Renders the template to an HTML string. Returns `false` on any error. + +## Usage Patterns + +### One-shot rendering + +```php +$html = WP_HTML_Template::from('

Hello, !

') + ->bind(['name' => 'World']) + ->render(); +``` + +### Nested templates (HTML injection) + +A template instance can be used as a replacement value. It will be rendered and injected as HTML (not escaped). + +```php +$html = WP_HTML_Template::from('
') + ->bind([ + 'icon' => WP_HTML_Template::from(''), + 'message' => 'Something went wrong.', + ]) + ->render(); +``` + +### Nested templates with their own placeholders + +```php +$html = WP_HTML_Template::from('
') + ->bind([ + 'content' => WP_HTML_Template::from('') + ->bind(['url' => '/help', 'text' => 'Learn more']), + ]) + ->render(); +``` + +### Loop rendering + +Parse once, bind and render with different values each iteration. + +```php +$item_template = WP_HTML_Template::from('
  • '); + +foreach ($items as $item) { + echo $item_template->bind(['item' => $item])->render(); +} +``` + +## Replacement Values + +| Replacement Type | Text Context | Attribute Context | +|------------------|--------------|-------------------| +| String | Escaped | Escaped | +| WP_HTML_Template | Rendered (HTML preserved) | `false` (error) | + +## Error Handling + +All error conditions cause `render()` to return `false`: + +| Condition | Result | +|-----------|--------| +| Missing replacement key | `false` | +| Unused replacement key | `false` | +| Template in attribute context | `false` | +| HTML processing/normalization failure | `false` | + +Replacements must match placeholders exactly—no more, no less. + +## Immutability + +`bind()` always returns a new instance. Templates are safe to reuse: + +```php +$tpl = WP_HTML_Template::from('

    '); + +$a = $tpl->bind(['text' => 'Hello']); +$b = $tpl->bind(['text' => 'World']); + +// $a and $b are independent +echo $a->render(); //

    Hello

    +echo $b->render(); //

    World

    +``` + +## Migration from Current API + +| Old | New | +|-----|-----| +| `T::sprintf($tpl, $r)` | `T::from($tpl)->bind($r)->render()` | +| `T::from($tpl, $r)->render()` | `T::from($tpl)->bind($r)->render()` | +| `T::from($tpl)->render($r)` | `T::from($tpl)->bind($r)->render()` | + +Breaking changes are acceptable since this is pre-release code (`@since 7.0.0`). diff --git a/docs/plans/2026-02-04-template-api-redesign.md b/docs/plans/2026-02-04-template-api-redesign.md new file mode 100644 index 0000000000000..874815b42399d --- /dev/null +++ b/docs/plans/2026-02-04-template-api-redesign.md @@ -0,0 +1,153 @@ +# WP_HTML_Template API Redesign + +**Date:** 2026-02-04 +**Status:** Design Complete + +--- + +## Overview + +Redesign the `WP_HTML_Template` API to support efficient repeated rendering. The key insight: parse once, bind many times, render on demand. + +## Classes + +### WP_HTML_Template + +The parsed template. Reusable across multiple bind operations. + +```php +class WP_HTML_Template { + public static function render(string $template, array $replacements): string|false; + public static function from(string $template): self; + public function bind(array $replacements): WP_HTML_Bound_Template; +} +``` + +### WP_HTML_Bound_Template + +A template with replacements bound. Ready to render. + +```php +class WP_HTML_Bound_Template { + public function render(): string|false; +} +``` + +## Usage Patterns + +### One-shot rendering + +For simple cases where you render once: + +```php +echo WP_HTML_Template::render('

    ', ['name' => 'Alice']); +``` + +### Reusable templates + +For loops or repeated use: + +```php +$template = WP_HTML_Template::from('
  • '); + +foreach ($items as $item) { + echo $template->bind(['item' => $item])->render(); +} +``` + +### Nested templates + +Templates can contain other bound templates as replacements: + +```php +$icon = WP_HTML_Template::from(''); +$message = WP_HTML_Template::from('
    '); + +echo $message->bind([ + 'icon' => $icon->bind(['class' => 'warning']), + 'text' => 'Something went wrong', +])->render(); +``` + +## Replacement Values + +Valid replacement types depend on context: + +| Context | String | WP_HTML_Bound_Template | +|-----------|--------|------------------------| +| Text | Yes | Yes | +| Attribute | Yes | No (error) | + +## Lazy Compilation + +`from()` stores the template string but does not parse it immediately. Compilation happens on the first `bind()` call and is cached for subsequent calls. + +```php +// Just stores the string +$template = WP_HTML_Template::from('

    '); + +// First bind() triggers compilation, caches result +$bound1 = $template->bind(['name' => 'Alice']); + +// Subsequent bind() reuses cached compilation +$bound2 = $template->bind(['name' => 'Bob']); +``` + +## Compilation Output + +Compilation produces a list of placeholders with their positions and contexts: + +```php +[ + 'template' => '

    ', + 'placeholders' => [ + 'class' => ['start' => 11, 'length' => 10, 'context' => 'attribute'], + 'content' => ['start' => 23, 'length' => 12, 'context' => 'text'], + ], +] +``` + +This allows `bind()` to validate replacements and `render()` to efficiently build the output string. + +## Error Handling + +Validation occurs at `bind()` time using the compiled placeholder information: + +### Missing key + +```php +$t = WP_HTML_Template::from('

    '); +$t->bind(['name' => 'Alice']); +// Warning: Missing replacement key 'age' +``` + +Rendering continues with missing placeholders removed. + +### Unused key + +```php +$t->bind(['name' => 'Alice', 'age' => '30', 'extra' => 'ignored']); +// Warning: Unused replacement key 'extra' +``` + +Rendering continues with unused keys ignored. + +### Wrong type for attribute + +```php +$t = WP_HTML_Template::from('

    text

    '); +$t->bind(['class' => $otherTemplate->bind([...])]); +// Warning: Replacement 'class' must be a string (attribute context) +``` + +`render()` returns `false`. + +## Migration from Current API + +| Old | New | +|-----|-----| +| `T::sprintf($tpl, $r)` | `T::render($tpl, $r)` | +| `T::from($tpl, $r)->render()` | `T::from($tpl)->bind($r)->render()` | +| `T::from($tpl)->render($r)` | `T::from($tpl)->bind($r)->render()` | + +Breaking changes are acceptable since this is pre-release code (`@since 7.0.0`). diff --git a/docs/plans/2026-02-06-unified-edits-array-design.md b/docs/plans/2026-02-06-unified-edits-array-design.md new file mode 100644 index 0000000000000..c5752fd227061 --- /dev/null +++ b/docs/plans/2026-02-06-unified-edits-array-design.md @@ -0,0 +1,106 @@ +# Unified Edits Array Design + +## Problem + +`WP_HTML_Template` currently uses three separate arrays to track compiled data: + +- `$compiled` — placeholder offsets, keyed by name, with context +- `$text_normalizations` — `[start, length, normalized_text]` tuples +- `$attr_escapes` — `[start, length]` tuples (computed at render time) + +These serve similar purposes (tracking spans to replace) but have different shapes and processing logic. The render method builds an intermediate `$updates` array from all three, sorts it, then applies replacements. + +Additionally, `attr_escapes` defers computation to render time unnecessarily — the template string is immutable, so decode→re-encode can happen at compile time. + +## Design + +Replace all three arrays with two: + +### `$edits` — unified edit list + +A flat array of edit operations, naturally ordered by ascending offset (the processor walks the document linearly). Each entry is one of: + +**Pre-computed replacement** (normalizations and escapes): +```php +['start' => 100, 'length' => 5, 'replacement' => '&'] +``` + +**Placeholder reference** (needs render-time lookup): +```php +['start' => 200, 'length' => 12, 'placeholder' => 'username', 'context' => 'text'] +``` + +The `context` field is `'text'` or `'attribute'`, used to validate that templates aren't inserted into attribute values. + +### `$placeholder_names` — validation index + +A set of placeholder names for O(1) lookup during `bind()`: + +```php +['username' => true, 'title' => true] +``` + +This replaces the current pattern of iterating `$compiled` to build a lookup on every `bind()` call. + +## Compile-time changes + +1. **Pre-compute attribute escapes**: Move the decode→re-encode logic from `render()` to `compile()`. Store only if the result differs from the original span (same redundancy check as text normalizations). + +2. **Single array population**: As the processor encounters normalizations, escapes, or placeholders, append to `$edits` in document order. + +3. **Build placeholder index**: When encountering a placeholder, also add its name to `$placeholder_names`. + +## Render-time changes + +The render loop becomes a single reverse iteration: + +```php +foreach (array_reverse($this->edits) as $edit) { + if (isset($edit['placeholder'])) { + // Look up replacement value + // Validate template-in-attribute + // Escape and apply + } else { + // Pre-computed, apply directly + $html = substr_replace($html, $edit['replacement'], $edit['start'], $edit['length']); + } +} +``` + +No intermediate `$updates` array. No `usort()`. + +## Bind-time changes + +Validation uses `$placeholder_names` directly instead of building a lookup: + +```php +// Check for missing keys +foreach ($this->placeholder_names as $name => $_) { + // ... validate $name exists in $replacements +} + +// Check for unused keys +foreach ($replacements as $key => $_) { + // ... validate $key exists in $placeholder_names +} +``` + +## Invariants + +- Offsets in `$edits` are strictly ascending (document order) +- No overlapping spans +- Entries have either `replacement` (pre-computed) or `placeholder` + `context` (render-time) +- Every name in an edit with `placeholder` key exists in `$placeholder_names` + +## Trade-offs + +**Gains:** +- Three properties → two properties +- Render: three foreach loops + sort → one reverse iteration +- Attribute escapes pre-computed (less render-time work) +- Redundant escapes filtered out (fewer edits when template is well-formed) +- Clearer mental model: "edits" are things to replace, "placeholder_names" is the validation index + +**Costs:** +- Slightly more memory per placeholder entry (storing `context` per offset instead of once per name) +- Loses grouping by placeholder name (negligible — hash lookups are cheap) diff --git a/src/wp-includes/html-api/class-wp-html-tag-processor.php b/src/wp-includes/html-api/class-wp-html-tag-processor.php index 69e3e5d2c7557..214e06502260a 100644 --- a/src/wp-includes/html-api/class-wp-html-tag-processor.php +++ b/src/wp-includes/html-api/class-wp-html-tag-processor.php @@ -708,7 +708,7 @@ class WP_HTML_Tag_Processor { * @since 6.2.0 * @var WP_HTML_Attribute_Token[] */ - private $attributes = array(); + protected $attributes = array(); /** * Tracks spans of duplicate attributes on a given tag, used for removing diff --git a/src/wp-includes/html-api/class-wp-html-template.php b/src/wp-includes/html-api/class-wp-html-template.php new file mode 100644 index 0000000000000..033ea88e83a77 --- /dev/null +++ b/src/wp-includes/html-api/class-wp-html-template.php @@ -0,0 +1,470 @@ + int, 'length' => int, 'replacement' => string] + * + * Placeholder reference (render-time lookup): + * ['start' => int, 'length' => int, 'placeholder' => string, 'context' => 'text'|'attribute'] + * + * @since 7.0.0 + * @var array + */ + private array $edits = array(); + + /** + * Placeholder names for O(1) validation. + * + * @since 7.0.0 + * @var array + */ + private array $placeholder_names = array(); + + /** + * Returns the compiled placeholder metadata. + * + * Derives the grouped structure from $edits on-demand. + * If a placeholder appears in both text and attribute contexts, + * the attribute context takes precedence (more restrictive). + * + * @since 7.0.0 + * + * @return array Associative array of placeholder_name => metadata. + */ + public function get_placeholders(): array { + $this->compile(); + + $result = array(); + + foreach ( $this->edits as $edit ) { + if ( ! isset( $edit['placeholder'] ) ) { + continue; + } + + $placeholder = $edit['placeholder']; + $context = $edit['context']; + + if ( ! isset( $result[ $placeholder ] ) ) { + $result[ $placeholder ] = array( + 'offsets' => array(), + 'context' => $context, + ); + } else { + // Promote text context to attribute context. + if ( 'attribute' === $context ) { + $result[ $placeholder ]['context'] = 'attribute'; + } + } + + $result[ $placeholder ]['offsets'][] = array( $edit['start'], $edit['length'] ); + } + + return $result; + } + + /** + * Compiles the template to extract placeholder metadata. + * + * Parses the template once and caches placeholder positions, lengths, + * and contexts. If a placeholder appears in both text and attribute + * contexts, the attribute context takes precedence (more restrictive). + * + * @since 7.0.0 + */ + private function compile(): void { + if ( $this->is_compiled ) { + return; + } + + $this->is_compiled = true; + $this->edits = array(); + $this->placeholder_names = array(); + + $processor = ( new class( '', WP_HTML_Processor::CONSTRUCTOR_UNLOCK_CODE ) extends WP_HTML_Processor { + public function get_html(): string { + return $this->html; + } + + public function get_bookmark( string $name ) { + return $this->bookmarks[ "_{$name}" ] ?? null; + } + + public function get_tag_attributes(): array { + return $this->attributes; + } + } )::create_fragment( $this->template_string ); + + if ( null === $processor ) { + return; + } + + while ( $processor->next_token() ) { + switch ( $processor->get_token_type() ) { + /* + * Track text normalizations to prevent something like + * `a<u` from becoming `au` after replacement. + */ + case '#text': + $processor->set_bookmark( 'text' ); + $mark = $processor->get_bookmark( 'text' ); + if ( null === $mark ) { + break; + } + $normalized = $processor->serialize_token(); + if ( 0 !== substr_compare( $processor->get_html(), $normalized, $mark->start, $mark->length ) ) { + $this->edits[] = array( + 'start' => $mark->start, + 'length' => $mark->length, + 'replacement' => $normalized, + ); + } + break; + + case '#funky-comment': + $processor->set_bookmark( 'placeholder' ); + $mark = $processor->get_bookmark( 'placeholder' ); + if ( null === $mark ) { + break; + } + + $start = $mark->start; + $length = $mark->length; + $html = $processor->get_html(); + + // Must be at least `` (5 chars) and start with `edits[] = array( + 'start' => $start, + 'length' => $length, + 'placeholder' => $placeholder, + 'context' => 'text', + ); + $this->placeholder_names[ $placeholder ] = true; + break; + + case '#tag': + if ( $processor->is_tag_closer() ) { + break; + } + + $html = $processor->get_html(); + foreach ( $processor->get_tag_attributes() as $attribute ) { + // Boolean attributes cannot contain placeholders. + if ( $attribute->is_true ) { + continue; + } + // At least `` to contain a placeholder. + if ( $attribute->value_length < 5 ) { + continue; + } + + $last_offset = $attribute->value_starts_at; + $offset = $attribute->value_starts_at; + $end = $offset + $attribute->value_length; + + while ( + 1 === preg_match( + '##i', + $html, + $matches, + PREG_OFFSET_CAPTURE, + $offset + ) + && $matches[0][1] < $end + ) { + $placeholder = $matches[1][0]; + $match_start = $matches[0][1]; + $match_length = strlen( $matches[0][0] ); + + // Pre-compute escape for text segment before this placeholder. + if ( $match_start > $last_offset ) { + $seg_length = $match_start - $last_offset; + $original = substr( $html, $last_offset, $seg_length ); + $decoded = WP_HTML_Decoder::decode_attribute( $original ); + $escaped = strtr( + $decoded, + array( + '&' => '&', + '<' => '<', + '>' => '>', + "'" => ''', + '"' => '"', + ) + ); + // Only add edit if escaping actually changes the text. + if ( $escaped !== $original ) { + $this->edits[] = array( + 'start' => $last_offset, + 'length' => $seg_length, + 'replacement' => $escaped, + ); + } + } + + // New: append placeholder edit and register name. + $this->edits[] = array( + 'start' => $match_start, + 'length' => $match_length, + 'placeholder' => $placeholder, + 'context' => 'attribute', + ); + $this->placeholder_names[ $placeholder ] = true; + + $last_offset = $match_start + $match_length; + $offset = $last_offset; + } + + // Pre-compute escape for trailing text segment after last placeholder. + if ( $last_offset < $end ) { + $seg_length = $end - $last_offset; + $original = substr( $html, $last_offset, $seg_length ); + $decoded = WP_HTML_Decoder::decode_attribute( $original ); + $escaped = strtr( + $decoded, + array( + '&' => '&', + '<' => '<', + '>' => '>', + "'" => ''', + '"' => '"', + ) + ); + // Only add edit if escaping actually changes the text. + if ( $escaped !== $original ) { + $this->edits[] = array( + 'start' => $last_offset, + 'length' => $seg_length, + 'replacement' => $escaped, + ); + } + } + } + break; + } + } + } + + private function __construct( string $template_string, array $replacements ) { + $this->template_string = $template_string; + $this->replacements = $replacements; + } + + /** + * Creates a template from a string. + * + * @since 7.0.0 + * + * @param string $template The template string with placeholders. + * @return static The template instance. + */ + public static function from( string $template ): static { + return new static( $template, array() ); + } + + /** + * Returns a new immutable instance with replacements bound. + * + * Triggers compilation if not already done. Validates replacements: + * - Warns if a placeholder has no corresponding replacement + * - Warns if a replacement key has no corresponding placeholder + * - Warns if a template is used in attribute context + * + * @since 7.0.0 + * + * @param array $replacements The replacement values. + * @return static A new template instance with the replacements bound. + */ + public function bind( array $replacements ): static { + $this->compile(); + + // Check for missing keys (placeholder without replacement). + foreach ( $this->placeholder_names as $placeholder => $_ ) { + $placeholder = (string) $placeholder; + $found = array_key_exists( $placeholder, $replacements ); + if ( ! $found && ctype_digit( $placeholder ) ) { + $found = array_key_exists( (int) $placeholder, $replacements ); + } + if ( ! $found ) { + _doing_it_wrong( + __METHOD__, + sprintf( + 'Missing replacement for placeholder: %s', + $placeholder + ), + '7.0.0' + ); + } + } + + // Check for unused keys (replacement without placeholder). + foreach ( $replacements as $key => $value ) { + $str_key = (string) $key; + $found = isset( $this->placeholder_names[ $key ] ) || isset( $this->placeholder_names[ $str_key ] ); + if ( ! $found ) { + _doing_it_wrong( + __METHOD__, + sprintf( + 'Unused replacement key: %s', + $key + ), + '7.0.0' + ); + } + } + + // Check for templates in attribute context. + foreach ( $this->edits as $edit ) { + if ( ! isset( $edit['placeholder'] ) || 'attribute' !== $edit['context'] ) { + continue; + } + + $placeholder = $edit['placeholder']; + $key = ctype_digit( $placeholder ) ? (int) $placeholder : $placeholder; + $value = $replacements[ $key ] ?? $replacements[ $placeholder ] ?? null; + + if ( $value instanceof self ) { + _doing_it_wrong( + __METHOD__, + sprintf( + 'Template cannot be used in attribute context: %s', + $placeholder + ), + '7.0.0' + ); + break; // Only warn once per placeholder name. + } + } + + $new = new static( $this->template_string, $replacements ); + $new->is_compiled = $this->is_compiled; + $new->edits = $this->edits; + $new->placeholder_names = $this->placeholder_names; + return $new; + } + + /** + * Renders the template to an HTML string. + * + * Uses pre-compiled placeholder metadata to perform replacements + * without re-parsing. Collects all updates (placeholder replacements, + * text normalizations, attribute text escaping) and applies them + * from end to start using substr_replace() to preserve positions. + * + * Returns false on any error: + * - Missing replacement key (placeholder without corresponding replacement) + * - Unused replacement key (replacement without corresponding placeholder) + * - Template in attribute context + * - HTML processing/normalization failure + * + * @since 7.0.0 + * + * @return string|false The rendered HTML, or false on error. + */ + public function render(): string|false { + $this->compile(); + + if ( empty( $this->replacements ) ) { + return WP_HTML_Processor::normalize( $this->template_string ) ?? $this->template_string; + } + + $escape_map = array( + '&' => '&', + '<' => '<', + '>' => '>', + "'" => ''', + '"' => '"', + ); + + $html = $this->template_string; + $used_keys = array(); + + // Process edits in reverse order (end to start) to preserve positions. + foreach ( array_reverse( $this->edits ) as $edit ) { + if ( isset( $edit['placeholder'] ) ) { + // Placeholder: look up replacement value. + $placeholder = $edit['placeholder']; + + if ( array_key_exists( $placeholder, $this->replacements ) ) { + $key = $placeholder; + } elseif ( ctype_digit( $placeholder ) && array_key_exists( (int) $placeholder, $this->replacements ) ) { + $key = (int) $placeholder; + } else { + return false; + } + + $used_keys[ $key ] = true; + $value = $this->replacements[ $key ]; + + if ( $value instanceof self ) { + if ( 'attribute' === $edit['context'] ) { + return false; + } + + $rendered = $value->render(); + if ( false === $rendered ) { + return false; + } + + $html = substr_replace( $html, $rendered, $edit['start'], $edit['length'] ); + } elseif ( is_string( $value ) ) { + $escaped = strtr( $value, $escape_map ); + $html = substr_replace( $html, $escaped, $edit['start'], $edit['length'] ); + } else { + return false; + } + } else { + // Pre-computed replacement: apply directly. + $html = substr_replace( $html, $edit['replacement'], $edit['start'], $edit['length'] ); + } + } + + // Return false if any replacement key was not used. + if ( count( $used_keys ) !== count( $this->replacements ) ) { + return false; + } + + return WP_HTML_Processor::normalize( $html ) ?? $html; + } +} diff --git a/src/wp-settings.php b/src/wp-settings.php index 60c220100f539..b80167116f541 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -276,6 +276,7 @@ require ABSPATH . WPINC . '/html-api/class-wp-html-stack-event.php'; require ABSPATH . WPINC . '/html-api/class-wp-html-processor-state.php'; require ABSPATH . WPINC . '/html-api/class-wp-html-processor.php'; +require ABSPATH . WPINC . '/html-api/class-wp-html-template.php'; require ABSPATH . WPINC . '/class-wp-block-processor.php'; require ABSPATH . WPINC . '/class-wp-http.php'; require ABSPATH . WPINC . '/class-wp-http-streams.php'; diff --git a/templating-examples.php b/templating-examples.php new file mode 100644 index 0000000000000..2c9e4e80b83f3 --- /dev/null +++ b/templating-examples.php @@ -0,0 +1,205 @@ +Error: This is not a valid feed template.' ), '', array( 'response' => 404 ) ); + +// src/wp-includes/functions.php:1844-1845 +// Link with placeholder inside translation +$wpdb->error = sprintf( + /* translators: %s: Database repair URL. */ + __( 'One or more database tables are unavailable. The database may need to be repaired.' ), + 'maint/repair.php?referrer=is_blog_installed' +); + +// src/wp-admin/edit-form-advanced.php:185 +// HTML wrapper injected as sprintf parameter +$messages['post'][9] = sprintf( __( 'Post scheduled for: %s.' ), '' . $scheduled_date . '' ) . $scheduled_post_link_html; + +// src/wp-admin/revision.php:145-147 +// Multiple strong tags for emphasis in help text +$revisions_overview .= ''; + +// src/wp-includes/theme.php:978-979 +// Context translation (_x) with HTML error prefix +return new WP_Error( + 'theme_wp_php_incompatible', + sprintf( + /* translators: %s: Theme name. */ + _x( 'Error: Current WordPress and PHP versions do not meet minimum requirements for %s.', 'theme' ), + $theme->display( 'Name' ) + ) +); + +// src/wp-includes/widgets/class-wp-widget-text.php:542 +// Complex: _e() with embedded link and HTML entities +_e( 'Did you know there is a “Custom HTML” widget now? You can find it by pressing the “Add a Widget” button and searching for “HTML”. Check it out to add some custom code to your site!' ); + +// src/wp-includes/blocks/comments-title.php:29 +// HTML entities (curly quotes) in translation +$post_title = sprintf( __( '“%s”' ), get_the_title() ); + +// src/wp-includes/blocks/latest-posts.php:164-166 +// Complex nested HTML with multiple escaped placeholders +$trimmed_excerpt .= sprintf( + /* translators: 1: A URL to a post, 2: Hidden accessibility text: Post title */ + __( '… Read more: %2$s' ), + esc_url( $post_link ), + esc_html( $title ) +); + +// src/wp-admin/includes/class-plugin-upgrader.php:60 +// HTML span wrapping another placeholder (double sprintf) +$this->strings['downloading_package'] = sprintf( __( 'Downloading update from %s…' ), '%s' ); + +// src/wp-admin/includes/privacy-tools.php:404 +// Code tag wrapping technical reference +sprintf( __( 'The %s post meta must be an array.' ), '_export_data_grouped' ); + +// src/wp-admin/widgets.php:24 +// Full paragraph with embedded documentation link +wp_die( __( 'The theme you are currently using is not widget-aware, meaning that it has no sidebars that you are able to change. For information on making your theme widget-aware, please follow these instructions.' ) ); + +// src/wp-admin/revision.php:158 +// Link in translation without placeholders +$revisions_sidebar .= '

    ' . __( 'Revisions Management' ) . '

    '; + + +/** + * ============================================================================= + * PATTERN 2: sprintf with manual escaping (non-translation) + * ============================================================================= + * + * These patterns require developers to choose the correct escape function + * for each context (esc_url, esc_attr, esc_html). + */ + +// src/wp-includes/blocks/post-title.php:41 +// Link with multiple escaped attributes +$rel = ! empty( $attributes['rel'] ) ? 'rel="' . esc_attr( $attributes['rel'] ) . '"' : ''; +$title = sprintf( + '%4$s', + esc_url( get_the_permalink( $block->context['postId'] ) ), + esc_attr( $attributes['linkTarget'] ), + $rel, // Note: $rel already has esc_attr inside + $title // Note: $title escaping unclear +); + +// src/wp-includes/blocks/avatar.php:68 +// Similar link pattern for avatar +$avatar_block = sprintf( + '%4$s', + esc_url( get_author_posts_url( $author_id ) ), + esc_attr( $attributes['linkTarget'] ), + $label, // aria-label attribute, already escaped + $avatar_block // Inner HTML +); + +// src/wp-includes/formatting.php:3476 +// Image tag with esc_url and esc_attr +return sprintf( + '%s', + esc_url( $src_url ), + esc_attr( $smiley ) +); + +// src/wp-includes/class-wp-styles.php:204-211 +// Link tag with nested sprintf for conditional attribute +$tag = sprintf( + "\n", + $rel, // Not escaped + esc_attr( $handle ), + $title ? sprintf( " title='%s'", esc_attr( $title ) ) : '', + $href, // Not escaped + esc_attr( $media ) +); + + +/** + * ============================================================================= + * PATTERN 3: Building aria-label attributes + * ============================================================================= + */ + +// src/wp-includes/blocks/avatar.php:65 +// Aria label with translation and escaping +$label = 'aria-label="' . esc_attr( sprintf( __( '(%s author archive, opens in a new tab)' ), $author_name ) ) . '"'; + + +/** + * ============================================================================= + * POTENTIAL TEMPLATING ALTERNATIVES + * ============================================================================= + * + * How these patterns could look with WP_HTML_Template. + */ + +/* + * OPTION A: Template wraps translation (structure in code, text translated) + * + * HTML structure stays in PHP code, only text content is translated. + */ + +// Instead of: __( 'Error: This is not a valid feed template.' ) +WP_HTML_Template::render( + ' ', + array( + 'label' => __( 'Error:' ), + 'message' => __( 'This is not a valid feed template.' ), + ) +); + +// Instead of: sprintf( __( 'Post scheduled for: %s.' ), '' . $date . '' ) +WP_HTML_Template::render( + __( 'Post scheduled for: %s.', 'template-placeholder' ), // Special handling? + // Or: + '', + array( + 'before' => __( 'Post scheduled for: ' ), + 'date' => $scheduled_date, + 'after' => '', + ) +); + +/* + * OPTION B: Translation contains template placeholders + * + * Translated string includes the syntax directly. + * This is closer to how WordPress translations work today. + */ + +// Instead of: sprintf( __( '... repaired.' ), $url ) +WP_HTML_Template::render( + __( 'One or more database tables are unavailable. The database may need to be repaired.' ), + array( 'url' => 'maint/repair.php?referrer=is_blog_installed' ) +); + +// Instead of: sprintf( __( '... href="%1$s" ... %2$s ...' ), esc_url($url), esc_html($title) ) +WP_HTML_Template::render( + __( '… Read more: ' ), + array( + 'url' => $post_link, // Auto-escaped as URL in href context + 'title' => $title, // Auto-escaped as text in text context + ) +); diff --git a/tests/phpunit/tests/html-api/wpHtmlTemplate.php b/tests/phpunit/tests/html-api/wpHtmlTemplate.php new file mode 100644 index 0000000000000..dd730a5ba3ab1 --- /dev/null +++ b/tests/phpunit/tests/html-api/wpHtmlTemplate.php @@ -0,0 +1,946 @@ +>s'; + $replacements = array( 'tag-name' => 'i' ); + $result = T::from( $template_string )->bind( $replacements )->render(); + + $expected = 'a<i>s'; + $this->assertEqualHTML( $expected, $result ); + } + + /** + * Verifies that only the first of duplicate attributes is replaced. + * + * Note: Duplicate attributes are stripped per HTML spec, so placeholders + * in duplicate attributes are ignored. Providing a replacement for such + * placeholders would be an unused key error. + * + * @ticket 60229 + * + * @covers ::from + * @covers ::bind + * @covers ::render + */ + public function test_replaces_only_in_first_duplicate_attribute() { + $template_string = ''; + $replacements = array( + 'replace' => 'O', + 'replace-2' => 'K', + ); + + $result = T::from( $template_string )->bind( $replacements )->render(); + + $expected = ''; + $this->assertEqualHTML( $expected, $result ); + } + + /** + * Verifies that attribute replacement is not recursive. + * + * @ticket 60229 + * + * @covers ::from + * @covers ::bind + * @covers ::render + */ + public function test_attribute_replacement_is_not_recursive() { + $template_string = '
    '; + $replacements = array( + 'replace' => '', + ); + + $result = T::from( $template_string )->bind( $replacements )->render(); + + $expected = '
    </%replace>
    '; + $this->assertEqualHTML( $expected, $result ); + } + + /** + * Verifies that placeholder names allow surrounding whitespace. + * + * @ticket 60229 + * + * @covers ::from + * @covers ::bind + * @covers ::render + */ + public function test_placeholder_names_allow_surrounding_whitespace() { + $template_string = ""; + $replacements = array( + 'n' => 'the name', + 'c' => 'the "content" & whatever else', + ); + + $result = T::from( $template_string )->bind( $replacements )->render(); + + $expected = + <<<'HTML' + + HTML; + $this->assertEqualHTML( $expected, $result ); + } + + /** + * Verifies that ampersands are escaped to prevent character reference injection. + * + * @ticket 60229 + * + * @covers ::from + * @covers ::bind + * @covers ::render + */ + public function test_escapes_ampersand_to_prevent_character_reference_injection() { + $template_string = ''; + $replacements = array( 'placeholder' => 'not' ); + $result = T::from( $template_string )->bind( $replacements )->render(); + + $expected = + <<<'HTML' + + HTML; + $this->assertEqualHTML( $expected, $result ); + } + + /** + * Verifies that nested templates are rejected in attribute values. + * + * @ticket 60229 + * + * @covers ::from + * @covers ::bind + * @covers ::render + * + * @expectedIncorrectUsage WP_HTML_Template::bind + */ + public function test_rejects_nested_template_in_attribute_value() { + $template_string = ''; + $replacements = array( + 'html' => T::from( 'This is not allowed!' ), + ); + $this->assertFalse( T::from( $template_string )->bind( $replacements )->render() ); + } + + /** + * @dataProvider data_template + * + * @ticket 60229 + * + * @covers ::from + * @covers ::bind + * @covers ::render + */ + public function test_template( string $template_string, array $replacements, string $expected ) { + $result = T::from( $template_string )->bind( $replacements )->render(); + $this->assertEqualHTML( $expected, $result ); + } + + public static function data_template() { + return array( + 'basic template (no placeholders)' => array( + '

    Hi!

    ', + array(), + '

    Hi!

    ', + ), + + 'basic text replacement' => array( + '

    Hello, !

    ', + array( 'name' => 'World' ), + '

    Hello, World!

    ', + ), + + 'escapes special characters in text' => array( + '

    Hello, !

    ', + array( 'placeholder' => 'Alice & Bob' ), + '

    Hello, Alice & Bob!

    ', + ), + + 'escapes angle brackets in text' => array( + '

    Hello, !

    ', + array( 'name' => '' ), + '

    Hello, <little-bobby-tags>!

    ', + ), + + 'numeric placeholders' => array( + '

    Hello, and !

    ', + array( 'Alice', 'Bob' ), + '

    Hello, Alice and Bob!

    ', + ), + + 'repeated placeholders' => array( + '

    , , , & !

    ', + array( + 'Alice', + 'name' => 'Bob', + ), + '

    Alice, Alice, Bob, & Bob!

    ', + ), + + 'nested template replacement' => array( + '

    Hello, ', + array( 'html' => WP_HTML_Template::from( 'Alice & Bob' ) ), + '

    Hello, Alice & Bob

    ', + ), + + 'replaces attribute values' => array( + '', + array( + 'n' => 'the name', + 'c' => 'the content', + ), + '', + ), + + 'escapes attribute values' => array( + '', + array( + 'c' => 'the "content" & whatever else', + ), + '', + ), + ); + } + + /** + * Test real-world patterns from WordPress core. + * + * @dataProvider data_real_world_examples + * + * @ticket 60229 + * + * @covers ::from + * @covers ::bind + * @covers ::render + */ + public function test_real_world_examples( string $template_string, array $replacements, string $expected ) { + $result = WP_HTML_Template::from( $template_string )->bind( $replacements )->render(); + $this->assertEqualHTML( $expected, $result ); + } + + /** + * Data provider with real-world patterns from WordPress core. + * + * Each test case is based on actual code patterns found in WordPress core + * that could benefit from the WP_HTML_Template API. + * + * @return array[] + */ + public static function data_real_world_examples() { + /* + * Group 1: Simple sprintf patterns with manual escaping. + * + * These patterns currently require developers to manually choose + * the correct escape function (esc_url, esc_attr, esc_html). + */ + + // src/wp-includes/formatting.php:3476 - Smiley image + yield 'formatting.php:3476 - smiley image tag' => array( + <<<'HTML' + </%alt> + HTML, + array( + 'src' => 'https://example.com/smilies/:).png', + 'alt' => ':)', + ), + <<<'HTML' + :) + HTML, + ); + + // src/wp-includes/blocks/post-title.php:41 - Post title link + yield 'blocks/post-title.php:41 - post title link' => array( + '', + array( + 'url' => 'https://example.com/hello-world/', + 'target' => '_blank', + 'title' => 'Hello World', + ), + 'Hello World', + ); + + // Same pattern with escaping needed + yield 'blocks/post-title.php:41 - post title link with special chars' => array( + "\n\n", + array( + 'url' => 'https://example.com/hello-world/?foo=1&bar=2', + 'target' => '_blank', + 'title' => WP_HTML_Template::from( '\'\' & ""' ) + ->bind( + array( + 'italic' => 'This', + 'bold' => 'That', + ) + ), + ), + <<<'HTML' + + 'This' & "That" + + HTML, + ); + + /* + * Group 2: Translation patterns with embedded HTML. + * + * These patterns have HTML directly in translatable strings. + */ + + // src/wp-includes/functions.php:1620 - Error message (static, no placeholders) + yield 'functions.php:1620 - static error message' => array( + 'Error: This is not a valid feed template.', + array(), + 'Error: This is not a valid feed template.', + ); + + // src/wp-includes/functions.php:1844-1845 - Database repair link + yield 'functions.php:1844 - database repair link' => array( + <<<'HTML' + One or more database tables are unavailable. The database may need to be repaired. + HTML, + array( + 'url' => 'maint/repair.php?referrer=is_blog_installed', + ), + <<<'HTML' + One or more database tables are unavailable. The database may need to be repaired. + HTML, + ); + + // src/wp-admin/edit-form-advanced.php:185 - Scheduled post date + yield 'edit-form-advanced.php:185 - scheduled post date' => array( + 'Post scheduled for: .', + array( + 'date' => 'March 15, 2025 at 10:30 am', + ), + 'Post scheduled for: March 15, 2025 at 10:30 am.', + ); + + // src/wp-includes/blocks/latest-posts.php:164-166 - Read more link with nested elements + yield 'blocks/latest-posts.php:164 - read more link with screen reader text' => array( + <<<'HTML' + … Read more: + HTML, + array( + 'url' => 'https://example.com/my-post/', + 'title' => 'My Amazing Post', + ), + <<<'HTML' + … Read more: My Amazing Post + HTML, + ); + + // Same pattern with escaping needed + yield 'blocks/latest-posts.php:164 - read more with XSS attempt' => array( + <<<'HTML' + … Read more: + HTML, + array( + 'url' => 'javascript:alert("xss")', + 'title' => '', + ), + <<<'HTML' + … Read more: <script>alert("xss")</script> + HTML, + ); + + // src/wp-includes/theme.php:978-979 - Theme error with name + yield 'theme.php:978 - theme error message' => array( + <<<'HTML' + Error: Current WordPress and PHP versions do not meet minimum requirements for . + HTML, + array( + 'theme_name' => 'Twenty Twenty-Five', + ), + <<<'HTML' + Error: Current WordPress and PHP versions do not meet minimum requirements for Twenty Twenty-Five. + HTML, + ); + + // src/wp-admin/includes/privacy-tools.php:404 - Code tag in error + yield 'privacy-tools.php:404 - code in error message' => array( + 'The post meta must be an array.', + array( + 'meta_key' => '_export_data_grouped', + ), + 'The _export_data_grouped post meta must be an array.', + ); + + /* + * Group 3: Edge cases. + */ + + // Placeholder reuse (same placeholder multiple times) + yield 'placeholder reuse' => array( + ' ', + array( + 'id' => 'user_name', + ), + ' ', + ); + + // Numeric placeholders like sprintf + yield 'numeric placeholders' => array( + ' by ', + array( + 'https://example.com/post/', + 'Post Title', + 'https://example.com/author/', + 'Author Name', + ), + 'Post Title by Author Name', + ); + + // Nested template (pre-escaped HTML) + yield 'nested template for complex structure' => array( + '
    ', + array( + 'icon' => WP_HTML_Template::from( '' ), + 'message' => 'Something went wrong.', + ), + '
    Something went wrong.
    ', + ); + + // Empty replacement + yield 'empty replacement value' => array( + '

    Hello

    ', + array( + 'suffix' => '', + ), + '

    Hello

    ', + ); + + // HTML entities in template (should be preserved) + yield 'HTML entities in template' => array( + '

    ', + array( + 'quote' => 'Hello World', + ), + '

    “Hello World”

    ', + ); + + // Multiple attributes on same element + yield 'multiple attributes on element' => array( + '', + array( + 'type' => 'text', + 'name' => 'user_email', + 'value' => 'test@example.com', + 'placeholder' => 'Enter your email', + ), + '', + ); + + // Attribute value with quotes and special characters + yield 'attribute with quotes and ampersands' => array( + 'Link', + array( + 'url' => 'https://example.com/?a=1&b=2', + 'title' => <<<'TEXT' + Click "here" for Tom & Jerry + TEXT, + ), + <<<'HTML' + Link + HTML, + ); + + // Self-closing void element + yield 'self-closing meta tag' => array( + '', + array( + 'name' => 'description', + 'content' => <<<'TEXT' + A page about "cats" & dogs + TEXT, + ), + <<<'HTML' + + HTML, + ); + + // src/wp-includes/blocks/avatar.php:68 - Complex link with aria-label + yield 'blocks/avatar.php:68 - avatar link' => array( + <<<'HTML' + + HTML, + array( + 'url' => 'https://example.com/author/johndoe/', + 'target' => '_blank', + 'aria_label' => '(John Doe author archive, opens in a new tab)', + 'inner' => WP_HTML_Template::from( 'John Doe' ), + ), + <<<'HTML' + John Doe + HTML, + ); + } + + /** + * Verifies nested templates work correctly in a definition list. + * + * @ticket 60229 + * + * @covers ::from + * @covers ::bind + * @covers ::render + */ + public function test_nested_templates_in_definition_list() { + $row_template = WP_HTML_Template::from( "
    \n
    " ); + + $row_replacements = array(); + for ( $i = 1; $i <= 3; $i++ ) { + $row_replacements[ "row-{$i}" ] = $row_template->bind( + array( + 'term' => "Term \"{$i}\"", + 'definition' => WP_HTML_Template::from( 'IYKYK: ' ) + ->bind( + array( + 'i' => (string) $i, + 'expansion' => '"If You Know You Know"', + ) + ), + ) + ); + } + + $result = WP_HTML_Template::from( + <<<'HTML' +
    + + + +
    + HTML + )->bind( $row_replacements )->render(); + + $expected = + <<<'HTML' +
    +
    Term "1"
    +
    IYKYK: 1
    +
    Term "2"
    +
    IYKYK: 2
    +
    Term "3"
    +
    IYKYK: 3
    +
    + HTML; + + $this->assertEqualHTML( $expected, $result ); + } + + /** + * Verifies table templates are not yet supported. + * + * @ticket 60229 + * + * @covers ::from + * @covers ::bind + * @covers ::render + */ + public function test_table_templates_not_yet_supported() { + $this->markTestSkipped( 'IN TABLE templates are not supported yet.' ); + $header_tpl = WP_HTML_Template::from( '
    ' ) + ->bind( + array( + 'ID' => 'ID', + 'name' => 'Name', + 'value' => 'Value', + 'link' => 'Link', + ) + ); + $row_tpl = WP_HTML_Template::from( '
    ' ); + + $row_gen = ( function () { + static $i = 1; + yield array( + 'ID' => $i, + 'name' => 'Name {$i}', + 'value' => WP_HTML_Template::from( 'Value {$i}' )->bind( array( 'i' => $i ) ), + 'link' => WP_HTML_Template::from( '' ) + ->bind( + array( + 'url' => '/example/1', + 'link-name' => 'Click here', + ) + ), + ); + } )(); + + $result = WP_HTML_Template::from( + <<<'HTML' + + + + + + + HTML + )->bind( + array( + 'header' => $header_tpl, + 'row-1' => $row_tpl->bind( $row_gen->next() ), + 'row-2' => $row_tpl->bind( $row_gen->next() ), + 'row-3' => $row_tpl->bind( $row_gen->next() ), + ) + )->render(); + + $expected = + <<<'HTML' + HTML; + $this->assertEqualHTML( $expected, $result ); + } + + /** + * Verifies that attributes are replaced in atomic elements (SCRIPT, STYLE, TITLE). + * + * These elements have special parsing rules that skip their content, + * but attributes should still be processed normally. + * + * @ticket 60229 + * + * @dataProvider data_atomic_element_attributes + * + * @covers ::from + * @covers ::bind + * @covers ::render + */ + public function test_atomic_element_attributes_are_replaced( string $template_string, array $replacements, string $expected ) { + $result = T::from( $template_string )->bind( $replacements )->render(); + $this->assertEqualHTML( $expected, $result ); + } + + public static function data_atomic_element_attributes() { + return array( + 'SCRIPT element attributes' => array( + '', + array( 'src' => '/js/app.js' ), + '', + ), + + 'STYLE element attributes' => array( + '', + array( 'media' => 'screen' ), + '', + ), + + 'TITLE element attributes' => array( + 'Page Title', + array( 'lang' => 'en' ), + 'Page Title', + ), + + 'TEXTAREA element attributes' => array( + '', + array( 'name' => 'my-textarea' ), + '', + ), + ); + } + + /** + * Verifies content placeholder behavior in elements with special parsing. + * + * - RAWTEXT elements (SCRIPT, STYLE): Content is skipped, placeholders preserved literally. + * - RCDATA elements (TITLE, TEXTAREA): Content is processed but placeholders are not + * recognized - they're treated as literal text and HTML-escaped. + * + * With strict validation, providing a replacement for a placeholder that won't be + * processed (inside SCRIPT/STYLE/TITLE/TEXTAREA) is an unused key error. + * + * @ticket 60229 + * + * @dataProvider data_atomic_element_content_placeholders + * + * @todo Implement correct handling of atomic elements. + * + * @covers ::from + * @covers ::render + */ + public function test_special_element_content_placeholder_behavior( string $template_string, string $expected ) { + $result = T::from( $template_string )->render(); + $this->assertEqualHTML( $expected, $result ); + } + + public static function data_atomic_element_content_placeholders() { + return array( + // RAWTEXT elements (SCRIPT, STYLE): Content is truly skipped, placeholders preserved literally. + 'SCRIPT content placeholder preserved' => array( + '', + '', + ), + + 'STYLE content placeholder preserved' => array( + '', + '', + ), + + // RCDATA elements (TITLE, TEXTAREA): Content is processed but placeholder + // patterns are not recognized - they're treated as literal text and escaped. + 'TITLE content placeholder escaped' => array( + 'Hello </%name>', + 'Hello </%name>', + ), + + 'TEXTAREA content placeholder escaped' => array( + '', + '', + ), + ); + } + + /** + * Verifies leading newline behavior in PRE elements. + * + * HTML5 specifies that a single leading newline immediately after the + *
     start tag is ignored. This test documents the template behavior.
    +	 *
    +	 * @ticket 60229
    +	 *
    +	 * @dataProvider data_pre_element_leading_newline
    +	 *
    +	 * @covers ::from
    +	 * @covers ::bind
    +	 * @covers ::render
    +	 */
    +	public function test_pre_element_leading_newline_behavior( string $template_string, array $replacements, string $expected ) {
    +		$this->markTestSkipped( 'PRE newline handling is not yet correct.' );
    +
    +		$result = T::from( $template_string )->bind( $replacements )->render();
    +		$this->assertEqualHTML( $expected, $result );
    +	}
    +
    +	public static function data_pre_element_leading_newline() {
    +		return array(
    +			'PRE without newline'        => array(
    +				"
    ", + array( 'code' => "line1\nline2"), + "
    line1\nline2
    ", + ), + + 'PRE with newline' => array( + "
    \n
    ", + array( 'code' => "line1\nline2"), + "
    line1\nline2
    ", + ), + + 'PRE with newline in replacement' => array( + "
    \n
    ", + array( 'code' => "line1\nline2"), + "
    line1\nline2
    ", + ), + + 'PRE with newline and newline in replacement' => array( + "
    \n
    ", + array( 'code' => "\nline1\nline2" ), + "
    \n\nline1\nline2
    ", + ), + + 'PRE with newline, newline replacement, and additional contents' => array( + "
    \n
    ", + array( 'code' => "\nline1" ), + "
    \n\nline1
    ", + ), + ); + } + + /** + * Verifies bind() warns on missing replacement key. + * + * @ticket 60229 + * + * @covers ::bind + * + * @expectedIncorrectUsage WP_HTML_Template::bind + */ + public function test_bind_warns_on_missing_key() { + $template = T::from( '

    ' ); + $template->bind( array( 'name' => 'Alice' ) ); + } + + /** + * Verifies bind() warns on unused replacement key. + * + * @ticket 60229 + * + * @covers ::bind + * + * @expectedIncorrectUsage WP_HTML_Template::bind + */ + public function test_bind_warns_on_unused_key() { + $template = T::from( '

    ' ); + $template->bind( array( 'name' => 'Alice', 'extra' => 'ignored' ) ); + } + + /** + * Verifies bind() warns when template used in attribute context. + * + * @ticket 60229 + * + * @covers ::bind + * + * @expectedIncorrectUsage WP_HTML_Template::bind + */ + public function test_bind_warns_on_template_in_attribute_context() { + $template = T::from( '' ); + $template->bind( array( 'html' => T::from( 'nested' ) ) ); + } + + /** + * Verifies that get_placeholders returns placeholder metadata. + * + * @ticket 60229 + * + * @covers ::get_placeholders + */ + public function test_get_placeholders_returns_metadata() { + $template = T::from( '

    ' ); + + $placeholders = $template->get_placeholders(); + + $this->assertArrayHasKey( 'class', $placeholders ); + $this->assertArrayHasKey( 'content', $placeholders ); + $this->assertSame( 'attribute', $placeholders['class']['context'] ); + $this->assertSame( 'text', $placeholders['content']['context'] ); + } + + /** + * Verifies text placeholders are extracted with correct offsets. + * + * @ticket 60229 + * + * @covers ::get_placeholders + */ + public function test_extracts_text_placeholders_with_offsets() { + $template = T::from( '

    ' ); + + $placeholders = $template->get_placeholders(); + + $this->assertArrayHasKey( 'name', $placeholders ); + $this->assertSame( 'text', $placeholders['name']['context'] ); + $this->assertCount( 1, $placeholders['name']['offsets'] ); + //

    is 3 chars, so starts at offset 3 + $this->assertSame( 3, $placeholders['name']['offsets'][0][0] ); + // is 8 chars + $this->assertSame( 8, $placeholders['name']['offsets'][0][1] ); + } + + /** + * Verifies repeated text placeholders are captured. + * + * @ticket 60229 + * + * @covers ::get_placeholders + */ + public function test_extracts_repeated_text_placeholders() { + $template = T::from( '

    and

    ' ); + + $placeholders = $template->get_placeholders(); + + $this->assertArrayHasKey( 'name', $placeholders ); + $this->assertCount( 2, $placeholders['name']['offsets'] ); + } + + /** + * Verifies attribute placeholders are extracted. + * + * @ticket 60229 + * + * @covers ::get_placeholders + */ + public function test_extracts_attribute_placeholders() { + $template = T::from( '' ); + + $placeholders = $template->get_placeholders(); + + $this->assertArrayHasKey( 'n', $placeholders ); + $this->assertArrayHasKey( 'c', $placeholders ); + $this->assertSame( 'attribute', $placeholders['n']['context'] ); + $this->assertSame( 'attribute', $placeholders['c']['context'] ); + } + + /** + * Verifies context promotion from text to attribute. + * + * When a placeholder appears in both text and attribute contexts, + * the attribute context takes precedence (more restrictive escaping). + * + * @ticket 60229 + * + * @covers ::get_placeholders + */ + /** + * Verifies that static text around placeholders in attributes is escaped. + * + * @ticket 60229 + * + * @covers ::from + * @covers ::bind + * @covers ::render + */ + public function test_escapes_static_text_around_placeholder_in_attribute() { + // Leading static text (prefix before placeholder) + $result = T::from( 'Link' ) + ->bind( array( 'slug' => 'hello' ) ) + ->render(); + $this->assertEqualHTML( 'Link', $result ); + + // Trailing static text (suffix after placeholder) + $result = T::from( 'Link' ) + ->bind( array( 'slug' => 'hello' ) ) + ->render(); + $this->assertEqualHTML( 'Link', $result ); + + // Ampersand in trailing static text must be escaped + $result = T::from( 'Link' ) + ->bind( array( 'base' => '/search?q=test' ) ) + ->render(); + $this->assertEqualHTML( 'Link', $result ); + + // Ampersand entity in leading static text must not be double-escaped + $result = T::from( 'Link' ) + ->bind( array( 'val' => '2' ) ) + ->render(); + $this->assertEqualHTML( 'Link', $result ); + + // Character reference in trailing static text is preserved (not double-escaped) + $result = T::from( '' ) + ->bind( array( 'placeholder' => '' ) ) + ->render(); + $this->assertEqualHTML( '', $result ); + } + + public function test_context_promotion_text_to_attribute() { + $template = T::from( '' ); + + $placeholders = $template->get_placeholders(); + + $this->assertArrayHasKey( 'url', $placeholders ); + // Both occurrences should use attribute context + $this->assertSame( 'attribute', $placeholders['url']['context'] ); + $this->assertCount( 2, $placeholders['url']['offsets'] ); + } +}