Skip to content

Commit

Permalink
Html::appendToTag(), prependToTag(), |append, and |prepend
Browse files Browse the repository at this point in the history
Resolves #3937
Resolves #4762
  • Loading branch information
brandonkelly committed Aug 19, 2019
1 parent f1b4169 commit 5ee3d5f
Show file tree
Hide file tree
Showing 5 changed files with 355 additions and 9 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG-v3.3.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,15 @@
## Added
- Added the `tag()` Twig function, which renders a complete HTML tag.
- Added the `|attr` Twig filter, which modifies the attributes on an HTML tag. ([#4660](https://github.com/craftcms/cms/issues/4660))
- Added the `|append` and `|prepend` Twig filters, which add new HTML elements as children of an HTML tag. ([#3937](https://github.com/craftcms/cms/issues/3937))
- Added the `purgeStaleUserSessionDuration` config setting.
- Control Panel subnav items can now have badge counts. ([#4756](https://github.com/craftcms/cms/issues/4756))
- Added `craft\helpers\Html::appendToTag()`.
- Added `craft\helpers\Html::modifyTagAttributes()`.
- Added `craft\helpers\Html::normalizeTagAttributes()`.
- Added `craft\helpers\Html::parseTag()`.
- Added `craft\helpers\Html::parseTagAttributes()`.
- Added `craft\helpers\Html::prependToTag()`.

## Changed
- Global set reference tags can now refer to the global set by its handle. ([#4645](https://github.com/craftcms/cms/issues/4645))
46 changes: 46 additions & 0 deletions docs/dev/filters.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,29 @@ Returns an absolute value.

This works identically to Twig’s core [`abs`](https://twig.symfony.com/doc/2.x/filters/abs.html) filter.

## `append`

Appends HTML to the end of another element.

```twig
{{ '<div><p>Lorem</p></div>'|append('<p>Ipsum</p>') }}
{# Output: <div><p>Lorem</p><p>Ipsum</p></div> #}
```

If you only want to append a new element if one of the same type doesn’t already exist, pass `'keep'` as a second argument.

```twig
{{ '<div><p>Lorem</p></div>'|append('<p>Ipsum</p>', 'keep') }}
{# Output: <div><p>Lorem</p></div> #}
```

If you want to replace an existing element of the same type, pass `'replace'` as a second argument.

```twig
{{ '<div><p>Lorem</p></div>'|append('<p>Ipsum</p>', 'replace') }}
{# Output: <div><p>Ipsum</p></div> #}
```

## `ascii`

Converts a string to ASCII characters.
Expand Down Expand Up @@ -556,6 +579,29 @@ Returns a string formatted in “PascalCase” (AKA “UpperCamelCase”).

Formats a percentage according to the user’s preferred language.

## `append`

This comment has been minimized.

Copy link
@gtettelaar

gtettelaar Aug 19, 2019

Contributor

Shouldn't that be prepend?

This comment has been minimized.

Copy link
@brandonkelly

brandonkelly Aug 19, 2019

Author Member

Doh, thanks!


Prepends HTML to the beginning of another element.

```twig
{{ '<div><p>Ipsum</p></div>'|prepend('<p>Lorem</p>') }}
{# Output: <div><p>Lorem</p><p>Ipsum</p></div> #}
```

If you only want to append a new element if one of the same type doesn’t already exist, pass `'keep'` as a second argument.

```twig
{{ '<div><p>Ipsum</p></div>'|prepend('<p>Lorem</p>', 'keep') }}
{# Output: <div><p>Ipsum</p></div> #}
```

If you want to replace an existing element of the same type, pass `'replace'` as a second argument.

```twig
{{ '<div><p>Ipsum</p></div>'|prepend('<p>Lorem</p>', 'replace') }}
{# Output: <div><p>Lorem</p></div> #}
```

## `raw`

Marks a value as being “safe”, which means that in an environment with automatic escaping enabled this variable will not be escaped if raw is the last filter applied to it.
Expand Down
168 changes: 159 additions & 9 deletions src/helpers/Html.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@

namespace craft\helpers;

use Craft;
use yii\base\InvalidArgumentException;

/**
Expand Down Expand Up @@ -49,6 +48,100 @@ public static function encodeParams(string $html, array $variables = []): string
return $html;
}

/**
* Appends HTML to the end of the given tag.
*
* @param string $tag The HTML tag that `$html` should be appended to
* @param string $html The HTML to append to `$tag`.
* @param string|null $ifExists What to do if `$tag` already contains a child of the same type as the element
* defined by `$html`. Set to `'keep'` if no action should be taken, or `'replace'` if it should be replaced
* by `$tag`.
* @return string The modified HTML
* @since 3.3.0
*/
public static function appendToTag(string $tag, string $html, string $ifExists = null): string
{
return self::_addToTagInternal($tag, $html, 'htmlEnd', $ifExists);
}

/**
* Prepends HTML to the beginning of given tag.
*
* @param string $tag The HTML tag that `$html` should be prepended to
* @param string $html The HTML to prepend to `$tag`.
* @param string|null $ifExists What to do if `$tag` already contains a child of the same type as the element
* defined by `$html`. Set to `'keep'` if no action should be taken, or `'replace'` if it should be replaced
* by `$tag`.
* @return string The modified HTML
* @since 3.3.0
*/
public static function prependToTag(string $tag, string $html, string $ifExists = null): string
{
return self::_addToTagInternal($tag, $html, 'htmlStart', $ifExists);
}

/**
* Parses an HTML tag and returns info about it and its children.
*
* @param string $tag The HTML tag
* @param int $offset The offset to start looking for a tag
* @return array An array containing `type`, `attributes`, `children`, `start`, `end`, `htmlStart`, and `htmlEnd`
* properties. Nested text nodes will be represented as arrays within `children` with `type` set to `'text'`, and a
* `value` key containing the text value.
* @throws InvalidArgumentException if `$tag` doesn't contain a valid HTML tag
* @since 3.3.0
*/
public static function parseTag(string $tag, int $offset = 0): array
{
list($type, $start) = self::_findTag($tag, $offset);
$attributes = static::parseTagAttributes($tag, $start, $attrStart, $attrEnd);
$end = strpos($tag, '>', $attrEnd) + 1;
$isVoid = $tag[$end - 2] === '/' || isset(static::$voidElements[$type]);
$children = [];

// If this is a void element, we're done here
if ($isVoid) {
$htmlStart = $htmlEnd = null;
} else {
// Otherwise look for nested tags
$htmlStart = $cursor = $end;

do {
try {
$subtag = static::parseTag($tag, $cursor);
// Did we skip some text to get there?
if ($subtag['start'] > $cursor) {
$children[] = [
'type' => 'text',
'value' => substr($tag, $cursor, $subtag['start'] - $cursor),
];
}
$children[] = $subtag;
$cursor = $subtag['end'];
} catch (InvalidArgumentException $e) {
// We must have just reached the end
break;
}
} while (true);

// Find the closing tag
if (($htmlEnd = stripos($tag, "</{$type}>", $cursor)) === false) {
throw new InvalidArgumentException("Could not find a </{$type}> tag in string: {$tag}");
}

$end = $htmlEnd + strlen($type) + 3;

if ($htmlEnd > $cursor) {
$children[] = [
'type' => 'text',
'value' => substr($tag, $cursor, $htmlEnd - $cursor),
];
}
}

return compact('type', 'attributes', 'children', 'start', 'htmlStart', 'htmlEnd', 'end');
}

/**
* Modifies a HTML tag’s attributes, supporting the same attribute definitions as [[renderTagAttributes()]].
*
Expand All @@ -62,7 +155,7 @@ public static function modifyTagAttributes(string $tag, array $attributes): stri
{
// Normalize the attributes & merge with the old attributes
$attributes = static::normalizeTagAttributes($attributes);
$oldAttributes = static::parseTagAttributes($tag, $start, $end);
$oldAttributes = static::parseTagAttributes($tag, 0, $start, $end);
$attributes = ArrayHelper::merge($oldAttributes, $attributes);

// Ensure we don't have any duplicate classes
Expand All @@ -79,20 +172,17 @@ public static function modifyTagAttributes(string $tag, array $attributes): stri
* Parses an HTML tag to find its attributes.
*
* @param string $tag The HTML tag to parse
* @param int $offset The offset to start looking for a tag
* @param int|null $start The start position of the first attribute in the given tag
* @param int|null $end The end position of the last attribute in the given tag
* @return array The parsed HTML tags
* @throws InvalidArgumentException if `$tag` doesn't contain a valid HTML tag
* @since 3.3.0
*/
public static function parseTagAttributes(string $tag, int &$start = null, int &$end = null): array
public static function parseTagAttributes(string $tag, int $offset = 0, int &$start = null, int &$end = null): array
{
// Find the first HTML tag that isn't a DTD or a comment
if (!preg_match('/<\w+/', $tag, $match, PREG_OFFSET_CAPTURE)) {
throw new InvalidArgumentException('Could not find an HTML tag in string: ' . $tag);
}

$start = $match[0][1] + strlen($match[0][0]);
list($type, $tagStart) = self::_findTag($tag, $offset);
$start = $tagStart + strlen($type) + 1;
$anchor = $start;
$attributes = [];

Expand Down Expand Up @@ -167,6 +257,66 @@ public static function normalizeTagAttributes(array $attributes): array
return $normalized;
}

/**
* Finds the first tag defined in some HTML that isn't a comment or DTD.
*
* @param string $html
* @param int $offset
* @return array The tag type and starting position
* @throws
*/
private static function _findTag(string $html, int $offset = 0): array
{
// Find the first HTML tag that isn't a DTD or a comment
if (!preg_match('/<(\/?\w+)/', $html, $match, PREG_OFFSET_CAPTURE, $offset) || $match[1][0][0] === '/') {
throw new InvalidArgumentException('Could not find an HTML tag in string: ' . $html);
}

return [strtolower($match[1][0]), $match[0][1]];
}

/**
* Appends or prepends HTML to the beginning of a string.
*
* @param string $tag
* @param string $html
* @param string $position
* @param string|null $ifExists
* @return string
*/
private static function _addToTagInternal(string $tag, string $html, string $position, string $ifExists = null): string
{
$info = static::parseTag($tag);

// Make sure it's not a void tag
if (!isset($info['htmlStart'])) {
throw new InvalidArgumentException("<{$info['type']}> can't have children.");
}

if ($ifExists) {
// See if we have a child of the same type
list($type) = self::_findTag($html);
$child = ArrayHelper::firstWhere($info['children'], 'type', $type, true);

if ($child) {
switch ($ifExists) {
case 'keep':
return $tag;
case 'replace':
return substr($tag, 0, $child['start']) .
$html .
substr($tag, $child['end']);
default:
throw new InvalidArgumentException('Invalid $ifExists value: ' . $ifExists);
}
}
}

return substr($tag, 0, $info[$position]) .
$html .
substr($tag, $info[$position]);
}

private static function _sortedDataAttributes(): array
{
if (self::$_sortedDataAttributes === null) {
Expand Down
44 changes: 44 additions & 0 deletions src/web/twig/Extension.php
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ public function getFilters(): array
$security = Craft::$app->getSecurity();

return [
new TwigFilter('append', [$this, 'appendFilter'], ['is_safe' => ['html']]),
new TwigFilter('ascii', [StringHelper::class, 'toAscii']),
new TwigFilter('atom', [$this, 'atomFilter'], ['needs_environment' => true]),
new TwigFilter('attr', [$this, 'attrFilter'], ['is_safe' => ['html']]),
Expand Down Expand Up @@ -238,6 +239,7 @@ public function getFilters(): array
new TwigFilter('parseRefs', [$this, 'parseRefsFilter']),
new TwigFilter('pascal', [$this, 'pascalFilter']),
new TwigFilter('percentage', [$formatter, 'asPercent']),
new TwigFilter('prepend', [$this, 'prependFilter'], ['is_safe' => ['html']]),
new TwigFilter('replace', [$this, 'replaceFilter']),
new TwigFilter('rss', [$this, 'rssFilter'], ['needs_environment' => true]),
new TwigFilter('snake', [$this, 'snakeFilter']),
Expand Down Expand Up @@ -448,6 +450,27 @@ public function parseRefsFilter($str, int $siteId = null): Markup
return TemplateHelper::raw($str);
}

/**
* Prepends HTML to the beginning of given tag.
*
* @param string $tag The HTML tag that `$html` should be prepended to
* @param string $html The HTML to prepend to `$tag`.
* @param string|null $ifExists What to do if `$tag` already contains a child of the same type as the element
* defined by `$html`. Set to `'keep'` if no action should be taken, or `'replace'` if it should be replaced
* by `$tag`.
* @return string The modified HTML
* @since 3.3.0
*/
public static function prependFilter(string $tag, string $html, string $ifExists = null): string
{
try {
return Html::prependToTag($tag, $html, $ifExists);
} catch (InvalidArgumentException $e) {
Craft::warning($e->getMessage(), __METHOD__);
return $tag;
}
}

/**
* Replaces Twig's |replace filter, adding support for passing in separate
* search and replace arrays.
Expand Down Expand Up @@ -507,6 +530,27 @@ public function dateFilter(TwigEnvironment $env, $date, string $format = null, $
return $formatted;
}

/**
* Appends HTML to the end of the given tag.
*
* @param string $tag The HTML tag that `$html` should be appended to
* @param string $html The HTML to append to `$tag`.
* @param string|null $ifExists What to do if `$tag` already contains a child of the same type as the element
* defined by `$html`. Set to `'keep'` if no action should be taken, or `'replace'` if it should be replaced
* by `$tag`.
* @return string The modified HTML
* @since 3.3.0
*/
public static function appendFilter(string $tag, string $html, string $ifExists = null): string
{
try {
return Html::appendToTag($tag, $html, $ifExists);
} catch (InvalidArgumentException $e) {
Craft::warning($e->getMessage(), __METHOD__);
return $tag;
}
}

/**
* Converts a date to the Atom format.
*
Expand Down
Loading

0 comments on commit 5ee3d5f

Please sign in to comment.