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
95 changes: 95 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ allowing you to integrate PHP code if needed.
- [If Attribute](#if-attribute)
- [Loop](#loops)
- [Inline](#inline)
- [Short Syntax](#short-syntax)
- [Command Line](#command-line)
- [Compile](#compile)
- [Lint](#lint)
- [Filters](#filters)
- [Chain Filters](#chain-filters)
- [Filters with Arguments](#filters-with-arguments)
Expand Down Expand Up @@ -163,6 +167,97 @@ Pesto also allows you to use inline control flow directives.
</ul>
```

## Short Syntax
Every `php-*` attribute has a shorter `p:*` alias, both forms work everywhere
and can be mixed in the same template.

| Long form | Short form |
|---------------|-------------|
| `php-if` | `p:if` |
| `php-elseif` | `p:elseif` |
| `php-else` | `p:else` |
| `php-foreach` | `p:foreach` |
| `php-partial` | `p:partial` |
| `php-with` | `p:with` |
| `php-slot` | `p:slot` |

```html
<ul p:if="count($items) > 0">
<li p:foreach="$items as $item">{{ $item }}</li>
</ul>
<p p:else>No items</p>
```

If an element has both forms of the same directive, the long form wins.
Since no client-side framework claims the `p:` prefix, it is safe to combine
with Vue, Alpine.js, or Lit bindings.

## Command Line
Pesto ships with a `pesto` binary (installed at `vendor/bin/pesto`) to validate
and inspect templates without rendering them.

```shell
vendor/bin/pesto help
```

| Command | Description |
|----------------------------------|----------------------------------------------------|
| `pesto compile <template_path>` | Validate and compile a template, print the result |
| `pesto -c <template_path>` | Shorthand for `compile` |
| `pesto lint <path> [<path>...]` | Validate template files or directories |
| `pesto help` | Show the help message |

Both commands read the template from stdin when no path is given (or with `-`).

### Compile
Compiles a template and prints the resulting PHP, so you can see exactly
what Pesto generates:

```shell
echo '<li p:foreach="$items as $item" p:if="$item->visible">{{ $item->name | title }}</li>' | vendor/bin/pesto compile
```
```php
<?php foreach($items as $item): ?><?php if ($item->visible): ?><li><?= $__pesto->output($item->name, ['title', 'escape']) ?></li><?php endif; ?><?php endforeach; ?>
```

If the template is invalid, the errors are printed and the command exits with `1`.

### Lint
Validates templates without rendering them: it checks for unclosed `{{ }}`
expressions, orphan `php-else`/`php-elseif` directives, unprocessed directives,
and PHP syntax errors in the compiled output.

```shell
# Single files or directories (scanned recursively for .html and .php)
vendor/bin/pesto lint views/home.php
vendor/bin/pesto lint views/ emails/

# Or from stdin
echo '<p php-else>Guest</p>' | vendor/bin/pesto lint
```
```
✗ <stdin>
- Orphan "php-else" directive on line 1: it must be an immediate sibling of a "php-if" element.
```

With `--views <dir>` the linter also verifies that every `php-partial`
reference exists in the templates root. Without explicit paths, it lints
the whole directory:

```shell
vendor/bin/pesto lint --views views/
```
```
✓ views/home.php
✓ views/layouts/app.php
✓ views/partials/nav.php

Linted 3 templates: no errors found.
```

The exit code is `0` when all templates pass and `1` otherwise, so `lint`
fits directly into a CI pipeline.

## Filters
Pesto provides a simple way to apply filters to variables using the pipe operator,
you can define your own filters.
Expand Down
202 changes: 101 additions & 101 deletions benchmarks/chart.html

Large diffs are not rendered by default.

25 changes: 25 additions & 0 deletions bin/pesto
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#!/usr/bin/env php
<?php

declare(strict_types=1);

use Millancore\Pesto\Console\Application;

$autoloadFiles = [
__DIR__.'/../vendor/autoload.php',
__DIR__.'/../../../autoload.php',
];

foreach ($autoloadFiles as $autoloadFile) {
if (file_exists($autoloadFile)) {
require $autoloadFile;
break;
}
}

if (!class_exists(Application::class)) {
fwrite(STDERR, 'Composer autoloader not found. Run "composer install".'.PHP_EOL);
exit(1);
}

exit((new Application())->run($argv));
3 changes: 3 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
"description": "PHP View Engine",
"type": "library",
"license": "MIT",
"bin": [
"bin/pesto"
],
"authors": [
{
"name": "Juan Millan",
Expand Down
11 changes: 11 additions & 0 deletions src/Compiler/Pass/Pass.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,17 @@ protected function hasDirective(Node $node, string $directive): bool
return false;
}

protected function getDirectiveAttributeName(Node $node, string $directive): ?string
{
foreach (self::PREFIXES as $prefix) {
if ($node->hasAttribute($prefix.$directive)) {
return $prefix.$directive;
}
}

return null;
}

protected function getDirective(Node $node, string $directive): ?string
{
foreach (self::PREFIXES as $prefix) {
Expand Down
49 changes: 49 additions & 0 deletions src/Compiler/Pass/ValidationPass.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

declare(strict_types=1);

namespace Millancore\Pesto\Compiler\Pass;

use Millancore\Pesto\Contract\CompilerPass;
use Millancore\Pesto\Dom\Node;
use Millancore\Pesto\Exception\CompilerException;
use Millancore\Pesto\Pesto;

/**
* Runs last: any directive attribute still present was not consumed by a
* previous pass and would leak into the rendered HTML.
*/
class ValidationPass extends Pass implements CompilerPass
{
private const array DIRECTIVES = ['if', 'elseif', 'else', 'foreach', 'partial', 'with', 'slot'];

public function compile(Pesto $pesto): void
{
$selector = implode(', ', array_map(
fn (string $directive) => $this->directiveSelector($directive),
self::DIRECTIVES,
));

$errors = [];

$pesto->find($selector)->each(function (Node $node) use (&$errors) {
foreach (self::DIRECTIVES as $directive) {
$attribute = $this->getDirectiveAttributeName($node, $directive);

if ($attribute === null) {
continue;
}

$tag = strtolower($node->getDomNode()->nodeName);

$errors[] = $directive === 'else' || $directive === 'elseif'
? sprintf('Orphan "%s" directive on <%s>: it must be an immediate sibling of a "php-if" element.', $attribute, $tag)
: sprintf('Unprocessed "%s" directive on <%s>.', $attribute, $tag);
}
});

if ($errors !== []) {
throw new CompilerException(implode("\n", $errors));
}
}
}
47 changes: 42 additions & 5 deletions src/Compiler/PestoCompiler.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,20 @@
namespace Millancore\Pesto\Compiler;

use Millancore\Pesto\Contract\Compiler;
use Millancore\Pesto\Exception\CompilerException;

class PestoCompiler implements Compiler
{
private SyntaxCompiler $syntaxCompiler;
private DomCompiler $nodeCompiler;

public function __construct()
{
/**
* @param bool $validate reject templates with unclosed "{{" expressions
* or directives no pass consumed
*/
public function __construct(
private readonly bool $validate = true,
) {
$this->syntaxCompiler = new SyntaxCompiler();

$domPasses = [
Expand All @@ -22,18 +28,49 @@ public function __construct()
new Pass\SlotPass(),
new Pass\ContextPass(),
new Pass\UnwrapPass(),
// ...
];

if ($this->validate) {
$domPasses[] = new Pass\ValidationPass();
}

$this->nodeCompiler = new DomCompiler($domPasses);
}

/**
* @throws CompilerException
*/
public function compile(string $source): string
{
if ($this->validate) {
$this->assertExpressionsAreClosed($source);
}

$source = $this->nodeCompiler->compile($source);

return $source = $this->syntaxCompiler->compile($source);
return $this->syntaxCompiler->compile($source);
}

/**
* A "{{" without a following "}}" never gets closed. Pairs are consumed
* left to right, mirroring the syntax compiler's non-greedy matching.
*
* @throws CompilerException
*/
private function assertExpressionsAreClosed(string $source): void
{
$pos = 0;

while (($start = strpos($source, '{{', $pos)) !== false) {
$end = strpos($source, '}}', $start + 2);

if ($end === false) {
$line = $start === 0 ? 1 : substr_count($source, "\n", 0, $start) + 1;

throw new CompilerException(sprintf('Unclosed "{{" expression on line %d: missing matching "}}".', $line));
}

// return $this->nodeCompiler->compile($source);
$pos = $end + 2;
}
}
}
75 changes: 75 additions & 0 deletions src/Console/Application.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<?php

declare(strict_types=1);

namespace Millancore\Pesto\Console;

class Application
{
/** @var resource */
private $stdout;

/** @var resource */
private $stderr;

/** @var resource */
private $stdin;

/**
* @param resource $stdout
* @param resource $stderr
* @param resource $stdin
*/
public function __construct($stdout = STDOUT, $stderr = STDERR, $stdin = STDIN)
{
$this->stdout = $stdout;
$this->stderr = $stderr;
$this->stdin = $stdin;
}

/**
* @param array<string> $argv
*/
public function run(array $argv): int
{
$command = $argv[1] ?? null;
$args = array_slice($argv, 2);

return match ($command) {
'compile', '-c' => (new CompileCommand($this->stdout, $this->stderr, $this->stdin))->run($args),
'lint' => (new LintCommand($this->stdout, $this->stderr, $this->stdin))->run($args),
null, 'help', '-h', '--help' => $this->showHelp(),
default => $this->showUnknownCommand($command),
};
}

private function showHelp(): int
{
fwrite($this->stdout, <<<'HELP'
Pesto - PHP View Engine

Usage:
pesto compile <template_path> Validate and compile a template, print the result
pesto -c <template_path> Shorthand for compile
pesto lint <path> [<path>...] Validate template files or directories
pesto help Show this help message

Options:
--views <dir> Templates root; verifies php-partial references exist.
Without paths, lints the whole directory.

Both commands read the template from stdin when no path is given (or with "-"):
echo '<p p:if="$ok">Hi</p>' | pesto compile

HELP);

return 0;
}

private function showUnknownCommand(string $command): int
{
fwrite($this->stderr, sprintf('Unknown command "%s". Run "pesto help" for usage.', $command).PHP_EOL);

return 1;
}
}
Loading
Loading