Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion docs/internals/escaping.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,14 +54,22 @@ element's `restoreEscape()` and leak the context past the element.
A whole HTML attribute generated from an expression (`Html\ExpressionAttributeNode`)
is not escaped via `Escaper` at all. The node picks a
`Runtime\HtmlHelpers::format*Attribute` method at compile time — the attribute *type*
(`bool`/`list`/`data`/`aria`/`style`) comes from the attribute name via
(`bool`/`tristate`/`valuedBool`/`list`/`data`/`aria`/`style`) comes from the attribute name via
`classifyAttributeType`, or is forced by an attribute modifier: `|toggle` anywhere in
the chain, `|json` only as the **last** modifier (`json` never comes from the
attribute name). Any non-HTML content type falls back to
`XmlHelpers::formatAttribute`. These formatters do their own escaping (`escapeAttr`,
JSON smart-quoting), so they are a **third place** the escaping contract lives —
audit them alongside the two layers above.

The categories are not interchangeable, because HTML resolves an *invalid* value
differently per attribute. `tristate` renders a bool as the `true`/`false` keyword,
but `valuedBool` must not: `hidden="false"` maps to the hidden state, so `false` there
has to drop the attribute instead. For the same reason `formatValuedBoolAttribute`
tests falsiness **before** treating the value as a keyword — narrowing that to
`is_string($value)` looks tidier but renders `hidden="0"` for a `'0'` coming from a
database, and the element silently disappears.

## `ContentType` and contextual filters

`ContentType` is **not a PHP enum** — it is a `final class` of string constants
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/filters.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ survives regeneration.
| `bytes` | `Filters::bytes` | | |
| `capitalize` | `Filters::capitalize` | | needs ext-mbstring |
| `ceil` | `Filters::ceil` | | |
| `checkUrl` | `Filters::checkUrl` | | appended automatically to URL attributes by the `checkUrls` pass; opt out with `nocheck` |
| `checkUrl` | `Filters::checkUrl` | | appended automatically to URL attributes by the `checkUrls` pass as nullsafe `?\|checkUrl`, so null still drops the attribute; opt out with `nocheck` |
| `clamp` | `Filters::clamp` | | |
| `column` | `Filters::column` | | |
| `commas` | `Filters::commas` | | |
Expand Down
2 changes: 1 addition & 1 deletion src/Latte/Essential/Nodes/NAttrNode.php
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ public static function formatHtmlAttribute(mixed $name, mixed $value, bool $migr
{
LR\HtmlHelpers::validateAttributeName($name);
$type = LR\HtmlHelpers::classifyAttributeType($name);
if ($value === null || ($value === false && $type !== 'data' && $type !== 'aria')) {
if ($value === null || ($value === false && $type !== 'data' && $type !== 'aria' && $type !== 'tristate')) {
return '';
} elseif ($value === true && $type === '') {
return $name;
Expand Down
2 changes: 1 addition & 1 deletion src/Latte/Essential/Passes.php
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ public function checkUrlsPass(TemplateNode $node): void
&& !$node->modifier->removeFilter('nocheck') && !$node->modifier->removeFilter('noCheck')
&& !$node->modifier->hasFilter('datastream') && !$node->modifier->hasFilter('dataStream')
) {
$node->modifier->filters[] = new Php\FilterNode(new Php\IdentifierNode('checkUrl'));
$node->modifier->filters[] = new Php\FilterNode(new Php\IdentifierNode('checkUrl'), nullsafe: true); // null drops the attribute
}
});
}
Expand Down
60 changes: 47 additions & 13 deletions src/Latte/Runtime/HtmlHelpers.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,19 @@ final class HtmlHelpers
{
private const BooleanAttributes = [
'allowfullscreen' => 1, 'async' => 1, 'autofocus' => 1, 'autoplay' => 1, 'checked' => 1, 'controls' => 1,
'contenteditable' => 1, 'default' => 1, 'defer' => 1, 'disabled' => 1, 'draggable' => 1, 'formnovalidate' => 1,
'hidden' => 1, 'inert' => 1, 'ismap' => 1, 'itemscope' => 1, 'loop' => 1, 'multiple' => 1, 'muted' => 1,
'nomodule' => 1, 'novalidate' => 1, 'open' => 1, 'playsinline' => 1, 'readonly' => 1, 'required' => 1,
'reversed' => 1, 'selected' => 1, 'spellcheck' => 1,
'default' => 1, 'defer' => 1, 'disabled' => 1, 'formnovalidate' => 1, 'inert' => 1, 'ismap' => 1,
'itemscope' => 1, 'loop' => 1, 'multiple' => 1, 'muted' => 1, 'nomodule' => 1, 'novalidate' => 1,
'open' => 1, 'playsinline' => 1, 'readonly' => 1, 'required' => 1, 'reversed' => 1, 'selected' => 1,
];

/** enumerated attributes whose keywords are 'true' and 'false' */
private const TristateAttributes = [
'contenteditable' => 1, 'draggable' => 1, 'spellcheck' => 1, 'writingsuggestions' => 1,
];

/** boolean-like attributes accepting an optional keyword, e.g. hidden="until-found" */
private const ValuedBooleanAttributes = [
'hidden' => 1, 'popover' => 1,
];

private const SpaceSeparatedAttributes = [
Expand Down Expand Up @@ -166,13 +175,15 @@ public static function convertHtmlToText(string $s): string


/**
* Returns the type category of an HTML attribute: 'bool', 'list', 'data', 'aria', 'style', or ''.
* Returns the type category of an HTML attribute: 'bool', 'tristate', 'valuedBool', 'list', 'data', 'aria', 'style', or ''.
*/
public static function classifyAttributeType(string $name): string
{
$name = strtolower($name);
return match (true) {
isset(self::BooleanAttributes[$name]) => 'bool',
isset(self::TristateAttributes[$name]) => 'tristate',
isset(self::ValuedBooleanAttributes[$name]) => 'valuedBool',
isset(self::SpaceSeparatedAttributes[$name]) => 'list',
str_starts_with($name, 'data-') => 'data',
str_starts_with($name, 'aria-') => 'aria',
Expand Down Expand Up @@ -211,6 +222,34 @@ public static function formatBoolAttribute(string $namePart, mixed $value): stri
}


/**
* Formats HTML attribute whose keywords are 'true' and 'false'.
*/
public static function formatTristateAttribute(string $namePart, mixed $value, bool $migrationWarnings = false): string
{
if ($migrationWarnings && is_bool($value)) {
self::triggerMigrationWarning(trim($namePart), $value);
}
return match (true) {
is_bool($value) => $namePart . '="' . ($value ? 'true' : 'false') . '"',
default => self::formatAttribute($namePart, $value),
};
}


/**
* Formats boolean HTML attribute accepting an optional keyword.
*/
public static function formatValuedBoolAttribute(string $namePart, mixed $value): string
{
return match (true) {
!$value => '', // falsy values are not keywords; hidden="0" would map back to the hidden state
$value === true => $namePart,
default => self::formatAttribute($namePart, $value),
};
}


/**
* Formats space separated HTML attribute.
*/
Expand Down Expand Up @@ -268,14 +307,9 @@ public static function formatJsonAttribute(string $namePart, mixed $value): stri
*/
public static function formatAriaAttribute(string $namePart, mixed $value, bool $migrationWarnings = false): string
{
if ($migrationWarnings && is_bool($value)) {
self::triggerMigrationWarning(trim($namePart), $value);
}
return match (true) {
is_bool($value) => $namePart . '="' . ($value ? 'true' : 'false') . '"',
is_array($value) => self::formatArrayAttribute($namePart, $value, fn($v, $k) => $v === true ? $k : $v, ' '),
default => self::formatAttribute($namePart, $value),
};
return is_array($value)
? self::formatArrayAttribute($namePart, $value, fn($v, $k) => $v === true ? $k : $v, ' ')
: self::formatTristateAttribute($namePart, $value, $migrationWarnings);
}


Expand Down
18 changes: 18 additions & 0 deletions tests/common/Engine.migrationWarnings.phpt
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ Assert::error(
'Behavior change for attribute \'title\' with value null: previously it rendered as title="", now the attribute is omitted (on line 1 at column 17)',
);

Assert::error(
fn() => $latte->renderToString('<a href="{=null}">'),
E_USER_WARNING,
'Behavior change for attribute \'href\' with value null: previously it rendered as href="", now the attribute is omitted (on line 1 at column 12)',
);

Assert::error(
fn() => $latte->renderToString('<input onclick="{=foo}">'),
E_USER_WARNING,
Expand All @@ -33,6 +39,12 @@ Assert::error(
'Behavior change for attribute \'aria-foo\' with value true: previously it rendered as aria-foo="1", now it renders as aria-foo="true" (on line 1 at column 8)',
);

Assert::error(
fn() => $latte->renderToString('<div contenteditable="{=false}">'),
E_USER_WARNING,
'Behavior change for attribute \'contenteditable\' with value false: previously it rendered as contenteditable="", now it renders as contenteditable="false" (on line 1 at column 25)',
);

// |accept
Assert::same(
'<input>',
Expand All @@ -50,3 +62,9 @@ Assert::same(
'<input true>',
$latte->renderToString('<input true="{=true|toggle}" false="{=false|toggle}">'),
);

// toggle overrides the category derived from the attribute name
Assert::same(
'<div draggable>',
$latte->renderToString('<div contenteditable="{=false|toggle}" draggable="{=true|toggle}" hidden="{=false|toggle}">'),
);
9 changes: 8 additions & 1 deletion tests/common/Safe.url.phpt
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,18 @@ Assert::match(


Assert::contains(
'LR\HtmlHelpers::formatAttribute(\' href\', ($this->filters->checkUrl)(($this->filters->upper)($url1)))',
'LR\HtmlHelpers::formatAttribute(\' href\', (($ʟ_tmp = ($this->filters->upper)($url1)) === null ? null : ($this->filters->checkUrl)($ʟ_tmp)))',
$latte->compile('<a href="{$url1|upper}"></a>'),
);


// null drops the attribute like in other attributes
Assert::match(
'<a></a> <img>',
$latte->renderToString('<a href={$url}></a> <img src="{$url}">', ['url' => null]),
);


// xlink:href in inline SVG
Assert::match(
'<svg><a xlink:href="">x</a></svg>',
Expand Down
60 changes: 60 additions & 0 deletions tests/runtime/HtmlHelpers.formatTristateAttribute.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php declare(strict_types=1);

use Latte\Runtime\HtmlHelpers;
use Tester\Assert;

require __DIR__ . '/../bootstrap.php';


class StringObject
{
public function __toString()
{
return 'one&<br>';
}
}


// bool
Assert::same('contenteditable="true"', HtmlHelpers::formatTristateAttribute('contenteditable', true));
Assert::same('contenteditable="false"', HtmlHelpers::formatTristateAttribute('contenteditable', false));

// keywords are passed through, 'false' must not toggle the attribute on
Assert::same('contenteditable="false"', HtmlHelpers::formatTristateAttribute('contenteditable', 'false'));
Assert::same('contenteditable="true"', HtmlHelpers::formatTristateAttribute('contenteditable', 'true'));
Assert::same('contenteditable="plaintext-only"', HtmlHelpers::formatTristateAttribute('contenteditable', 'plaintext-only'));

// text
Assert::same('contenteditable=""', HtmlHelpers::formatTristateAttribute('contenteditable', ''));
Assert::same('contenteditable="1"', HtmlHelpers::formatTristateAttribute('contenteditable', 1));
Assert::same('contenteditable="0"', HtmlHelpers::formatTristateAttribute('contenteditable', 0));
Assert::same('a="Hello &amp; Welcome"', HtmlHelpers::formatTristateAttribute('a', 'Hello & Welcome'));
Assert::same('a="one&amp;"', HtmlHelpers::formatTristateAttribute('a', new Latte\Runtime\Html('one&amp;<br>')));
Assert::same('a="one&amp;&lt;br&gt;"', HtmlHelpers::formatTristateAttribute('a', new StringObject));

// skipped
Assert::same('', HtmlHelpers::formatTristateAttribute('contenteditable', null));

// migration warnings
Assert::error(
fn() => Assert::same('contenteditable="true"', HtmlHelpers::formatTristateAttribute('contenteditable', true, migrationWarnings: true)),
E_USER_WARNING,
'Behavior change for attribute \'contenteditable\' with value true: previously it rendered as contenteditable="1", now it renders as contenteditable="true".',
);
Assert::error(
fn() => Assert::same('contenteditable="false"', HtmlHelpers::formatTristateAttribute('contenteditable', false, migrationWarnings: true)),
E_USER_WARNING,
'Behavior change for attribute \'contenteditable\' with value false: previously it rendered as contenteditable="", now it renders as contenteditable="false".',
);

// invalid
Assert::error(
fn() => Assert::same('', HtmlHelpers::formatTristateAttribute('contenteditable', [])),
E_USER_WARNING,
"Invalid value for attribute 'contenteditable': array is not allowed.",
);
Assert::error(
fn() => Assert::same('', HtmlHelpers::formatTristateAttribute('contenteditable', (object) [])),
E_USER_WARNING,
"Invalid value for attribute 'contenteditable': stdClass is not allowed.",
);
43 changes: 43 additions & 0 deletions tests/runtime/HtmlHelpers.formatValuedBoolAttribute.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php declare(strict_types=1);

use Latte\Runtime\HtmlHelpers;
use Tester\Assert;

require __DIR__ . '/../bootstrap.php';


class StringObject
{
public function __toString()
{
return 'until-found';
}
}


// bool
Assert::same('hidden', HtmlHelpers::formatValuedBoolAttribute('hidden', true));
Assert::same('', HtmlHelpers::formatValuedBoolAttribute('hidden', false));

// keyword is preserved
Assert::same('hidden="until-found"', HtmlHelpers::formatValuedBoolAttribute('hidden', 'until-found'));
Assert::same('popover="manual"', HtmlHelpers::formatValuedBoolAttribute('popover', 'manual'));
Assert::same('hidden="until-found"', HtmlHelpers::formatValuedBoolAttribute('hidden', new StringObject));
Assert::same('hidden="one&amp;two"', HtmlHelpers::formatValuedBoolAttribute('hidden', 'one&two'));

// falsey values keep the attribute off, they are not keywords
Assert::same('', HtmlHelpers::formatValuedBoolAttribute('hidden', ''));
Assert::same('', HtmlHelpers::formatValuedBoolAttribute('hidden', '0'));
Assert::same('', HtmlHelpers::formatValuedBoolAttribute('hidden', 0));
Assert::same('', HtmlHelpers::formatValuedBoolAttribute('hidden', []));
Assert::same('hidden="1"', HtmlHelpers::formatValuedBoolAttribute('hidden', 1));

// skipped
Assert::same('', HtmlHelpers::formatValuedBoolAttribute('hidden', null));

// invalid
Assert::error(
fn() => Assert::same('', HtmlHelpers::formatValuedBoolAttribute('hidden', [1])),
E_USER_WARNING,
"Invalid value for attribute 'hidden': array is not allowed.",
);
43 changes: 43 additions & 0 deletions tests/tags/n-attr.formatHtmlAttribute.phpt
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,49 @@ test('boolean attributes', function () {
});


test('tristate attributes', function () {
Assert::same('contenteditable="true"', NAttrNode::formatHtmlAttribute('contenteditable', true));
Assert::same('contenteditable="false"', NAttrNode::formatHtmlAttribute('contenteditable', false));
Assert::same('', NAttrNode::formatHtmlAttribute('contenteditable', null));

// special values
Assert::same('contenteditable="false"', NAttrNode::formatHtmlAttribute('contenteditable', 'false'));
Assert::same('contenteditable="plaintext-only"', NAttrNode::formatHtmlAttribute('contenteditable', 'plaintext-only'));
Assert::same('contenteditable=""', NAttrNode::formatHtmlAttribute('contenteditable', ''));
Assert::same('spellcheck="1"', NAttrNode::formatHtmlAttribute('spellcheck', 1));

// invalid
Assert::error(
fn() => Assert::same('', NAttrNode::formatHtmlAttribute('draggable', [])),
E_USER_WARNING,
"Invalid value for attribute 'draggable': array is not allowed.",
);
});


test('valued boolean attributes', function () {
Assert::same('hidden', NAttrNode::formatHtmlAttribute('hidden', true));
Assert::same('', NAttrNode::formatHtmlAttribute('hidden', false));
Assert::same('', NAttrNode::formatHtmlAttribute('hidden', null));

// special values
Assert::same('hidden="until-found"', NAttrNode::formatHtmlAttribute('hidden', 'until-found'));
Assert::same('popover="manual"', NAttrNode::formatHtmlAttribute('popover', 'manual'));
Assert::same('', NAttrNode::formatHtmlAttribute('hidden', ''));
Assert::same('', NAttrNode::formatHtmlAttribute('hidden', '0'));
Assert::same('', NAttrNode::formatHtmlAttribute('hidden', 0));
Assert::same('hidden="1"', NAttrNode::formatHtmlAttribute('hidden', 1));
Assert::same('', NAttrNode::formatHtmlAttribute('hidden', []));

// invalid
Assert::error(
fn() => Assert::same('', NAttrNode::formatHtmlAttribute('hidden', [1])),
E_USER_WARNING,
"Invalid value for attribute 'hidden': array is not allowed.",
);
});


test('style attribute', function () {
Assert::same('style=""', NAttrNode::formatHtmlAttribute('style', ''));
Assert::same('style="color:red;"', NAttrNode::formatHtmlAttribute('style', 'color:red;'));
Expand Down
Loading
Loading