From fc68cc6a83575a35b8b5e1859122861f623eb763 Mon Sep 17 00:00:00 2001 From: Josh Bruce Date: Thu, 15 Oct 2026 12:09:55 -0400 Subject: [PATCH 1/2] Encode raw textarea values and complete acceptance coverage Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 32 ++++++++++ src/Forms/TextArea.php | 6 +- tests/Forms/TextAreaTest.php | 119 +++++++++++++++++++++++++++++++++++ 3 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 tests/Forms/TextAreaTest.php diff --git a/README.md b/README.md index f2fa7fa..97f01bf 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,38 @@ Overriding the control's `id()` also changes its generated warning ID. Assertion failures identify the relationship, control type and ID, and rendered HTML, alongside PHPUnit's named case and expected/actual diff. +### Textarea values + +`TextArea::create($label, $name, $value = '', $warningMessage = '')` accepts +**raw UTF-8 text** for `$value`, not HTML or pre-encoded entities. It encodes +the value once when rendering, so ``, script-like text, ampersands, +and quotes remain text rather than markup. Pass `A & B`, not `A & B`, to +display `A & B`. Literal entity syntax in the raw value stays literal: passing +`&` displays `&`. Invalid UTF-8 sequences are replaced with the Unicode +replacement character. + +```php +use Eightfold\HtmlBuilder\Forms\TextArea; + +TextArea::create('Notes', 'notes', "\nFirst line\nSecond line\n", 'Add more detail.') + ->inputProps('rows 4', 'required required') + ->labelProps('class notes-label'); +``` + +Values are element text, not a `value` attribute. Spaces, tabs, and leading, +internal, and trailing LF newlines are preserved: rendering adds one extra +leading newline that HTML consumes. Native HTML parsing normalizes CR and CRLF +line endings to LF; browsers apply their native textarea submission rules. +`inputProps()` targets the textarea and supports native `rows`, `required`, +`readonly`, and `disabled` attributes. Generated `id` and `name` override +conflicting input properties; `id()` also updates the label and warning +associations. String and `PropertyInterface` warnings follow the shared guidance +rules above. + +This value encoding does not change the general builder contract: callers +remain responsible for sanitizing labels, warning markup, additional content, +and attribute values. + ### Select attributes and selection `Select::inputProps()` targets the `

Outside

']; + yield 'UTF-8 text' => ["Caf\u{00e9} \u{1f642}"]; + } + + #[Test] + #[DataProvider('values')] + public function raw_values_round_trip_as_text_without_creating_markup(string $value): void + { + $control = TextArea::create('Notes', 'notes', $value); + $html = (string) $control; + $xpath = self::xpath($html); + $textareas = $xpath->query('//*[local-name()="textarea"]'); + parent::assertNotFalse($textareas); + parent::assertCount(1, $textareas, "Value must not create extra textareas.\nRendered HTML:\n" . $html); + $textarea = $textareas->item(0); + parent::assertInstanceOf(DomElement::class, $textarea); + parent::assertSame( + $value, + $textarea->textContent, + "The raw value must round-trip exactly, without trimming or entity decoding.\nRendered HTML:\n" . $html + ); + parent::assertFalse($textarea->hasAttribute('value'), 'Textarea values belong in text content.'); + parent::assertSame( + 0, + $xpath->query('//*[local-name()="script" or local-name()="p"]')->length, + 'Markup-like value text must not become document elements.' + ); + parent::assertSame($html, (string) $control, 'Rendering twice must not encode the value twice.'); + } + + #[Test] + public function carriage_returns_follow_native_html_newline_normalization(): void + { + $xpath = self::xpath((string) TextArea::create('Notes', 'notes', "\r\nFirst\rSecond\r\n")); + parent::assertSame( + "\nFirst\nSecond\n", + $xpath->query('//*[local-name()="textarea"]')->item(0)->textContent, + 'HTML parsing normalizes CR and CRLF to LF without losing leading or trailing line breaks.' + ); + } + + #[Test] + public function native_properties_and_custom_identity_target_the_textarea(): void + { + $html = (string) TextArea::create('Notes', 'notes', 'A & B') + ->id('custom-notes') + ->inputProps( + 'id ignored', + 'name ignored', + 'rows 4', + 'required required', + 'readonly readonly', + 'disabled disabled' + )->wrapperProps('class notes-wrapper')->labelProps('class notes-label'); + $xpath = self::xpath($html); + $textarea = $xpath->query('//*[local-name()="textarea"]')->item(0); + parent::assertInstanceOf(DomElement::class, $textarea); + parent::assertSame('custom-notes', $textarea->getAttribute('id'), 'The explicit ID must win.'); + parent::assertSame('notes', $textarea->getAttribute('name'), 'The declared submitted name must win.'); + parent::assertSame('4', $textarea->getAttribute('rows')); + foreach (['required', 'readonly', 'disabled'] as $attribute) { + parent::assertTrue($textarea->hasAttribute($attribute), $attribute . ' must reach the textarea.'); + } + parent::assertFalse($textarea->hasAttribute('aria-invalid'), 'No warning must mean no generated invalid state.'); + parent::assertFalse($textarea->hasAttribute('aria-describedby'), 'No guidance must mean no generated references.'); + parent::assertSame( + 1, + $xpath->query('//*[local-name()="label" and @for="custom-notes" and @class="notes-label"]')->length, + 'The styled label must reference the custom control ID.' + ); + parent::assertSame( + 1, + $xpath->query('//*[local-name()="div" and @class="notes-wrapper"]')->length + ); + parent::assertSame( + 0, + $xpath->query('//*[local-name()="div" and (@rows or @required or @readonly or @disabled)]')->length, + 'Native control attributes must not leak onto the wrapper.' + ); + parent::assertStringContainsString('A & B', $html, 'Raw ampersands must be encoded once.'); + } + + private static function xpath(string $html): XPath + { + return new XPath(HTMLDocument::createFromString( + 'Textarea test' + . $html . '' + )); + } +} From 471fdf944a64ed58b8d95fc310e2c1adc29c4cf5 Mon Sep 17 00:00:00 2001 From: Josh Bruce Date: Thu, 15 Oct 2026 12:14:04 -0400 Subject: [PATCH 2/2] Preserve caller-owned textarea escaping contract Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 24 ++++++++------- src/Forms/TextArea.php | 5 ++-- tests/Forms/TextAreaTest.php | 58 +++++++++++++++++++++++++++++++----- 3 files changed, 67 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 97f01bf..7668435 100644 --- a/README.md +++ b/README.md @@ -91,17 +91,20 @@ HTML, alongside PHPUnit's named case and expected/actual diff. ### Textarea values `TextArea::create($label, $name, $value = '', $warningMessage = '')` accepts -**raw UTF-8 text** for `$value`, not HTML or pre-encoded entities. It encodes -the value once when rendering, so ``, script-like text, ampersands, -and quotes remain text rather than markup. Pass `A & B`, not `A & B`, to -display `A & B`. Literal entity syntax in the raw value stays literal: passing -`&` displays `&`. Invalid UTF-8 sequences are replaced with the Unicode -replacement character. +**caller-sanitized content** for `$value`, consistent with the other controls. +It renders the supplied value without escaping, decoding, or double encoding. +The implementer must escape untrusted text before passing it to the component; +an unescaped `` can close the control and introduce markup. +Escaping flags, character encoding, and invalid-character handling remain the +implementer's choice. ```php use Eightfold\HtmlBuilder\Forms\TextArea; -TextArea::create('Notes', 'notes', "\nFirst line\nSecond line\n", 'Add more detail.') +$value = "\nFirst line & second line\n"; +$escapedValue = htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + +TextArea::create('Notes', 'notes', $escapedValue, 'Add more detail.') ->inputProps('rows 4', 'required required') ->labelProps('class notes-label'); ``` @@ -116,9 +119,10 @@ conflicting input properties; `id()` also updates the label and warning associations. String and `PropertyInterface` warnings follow the shared guidance rules above. -This value encoding does not change the general builder contract: callers -remain responsible for sanitizing labels, warning markup, additional content, -and attribute values. +The escaping call above is an application example, not a component default. +To display literal entity syntax such as `&`, escape that text too (for +example, supply `&amp;`). Callers remain responsible for sanitizing all +values, labels, warning markup, additional content, and attribute values. ### Select attributes and selection diff --git a/src/Forms/TextArea.php b/src/Forms/TextArea.php index 0f61c15..3960048 100644 --- a/src/Forms/TextArea.php +++ b/src/Forms/TextArea.php @@ -33,7 +33,7 @@ class TextArea implements Stringable private array $content = []; /** - * @param string $value Raw UTF-8 text, not HTML or pre-encoded entities. + * @param string $value Caller-sanitized content, rendered without escaping. */ public static function create( string|Stringable $label, @@ -119,8 +119,7 @@ public function __toString(): string $inputProperties = self::inputProperties($warning === ''); $textarea = Element::textarea( - // HTML consumes one leading newline after the opening tag. - "\n" . htmlspecialchars($this->value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') + "\n" . $this->value )->props(...[ ...$inputProperties, 'id ' . $this->id, diff --git a/tests/Forms/TextAreaTest.php b/tests/Forms/TextAreaTest.php index 6383fa4..c33d5a1 100644 --- a/tests/Forms/TextAreaTest.php +++ b/tests/Forms/TextAreaTest.php @@ -33,9 +33,10 @@ public static function values(): iterable #[Test] #[DataProvider('values')] - public function raw_values_round_trip_as_text_without_creating_markup(string $value): void + public function caller_escaped_values_round_trip_without_double_encoding(string $value): void { - $control = TextArea::create('Notes', 'notes', $value); + $escaped = htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $control = TextArea::create('Notes', 'notes', $escaped); $html = (string) $control; $xpath = self::xpath($html); $textareas = $xpath->query('//*[local-name()="textarea"]'); @@ -46,15 +47,58 @@ public function raw_values_round_trip_as_text_without_creating_markup(string $va parent::assertSame( $value, $textarea->textContent, - "The raw value must round-trip exactly, without trimming or entity decoding.\nRendered HTML:\n" . $html + "Caller-escaped text must round-trip without trimming or double encoding.\nRendered HTML:\n" . $html ); parent::assertFalse($textarea->hasAttribute('value'), 'Textarea values belong in text content.'); parent::assertSame( 0, $xpath->query('//*[local-name()="script" or local-name()="p"]')->length, - 'Markup-like value text must not become document elements.' + 'Caller-escaped markup-like text must not become document elements.' + ); + parent::assertStringContainsString( + '>' . "\n" . $escaped . '', + $html, + 'The supplied value must be rendered verbatim after the parser-consumed newline.' + ); + parent::assertSame($html, (string) $control, 'Repeated rendering must not transform the supplied value.'); + } + + /** @return iterable */ + public static function escapingOptions(): iterable + { + yield 'quotes' => [ENT_QUOTES | ENT_SUBSTITUTE]; + yield 'double quotes only' => [ENT_COMPAT | ENT_SUBSTITUTE]; + yield 'no quotes' => [ENT_NOQUOTES | ENT_SUBSTITUTE]; + yield 'HTML5 entities' => [ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5]; + } + + #[Test] + #[DataProvider('escapingOptions')] + public function caller_selected_escaping_options_are_preserved(int $flags): void + { + $value = ' & "quoted" and \'single\''; + $escaped = htmlspecialchars($value, $flags, 'UTF-8'); + $html = (string) TextArea::create('Notes', 'notes', $escaped); + parent::assertStringContainsString( + '>' . "\n" . $escaped . '', + $html, + 'TextArea must not impose its own encoding options on caller-escaped values.' + ); + parent::assertSame( + $value, + self::xpath($html)->query('//*[local-name()="textarea"]')->item(0)->textContent + ); + } + + #[Test] + public function supplied_content_is_not_automatically_sanitized(): void + { + $value = 'Literal source & "quotes"'; + parent::assertStringContainsString( + '>' . "\n" . $value . '', + (string) TextArea::create('Notes', 'notes', $value), + 'Sanitization belongs to the caller, consistently with the other controls.' ); - parent::assertSame($html, (string) $control, 'Rendering twice must not encode the value twice.'); } #[Test] @@ -71,7 +115,7 @@ public function carriage_returns_follow_native_html_newline_normalization(): voi #[Test] public function native_properties_and_custom_identity_target_the_textarea(): void { - $html = (string) TextArea::create('Notes', 'notes', 'A & B') + $html = (string) TextArea::create('Notes', 'notes', 'A & B') ->id('custom-notes') ->inputProps( 'id ignored', @@ -106,7 +150,7 @@ public function native_properties_and_custom_identity_target_the_textarea(): voi $xpath->query('//*[local-name()="div" and (@rows or @required or @readonly or @disabled)]')->length, 'Native control attributes must not leak onto the wrapper.' ); - parent::assertStringContainsString('A & B', $html, 'Raw ampersands must be encoded once.'); + parent::assertStringContainsString('A & B', $html, 'Caller-escaped ampersands must remain unchanged.'); } private static function xpath(string $html): XPath