Skip to content
Merged
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
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ Liquid is a template engine with interesting advantages:

| PHP Liquid | Shopify Liquid |
|------------:|---------------:|
| v0.10 | v5.12 |
| v0.9 | v5.8 |
| v0.8 | v5.7 |
| v0.7 | v5.6 |
Expand Down Expand Up @@ -55,7 +56,13 @@ $environment = \Keepsuit\Liquid\EnvironmentFactory::new()
// set filesystem used to load templates
->setFilesystem(new \Keepsuit\Liquid\FileSystems\LocalFileSystem(__DIR__ . '/views'))
// set the resource limits
->setResourceLimits(new \Keepsuit\Liquid\ResourceLimits())
->setResourceLimits(new \Keepsuit\Liquid\Render\ResourceLimits(
renderLengthLimit: 100_000,
renderScoreLimit: 50_000,
assignScoreLimit: 5_000,
cumulativeRenderScoreLimit: 100_000,
cumulativeAssignScoreLimit: 10_000,
))
// register a custom extension
->addExtension(new CustomExtension())
// register a custom tag
Expand Down Expand Up @@ -285,6 +292,18 @@ $environment = \Keepsuit\Liquid\EnvironmentFactory::new()
$environment->addExtension(new CustomExtension());
```

## Resource limits

`Keepsuit\Liquid\Render\ResourceLimits` supports both per-render limits and cumulative resource limits.

- `renderLengthLimit`: limits the rendered output size for a render pass.
- `renderScoreLimit`: limits render work for a render pass.
- `assignScoreLimit`: limits assignment and capture work for a render pass.
- `cumulativeRenderScoreLimit`: limits total render work across a full render tree, including partial renders.
- `cumulativeAssignScoreLimit`: limits total assignment and capture work across a full render tree, including partial renders.

Per-render counters can be reset between renders. Cumulative counters are intended for a full render lifecycle so repeated partial renders can share the same budget.

## Custom tags and filters

By default, only the standard liquid tags and filters are available.
Expand Down
5 changes: 3 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,14 @@
],
"require": {
"php": "^8.2",
"ext-mbstring": "*"
"ext-mbstring": "*",
"symfony/polyfill-php85": "^1.33"
},
"require-dev": {
"laravel/pint": "^1.2",
"pestphp/pest": "^3.0 || ^4.0",
"pestphp/pest-plugin-arch": "^3.0 || ^4.0",
"phpbench/phpbench": "dev-master",
"phpbench/phpbench": "^1.4",
"phpstan/extension-installer": "^1.3",
"phpstan/phpstan": "^2.0",
"phpstan/phpstan-deprecation-rules": "^2.0",
Expand Down
12 changes: 7 additions & 5 deletions performance/Shopify/CustomFilters.php
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,9 @@ public function highlightActiveTag(string|int|float $tag, string $cssClass = 'ac

public function linkToAddTag(string|int|float $label, string $tag): string
{
$currentTags = $this->context->get('current_tags') ?? [];
assert(is_array($currentTags));
$currentTags = $this->context->get('current_tags');
$currentTags = is_array($currentTags) ? $currentTags : [];
$currentTags = array_values(array_filter($currentTags, is_string(...)));
$tags = array_unique([...$currentTags, $tag]);

return sprintf(
Expand All @@ -90,9 +91,10 @@ public function linkToAddTag(string|int|float $label, string $tag): string

public function linkToRemoveTag(string|int|float $label, string $tag): string
{
$currentTags = $this->context->get('current_tags') ?? [];
assert(is_array($currentTags));
$tags = array_filter($currentTags, fn ($t) => $t !== $tag);
$currentTags = $this->context->get('current_tags');
$currentTags = is_array($currentTags) ? $currentTags : [];
$currentTags = array_values(array_filter($currentTags, is_string(...)));
$tags = array_values(array_filter($currentTags, fn (string $currentTag) => $currentTag !== $tag));

return sprintf(
'<a title="Show tag %s" href="/collections/%s/%s">%s</a>',
Expand Down
63 changes: 52 additions & 11 deletions performance/Shopify/Database.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,24 +18,39 @@ public static function tables(): array
}

$database = (array) Yaml::parseFile(static::DATABASE_FILE_PATH);
$products = is_array($database['products'] ?? null) ? $database['products'] : [];
$collections = is_array($database['collections'] ?? null) ? $database['collections'] : [];
$blogs = is_array($database['blogs'] ?? null) ? $database['blogs'] : [];
$lineItems = is_array($database['line_items'] ?? null) ? $database['line_items'] : [];

$database['products'] = array_map(
function (array $product) use ($database) {
function (array $product) use ($collections) {
$collections = array_filter(
$database['collections'],
fn (array $collection) => Arr::first($collection['products'], fn (array $p) => $p['id'] === $product['id']) !== null
$collections,
fn (array $collection) => Arr::first(
is_array($collection['products'] ?? null) ? $collection['products'] : [],
fn (array $p) => $p['id'] === $product['id']
) !== null
);
$product['collections'] = array_values($collections);

return $product;
},
$database['products']
$products
);

$tables = [];
foreach ($database as $key => $values) {
$tables[$key] = array_reduce($values, function (array $acc, array $item) {
if (isset($item['handle'])) {
if (! is_array($values)) {
continue;
}

$tables[$key] = array_reduce($values, function (array $acc, mixed $item) {
if (! is_array($item)) {
return $acc;
}

if (isset($item['handle']) && is_string($item['handle'])) {
$acc[$item['handle']] = $item;
} else {
$acc[] = $item;
Expand All @@ -45,17 +60,43 @@ function (array $product) use ($database) {
}, []);
}

$tables['collection'] = Arr::first($database['collections']);
$tables['collection'] = Arr::first($collections);
$tables['product'] = Arr::first($database['products']);
$tables['blog'] = Arr::first($database['blogs']);
$tables['blog'] = Arr::first($blogs);
$articles = $tables['blog']['articles'] ?? [];
assert(is_array($articles));
$tables['article'] = Arr::first($articles);

$tables['cart'] = [
'total_price' => array_reduce($tables['line_items'], fn (int $total, array $item) => $total + $item['line_price'] * $item['quantity'], 0),
'item_count' => array_reduce($tables['line_items'], fn (int $total, array $item) => $total + $item['quantity'], 0),
'items' => $database['line_items'],
'total_price' => array_reduce($lineItems, function (int $total, mixed $item): int {
if (! is_array($item)) {
return $total;
}

$linePrice = $item['line_price'] ?? 0;
$quantity = $item['quantity'] ?? 0;
if (! is_int($linePrice) && ! is_float($linePrice)) {
$linePrice = 0;
}
if (! is_int($quantity) && ! is_float($quantity)) {
$quantity = 0;
}

return (int) ($total + $linePrice * $quantity);
}, 0),
'item_count' => array_reduce($lineItems, function (int $total, mixed $item): int {
if (! is_array($item)) {
return $total;
}

$quantity = $item['quantity'] ?? 0;
if (! is_int($quantity) && ! is_float($quantity)) {
$quantity = 0;
}

return (int) ($total + $quantity);
}, 0),
'items' => $lineItems,
];

return static::$tables = $tables;
Expand Down
10 changes: 0 additions & 10 deletions phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,11 @@ parameters:
count: 1
path: performance/Shopify/Database.php

-
message: "#^Parameter \\#1 \\$array of function array_filter expects array, mixed given\\.$#"
count: 1
path: performance/Shopify/Database.php

-
message: "#^Parameter \\#1 \\$callback of function array_map expects \\(callable\\(mixed\\)\\: mixed\\)\\|null, Closure\\(array\\)\\: non\\-empty\\-array given\\.$#"
count: 1
path: performance/Shopify/Database.php

-
message: "#^Parameter \\#2 \\$array of function array_map expects array, mixed given\\.$#"
count: 1
path: performance/Shopify/Database.php

-
message: "#^Parameter \\#2 \\$callback of function array_filter expects \\(callable\\(mixed\\)\\: bool\\)\\|null, Closure\\(array\\)\\: bool given\\.$#"
count: 1
Expand Down
2 changes: 1 addition & 1 deletion src/Exceptions/SyntaxException.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ public static function unexpectedIdentifier(string $expected, string $given): Sy

public static function invalidExpression(string $expression): SyntaxException
{
return new SyntaxException(sprintf('%s is not a valid expression', $expression));
return new SyntaxException(sprintf('`%s` is not a valid expression', $expression));
}

public static function unexpectedCharacter(string $character): SyntaxException
Expand Down
14 changes: 14 additions & 0 deletions src/Filters/StandardFilters.php
Original file line number Diff line number Diff line change
Expand Up @@ -551,6 +551,20 @@ public function rstrip(?string $input): string
return rtrim($input ?? '');
}

/**
* Trims surrounding whitespace and collapses internal whitespace runs to single spaces.
*/
public function squish(?string $input): string
{
$input = trim($input ?? '');

if ($input === '') {
return '';
}

return preg_replace('/\s+/', ' ', $input) ?? $input;
}

/**
* Strips all HTML tags from a string.
*/
Expand Down
2 changes: 1 addition & 1 deletion src/Nodes/Variable.php
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ protected function renderOutput(mixed $output): string
}

if (is_array($output)) {
return implode('', $output);
return implode('', array_map($this->renderOutput(...), $output));
}

if (is_object($output) && method_exists($output, '__toString')) {
Expand Down
8 changes: 8 additions & 0 deletions src/Parse/ArgumentParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

namespace Keepsuit\Liquid\Parse;

use Keepsuit\Liquid\Exceptions\SyntaxException;

/**
* @phpstan-import-type Expression from ExpressionParser
*
Expand All @@ -15,9 +17,15 @@ public function __construct(

/**
* @return Argument
*
* @throws SyntaxException
*/
public function parseArgument(): mixed
{
if ($this->tokenStream->isEnd()) {
throw SyntaxException::unexpectedEndOfTemplate();
}

if (
$this->tokenStream->look(TokenType::Identifier)
&& $this->tokenStream->look(TokenType::Colon, 1)
Expand Down
11 changes: 9 additions & 2 deletions src/Parse/Lexer.php
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,10 @@ protected function lexBlock(): void
while (preg_match(LexerOptions::blockEndRegex(), $this->source, $matches, offset: $this->cursor) !== 1) {
$this->lexExpression();

$lastToken = $this->tokens[array_key_last($this->tokens)];
$lastToken = array_last($this->tokens);
if ($lastToken === null) {
throw SyntaxException::unexpectedEndOfTemplate();
}

if ($tag === null && $lastToken->type === TokenType::Identifier) {
$tag = $lastToken;
Expand All @@ -188,7 +191,11 @@ protected function lexBlock(): void
}

// If the last token is a block start, we remove the node
$lastToken = $this->tokens[array_key_last($this->tokens)];
$lastToken = array_last($this->tokens);
if ($lastToken === null) {
throw SyntaxException::unexpectedEndOfTemplate();
}

if ($lastToken->type === TokenType::BlockStart) {
array_pop($this->tokens);
} else {
Expand Down
2 changes: 2 additions & 0 deletions src/Parse/TokenStream.php
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,8 @@ public function expression(): mixed

/**
* @return Argument
*
* @throws SyntaxException
*/
public function argument(): mixed
{
Expand Down
10 changes: 10 additions & 0 deletions src/Parse/VariableParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ public function __construct(
protected TokenStream $tokenStream
) {}

/**
* @throws SyntaxException
*/
public function parseVariable(): Variable
{
$currentToken = $this->tokenStream->current();
Expand All @@ -34,11 +37,18 @@ public function parseVariable(): Variable
))->setLineNumber($currentToken->lineNumber);
}

/**
* @throws SyntaxException
*/
protected function parseFilterArgs(): array
{
$filterArgs = [$this->tokenStream->argument()];

while ($this->tokenStream->consumeOrFalse(TokenType::Comma)) {
if ($this->tokenStream->isEnd() || $this->tokenStream->look(TokenType::VariableEnd)) {
throw SyntaxException::unexpectedEndOfTemplate();
}

$filterArgs[] = $this->tokenStream->argument();
}

Expand Down
2 changes: 1 addition & 1 deletion src/Render/RenderContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,7 @@ public function setData(string $name, mixed $value): mixed

public function setToActiveScope(string $key, mixed $value): array
{
$index = array_key_last($this->scopes);
$index = count($this->scopes) - 1;

return $this->scopes[$index] = [
...$this->scopes[$index],
Expand Down
Loading
Loading