Initial commit
This commit is contained in:
parent
03ce6cfda8
commit
c137333ead
79
.gitignore
vendored
79
.gitignore
vendored
|
|
@ -1,87 +1,14 @@
|
||||||
# ---> JetBrains
|
.idea/
|
||||||
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
|
|
||||||
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
|
|
||||||
|
|
||||||
# User-specific stuff
|
|
||||||
.idea/**/workspace.xml
|
|
||||||
.idea/**/tasks.xml
|
|
||||||
.idea/**/usage.statistics.xml
|
|
||||||
.idea/**/dictionaries
|
|
||||||
.idea/**/shelf
|
|
||||||
|
|
||||||
# AWS User-specific
|
|
||||||
.idea/**/aws.xml
|
|
||||||
|
|
||||||
# Generated files
|
|
||||||
.idea/**/contentModel.xml
|
|
||||||
|
|
||||||
# Sensitive or high-churn files
|
|
||||||
.idea/**/dataSources/
|
|
||||||
.idea/**/dataSources.ids
|
|
||||||
.idea/**/dataSources.local.xml
|
|
||||||
.idea/**/sqlDataSources.xml
|
|
||||||
.idea/**/dynamic.xml
|
|
||||||
.idea/**/uiDesigner.xml
|
|
||||||
.idea/**/dbnavigator.xml
|
|
||||||
|
|
||||||
# Gradle
|
|
||||||
.idea/**/gradle.xml
|
|
||||||
.idea/**/libraries
|
|
||||||
|
|
||||||
# Gradle and Maven with auto-import
|
|
||||||
# When using Gradle or Maven with auto-import, you should exclude module files,
|
|
||||||
# since they will be recreated, and may cause churn. Uncomment if using
|
|
||||||
# auto-import.
|
|
||||||
# .idea/artifacts
|
|
||||||
# .idea/compiler.xml
|
|
||||||
# .idea/jarRepositories.xml
|
|
||||||
# .idea/modules.xml
|
|
||||||
# .idea/*.iml
|
|
||||||
# .idea/modules
|
|
||||||
# *.iml
|
|
||||||
# *.ipr
|
|
||||||
|
|
||||||
# CMake
|
|
||||||
cmake-build-*/
|
cmake-build-*/
|
||||||
|
|
||||||
# Mongo Explorer plugin
|
|
||||||
.idea/**/mongoSettings.xml
|
|
||||||
|
|
||||||
# File-based project format
|
|
||||||
*.iws
|
*.iws
|
||||||
|
|
||||||
# IntelliJ
|
|
||||||
out/
|
out/
|
||||||
|
|
||||||
# mpeltonen/sbt-idea plugin
|
|
||||||
.idea_modules/
|
.idea_modules/
|
||||||
|
|
||||||
# JIRA plugin
|
|
||||||
atlassian-ide-plugin.xml
|
atlassian-ide-plugin.xml
|
||||||
|
|
||||||
# Cursive Clojure plugin
|
|
||||||
.idea/replstate.xml
|
|
||||||
|
|
||||||
# SonarLint plugin
|
|
||||||
.idea/sonarlint/
|
|
||||||
|
|
||||||
# Crashlytics plugin (for Android Studio and IntelliJ)
|
|
||||||
com_crashlytics_export_strings.xml
|
com_crashlytics_export_strings.xml
|
||||||
crashlytics.properties
|
crashlytics.properties
|
||||||
crashlytics-build.properties
|
crashlytics-build.properties
|
||||||
fabric.properties
|
fabric.properties
|
||||||
|
|
||||||
# Editor-based Rest Client
|
|
||||||
.idea/httpRequests
|
|
||||||
|
|
||||||
# Android studio 3.1+ serialized cache file
|
|
||||||
.idea/caches/build_file_checksums.ser
|
|
||||||
|
|
||||||
# ---> Composer
|
|
||||||
composer.phar
|
composer.phar
|
||||||
/vendor/
|
/vendor/
|
||||||
|
.junie/
|
||||||
# Commit your application's lock file https://getcomposer.org/doc/01-basic-usage.md#commit-your-composer-lock-file-to-version-control
|
.phpunit.cache/
|
||||||
# You may choose to ignore a library lock file http://getcomposer.org/doc/02-libraries.md#lock-file
|
|
||||||
# composer.lock
|
|
||||||
|
|
||||||
52
CODE_OF_CONDUCT.md
Normal file
52
CODE_OF_CONDUCT.md
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
# Code of Conduct
|
||||||
|
|
||||||
|
We are committed to providing a friendly, safe, and welcoming environment for all. This Code of Conduct is adapted from the Contributor Covenant.
|
||||||
|
|
||||||
|
## Our Pledge
|
||||||
|
|
||||||
|
We as members, contributors, and leaders pledge to make participation in our community a harassment‑free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio‑economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
|
||||||
|
|
||||||
|
## Our Standards
|
||||||
|
|
||||||
|
Examples of behavior that contributes to a positive environment include:
|
||||||
|
- Using welcoming and inclusive language
|
||||||
|
- Being respectful of differing viewpoints and experiences
|
||||||
|
- Gracefully accepting constructive criticism
|
||||||
|
- Focusing on what is best for the community
|
||||||
|
- Showing empathy towards other community members
|
||||||
|
|
||||||
|
Examples of unacceptable behavior include:
|
||||||
|
- The use of sexualized language or imagery and unwelcome sexual attention or advances
|
||||||
|
- Trolling, insulting/derogatory comments, and personal or political attacks
|
||||||
|
- Public or private harassment
|
||||||
|
- Publishing others’ private information without explicit permission
|
||||||
|
- Other conduct which could reasonably be considered inappropriate in a professional setting
|
||||||
|
|
||||||
|
## Responsibilities and Enforcement
|
||||||
|
|
||||||
|
Project maintainers are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
|
||||||
|
|
||||||
|
Maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to temporarily or permanently ban any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This Code of Conduct applies within all project spaces and also applies when an individual is officially representing the project in public spaces.
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
|
||||||
|
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the project team at conduct@your-domain.example (replace with your contact). All complaints will be reviewed and investigated promptly and fairly.
|
||||||
|
|
||||||
|
All maintainers are obligated to respect the privacy and security of the reporter of any incident.
|
||||||
|
|
||||||
|
## Enforcement Guidelines
|
||||||
|
|
||||||
|
Community leaders will follow these guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
|
||||||
|
|
||||||
|
1. Correction — A private, written warning with clarity around the nature of the violation and an explanation of why the behavior was inappropriate.
|
||||||
|
2. Warning — A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time.
|
||||||
|
3. Temporary Ban — A temporary ban from any sort of interaction or public communication with the community for a specified period of time.
|
||||||
|
4. Permanent Ban — A permanent ban from any sort of public interaction within the community.
|
||||||
|
|
||||||
|
## Attribution
|
||||||
|
|
||||||
|
This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 2.1.
|
||||||
42
CONTRIBUTING.md
Normal file
42
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
Contributing to Eyrie Templates
|
||||||
|
|
||||||
|
Thanks for your interest in improving Eyrie Templates! This guide explains how to propose changes, report issues, and build the project.
|
||||||
|
|
||||||
|
Code of Conduct
|
||||||
|
By participating, you agree to abide by our Code of Conduct (see CODE_OF_CONDUCT.md).
|
||||||
|
|
||||||
|
Getting started
|
||||||
|
- Fork the repository and create a topic branch off main.
|
||||||
|
- Use clear commit messages and keep changes focused.
|
||||||
|
- For larger proposals, open an issue first to discuss the approach.
|
||||||
|
|
||||||
|
Development setup
|
||||||
|
- PHP: 8.1+ (proposed minimum; confirm in SPEC.md).
|
||||||
|
- Install dependencies via Composer once added to the project.
|
||||||
|
- Run linters/formatters if configured.
|
||||||
|
|
||||||
|
Pull requests
|
||||||
|
- Describe the problem and solution clearly; include screenshots for UX-facing changes.
|
||||||
|
- Add tests where applicable (parsing, rendering, loaders, escaping, security, components/props).
|
||||||
|
- Update documentation (README.md, SPEC.md) as needed.
|
||||||
|
- Ensure CI passes before requesting review.
|
||||||
|
|
||||||
|
Commit message style (suggested)
|
||||||
|
- Use the imperative mood: "Add X", "Fix Y".
|
||||||
|
- Reference issues: Fixes #123 or Refs #456.
|
||||||
|
|
||||||
|
Issue reports
|
||||||
|
- Include steps to reproduce, expected vs actual behavior, and environment details.
|
||||||
|
- For parser/runtime errors, include a minimal template snippet and context data.
|
||||||
|
|
||||||
|
Security
|
||||||
|
- Do not file security issues publicly. See SECURITY.md for private reporting instructions.
|
||||||
|
|
||||||
|
Style and design principles
|
||||||
|
- Security by default: auto-escaping on, minimal use of safe.
|
||||||
|
- Predictable behavior: no implicit state; explicit configuration.
|
||||||
|
- Maintain a small, coherent core; prefer helpers/filters/components over new syntax.
|
||||||
|
- Components are pure render units: no filesystem/network access and no mutation of outer context.
|
||||||
|
|
||||||
|
License
|
||||||
|
By contributing, you agree that your contributions will be licensed under the repository’s LICENSE.
|
||||||
59
MILESTONES.md
Normal file
59
MILESTONES.md
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
# Milestones
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
- [x] [Foundation](#foundation)
|
||||||
|
- [x] [Core Rendering](#core-rendering)
|
||||||
|
- [x] [Control Structures](#control-structures)
|
||||||
|
- [x] [Template Inheritance](#template-inheritance)
|
||||||
|
- [x] [Components and Partials](#components-and-partials)
|
||||||
|
- [x] [Advanced Features](#advanced-features)
|
||||||
|
- [x] [Performance and Optimization](#performance-and-optimization)
|
||||||
|
- [x] [Documentation](#documentation)
|
||||||
|
- [x] [Production Release v0.1.0](#production-release-v010)
|
||||||
|
|
||||||
|
## Foundation
|
||||||
|
- [x] Project initialization (Composer, PHPUnit, Directory structure)
|
||||||
|
- [x] Template Loader implementation
|
||||||
|
- [x] Basic Configuration system
|
||||||
|
|
||||||
|
## Core Rendering
|
||||||
|
- [x] Lexer for Eyrie syntax
|
||||||
|
- [x] Parser for expressions and output tags
|
||||||
|
- [x] Auto-escaping implementation
|
||||||
|
- [x] Basic variable and expression output (`<< >>`)
|
||||||
|
|
||||||
|
## Control Structures
|
||||||
|
- [x] Function calls and control blocks (`<( )>`)
|
||||||
|
- [x] If/Elseif/Else logic
|
||||||
|
- [x] Foreach loop implementation
|
||||||
|
- [x] Range loop implementation
|
||||||
|
|
||||||
|
## Template Inheritance
|
||||||
|
- [x] Extends mechanism (`[[ extends ]]`)
|
||||||
|
- [x] Block definition and overrides (`[[ block ]]`)
|
||||||
|
- [x] Super call implementation (`[[ super ]]`)
|
||||||
|
|
||||||
|
## Components and Partials
|
||||||
|
- [x] Partial inclusion (`[[ include ]]`)
|
||||||
|
- [x] Component rendering (`<@ />`)
|
||||||
|
- [x] Component props handling
|
||||||
|
|
||||||
|
## Advanced Features
|
||||||
|
- [x] Filters implementation (`|`)
|
||||||
|
- [x] Custom Helpers support
|
||||||
|
- [x] Custom Tags support
|
||||||
|
|
||||||
|
## Performance and Optimization
|
||||||
|
- [x] Compiled template caching
|
||||||
|
- [x] Performance benchmarking
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
- [x] User-friendly README (Installation, Quick Start, Syntax Guide)
|
||||||
|
- [x] Technical Specifications (SPECS.md)
|
||||||
|
- [x] Security Policy and Threat Model
|
||||||
|
|
||||||
|
## Production Release v0.1.0
|
||||||
|
- [x] Final specification audit
|
||||||
|
- [x] Security baseline verified (Threat Model)
|
||||||
|
- [x] Zero-warning test suite
|
||||||
|
- [x] Comprehensive user documentation
|
||||||
182
README.md
182
README.md
|
|
@ -1,3 +1,181 @@
|
||||||
# Eyrie-Templates
|
# Eyrie Templates
|
||||||
|
|
||||||
The templating engine for the Phred Framework
|
Eyrie is a fast, safe, and ergonomic server-side templating engine for PHP 8.2+. It is designed for the Phred Framework but can be used as a standalone library.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **High-Performance:** Templates are compiled to native PHP code and cached.
|
||||||
|
- **Security-First:** Automatic output escaping by default to prevent XSS.
|
||||||
|
- **Minimal Syntax:** Clean and predictable syntax optimized for HTML.
|
||||||
|
- **DRY Composition:** Robust template inheritance and reusable components.
|
||||||
|
- **Expressive Control:** Support for conditionals, loops, and range-based iteration.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Install Eyrie via Composer:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
composer require getphred/eyrie
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Eyrie\Engine;
|
||||||
|
use Eyrie\Loader\FileLoader;
|
||||||
|
|
||||||
|
// 1. Configure the loader
|
||||||
|
$loader = new FileLoader(['./templates']);
|
||||||
|
|
||||||
|
// 2. Initialize the engine
|
||||||
|
$engine = new Engine($loader, [
|
||||||
|
'cache' => './cache',
|
||||||
|
'debug' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 3. Render a template
|
||||||
|
echo $engine->render('welcome', ['name' => 'Phred']);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Template Syntax
|
||||||
|
|
||||||
|
### Output
|
||||||
|
Use `<< >>` to output variables or expressions. All output is automatically escaped.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<h1>Hello, << name >>!</h1>
|
||||||
|
<p>2 + 2 = << 2 + 2 >></p>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Filters
|
||||||
|
Modify output using the pipe `|` operator.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<p><< title | upper >></p>
|
||||||
|
<p><< bio | raw >></p> <!-- Use 'raw' to bypass auto-escaping (be careful!) -->
|
||||||
|
```
|
||||||
|
|
||||||
|
### Control Structures
|
||||||
|
Control blocks use the `<( )>` syntax.
|
||||||
|
|
||||||
|
#### Conditionals
|
||||||
|
```html
|
||||||
|
<( if user.isAdmin )>
|
||||||
|
<p>Welcome, Admin!</p>
|
||||||
|
<( elseif user.isMember )>
|
||||||
|
<p>Welcome, Member!</p>
|
||||||
|
<( else )>
|
||||||
|
<p>Welcome, Guest!</p>
|
||||||
|
<( endif )>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Loops
|
||||||
|
```html
|
||||||
|
<ul>
|
||||||
|
<( foreach item in items )>
|
||||||
|
<li><< item >></li>
|
||||||
|
<( endforeach )>
|
||||||
|
</ul>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Range Loops
|
||||||
|
The `loop` tag provides a convenient way to iterate over a range. It also provides a `loop` variable with metadata.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<( loop from 1 to 5 )>
|
||||||
|
<p>Iteration << loop.index >> of << loop.length >></p>
|
||||||
|
<( if loop.first )>First item!<( endif )>
|
||||||
|
<( if loop.last )>Last item!<( endif )>
|
||||||
|
<( endloop )>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Template Inheritance
|
||||||
|
Eyrie supports powerful template inheritance using layouts and blocks.
|
||||||
|
|
||||||
|
#### Layout (`layouts/base.eyrie.php`)
|
||||||
|
```html
|
||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>[[ block title ]]My Site[[ endblock ]]</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
[[ block content ]][[ endblock ]]
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Page (`home.eyrie.php`)
|
||||||
|
```html
|
||||||
|
[[ extends "layouts.base" ]]
|
||||||
|
|
||||||
|
[[ block title ]]Home Page - [[ super ]][[ endblock ]]
|
||||||
|
|
||||||
|
[[ block content ]]
|
||||||
|
<h1>Welcome Home</h1>
|
||||||
|
[[ endblock ]]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Components
|
||||||
|
Components are reusable pieces of UI that use a custom tag-like syntax.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<@ Alert type="success" message="Operation successful!" />
|
||||||
|
```
|
||||||
|
|
||||||
|
### Template Resolution
|
||||||
|
Eyrie uses dot-notation to resolve template names relative to the configured loader paths.
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Resolves to: ./templates/emails/welcome.eyrie.php
|
||||||
|
$engine->render('emails.welcome');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Partials
|
||||||
|
Include other template files directly.
|
||||||
|
|
||||||
|
```html
|
||||||
|
[[ include "partials.header" ]]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Loader
|
||||||
|
The `FileLoader` accepts an array of paths and an optional file extension (default is `.eyrie.php`).
|
||||||
|
|
||||||
|
```php
|
||||||
|
$loader = new FileLoader(['./templates', './shared'], '.html');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Engine Options
|
||||||
|
- `cache`: Path to the directory where compiled templates will be stored.
|
||||||
|
- `debug`: If `true`, templates are recompiled on every request (default `false`).
|
||||||
|
|
||||||
|
## Advanced Usage
|
||||||
|
|
||||||
|
### Custom Helpers
|
||||||
|
You can register custom PHP functions to be used inside your templates.
|
||||||
|
|
||||||
|
```php
|
||||||
|
$engine->addHelper('greet', function($name) {
|
||||||
|
return "Hello, $name!";
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Template:
|
||||||
|
```html
|
||||||
|
<< greet(name) >>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Global Variables
|
||||||
|
Add variables that are available to all templates.
|
||||||
|
|
||||||
|
```php
|
||||||
|
$engine->addGlobal('app_name', 'My Eyrie App');
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|
|
||||||
49
SECURITY.md
Normal file
49
SECURITY.md
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
# Security Policy (Draft)
|
||||||
|
|
||||||
|
## Supported versions
|
||||||
|
|
||||||
|
- Until first stable release, only the latest minor/patch is supported.
|
||||||
|
- Post‑1.0: last two minor versions receive security fixes.
|
||||||
|
|
||||||
|
## Reporting a vulnerability
|
||||||
|
|
||||||
|
- Please email security reports to: security@your-domain.example (replace with your contact)
|
||||||
|
- Provide a minimal reproduction, affected version, environment details, and impact if possible.
|
||||||
|
- We aim to acknowledge within 3 business days and provide a timeline after triage.
|
||||||
|
|
||||||
|
## Disclosure process
|
||||||
|
|
||||||
|
1. Private triage and fix development.
|
||||||
|
2. Coordinated disclosure with reporter; optional CVE request if applicable.
|
||||||
|
3. Security release notes summarizing impact, severity, and upgrade guidance.
|
||||||
|
|
||||||
|
## Secure development guidelines
|
||||||
|
|
||||||
|
- Auto‑escape by default; minimize usage of `safe` and review all instances.
|
||||||
|
- No dynamic eval; no template‑driven file/network access.
|
||||||
|
- Validate and normalize template names; prohibit `..` traversal and absolute paths unless namespaced.
|
||||||
|
- Keep clear separation: helpers/filters are whitelisted and reviewed.
|
||||||
|
- Enforce depth/iteration/time and size limits with safe defaults.
|
||||||
|
- Prefer exceptions with sanitized messages over warnings or silent failures.
|
||||||
|
- Components:
|
||||||
|
- Register components explicitly; unregistered components must not be invokable.
|
||||||
|
- Treat component props as untrusted input; validate types and ranges before use.
|
||||||
|
- Components must not perform filesystem/network access by default and must not mutate outer template context.
|
||||||
|
- Cap component recursion/nesting depth to avoid DoS.
|
||||||
|
|
||||||
|
## Secrets and sensitive data
|
||||||
|
|
||||||
|
- Do not pass secrets (tokens, passwords) through template contexts unless absolutely necessary; prefer redacted representations.
|
||||||
|
- Ensure logs and error messages do not include raw context values in production.
|
||||||
|
|
||||||
|
## Dependencies and updates
|
||||||
|
|
||||||
|
- Target PHP 8.1+ (finalize at release); use supported versions only.
|
||||||
|
- Pin constraints to secure versions; audit dependencies regularly (e.g., `composer audit`).
|
||||||
|
- Security fixes are backported per Supported versions policy.
|
||||||
|
|
||||||
|
## Hardening recommendations for deployers
|
||||||
|
|
||||||
|
- Place cache directory outside web root with perms `0700`.
|
||||||
|
- Run PHP under least‑privilege account; restrict template directories to read‑only for the runtime.
|
||||||
|
- Disable display of errors in production; enable structured logging.
|
||||||
264
SPECS.md
Normal file
264
SPECS.md
Normal file
|
|
@ -0,0 +1,264 @@
|
||||||
|
# Eyrie Templates — Product Specification (Draft)
|
||||||
|
|
||||||
|
This specification captures the initial scope for Eyrie Templates, the templating engine used by the Phred Framework.
|
||||||
|
|
||||||
|
## 1. Goals
|
||||||
|
|
||||||
|
- Fast, safe, ergonomic server‑side templating for PHP apps in the Phred ecosystem.
|
||||||
|
- Separation of concerns: business logic in PHP, presentation logic in templates.
|
||||||
|
- Security by default via auto‑escaping with contextual modes.
|
||||||
|
- Predictable, minimal syntax optimized for HTML.
|
||||||
|
- DRY composition with inheritance and partials.
|
||||||
|
|
||||||
|
## 2. Non‑Goals
|
||||||
|
|
||||||
|
- No arbitrary code execution in templates; no general programming.
|
||||||
|
- No dynamic variable creation inside templates (only loop counters).
|
||||||
|
- No while loops or unbounded iteration constructs.
|
||||||
|
- No filesystem or network access from templates.
|
||||||
|
|
||||||
|
## 3. Installation
|
||||||
|
|
||||||
|
- Composer package: `composer require getphred/eyrie`
|
||||||
|
|
||||||
|
### 3.1 Engine configuration
|
||||||
|
|
||||||
|
- Directory roots can be configured for Layouts, Pages, Partials, and Components.
|
||||||
|
- Template names are referenced using dot-notation relative to their configured roots and do not include file extensions.
|
||||||
|
- Default template file extension: `.eyrie.php`.
|
||||||
|
|
||||||
|
## 4. Core concepts
|
||||||
|
|
||||||
|
- Template: Text file (typically HTML) with Eyrie syntax.
|
||||||
|
- Context: Associative array passed at render time.
|
||||||
|
- Page: A top‑level template representing a view. Pages may extend a Layout and fill named Blocks.
|
||||||
|
- Layout: A template that defines the overarching structure (shell) and exposes named Blocks with optional defaults.
|
||||||
|
- Block: A named content region. Pages/components can override block content; `[[ super ]]` can include parent content.
|
||||||
|
- Component: A reusable, tag‑like unit rendered with a custom element syntax (e.g., `<@ Movie id="1" />`). Components encapsulate their own rendering and accept props.
|
||||||
|
- Props: Data passed to components (and optionally pages) akin to attributes; values flow from the rendering context and are subject to escaping.
|
||||||
|
- Helper: Whitelisted callable exposed to templates.
|
||||||
|
- Filter: Unary transformation applied via `|` pipe.
|
||||||
|
- Tag: Control structure/directive (loops, conditionals, includes, etc.).
|
||||||
|
- Loader: Resolves template names to sources.
|
||||||
|
- Cache: Compiled/intermediate representation for speed.
|
||||||
|
|
||||||
|
## 5. Syntax overview
|
||||||
|
|
||||||
|
- Output: `<< expression >>` prints value with auto‑escaping.
|
||||||
|
- Calls/control: control blocks start with `<(` and end with `)>`; examples use `<( if ... )>` / `<( endif )>`.
|
||||||
|
- Blocks/extends: `[[ ... ]]` for inheritance, blocks, includes.
|
||||||
|
- Components: `<@ ComponentName prop1="value" prop2={ expr } />` self‑closing. Support for children/content projection is TBD.
|
||||||
|
|
||||||
|
### 5.1 Expressions
|
||||||
|
|
||||||
|
- Property/array access: `user.name`, `order.items[0]`.
|
||||||
|
- Literals: strings, numbers, booleans, null.
|
||||||
|
- Operators: arithmetic, comparison, logical, ternary.
|
||||||
|
- Filters: `value | lower | escape('attr') | ...`.
|
||||||
|
|
||||||
|
### 5.2 Output examples
|
||||||
|
|
||||||
|
- Basic: `<< user.name >>`
|
||||||
|
- With filters: `<< title | upper >>`
|
||||||
|
- Raw (discouraged): `<< user.bio | raw >>`
|
||||||
|
|
||||||
|
### 5.3 Control structures (inside `<( ... )>`)
|
||||||
|
|
||||||
|
- If/elseif/else/end:
|
||||||
|
- `<( if cond )>` ... `<( elseif other )>` ... `<( else )>` ... `<( endif )>`
|
||||||
|
- Foreach:
|
||||||
|
- `<( foreach item in items )>` ... `<( endforeach )>`
|
||||||
|
- Loop vars (read‑only): `item`
|
||||||
|
- Loop (range/repeat):
|
||||||
|
- `<( loop from 0 to 10 )>` ... `<( endloop )>`
|
||||||
|
- Loop vars (read‑only): `loop.index` (int), `loop.first` (bool), `loop.last` (bool), `loop.length` (int)
|
||||||
|
|
||||||
|
### 5.4 Inheritance and blocks (inside `[[ ... ]]`)
|
||||||
|
|
||||||
|
- Extends: `[[ extends "layouts.base" ]]` (dot-notated paths, final part is parent file)
|
||||||
|
- Extends syntax does not include the layout file extension.
|
||||||
|
- Extends must be the first line of a template file.
|
||||||
|
- Declare/override:
|
||||||
|
- `[[ block content ]] ... [[ endblock ]]`
|
||||||
|
- `[[ super ]]` inside override to include parent content
|
||||||
|
- Block context (optional): `[[ block sidebar with { user: user } ]]`
|
||||||
|
|
||||||
|
### 5.5 Partials
|
||||||
|
- Basic Include: `[[ include "partials.footer" ]]`
|
||||||
|
- Include with optional context: `[[ include "partials.footer" with { x: 1 } ]]` (dot-notated paths, final part is partial file)
|
||||||
|
- Include syntax does not include the partial file extension.
|
||||||
|
|
||||||
|
### 5.6 Components
|
||||||
|
|
||||||
|
- Tag‑based components are referenced by PascalCase names and invoked with the `<@` prefix: `<@ Movie id="1" />`.
|
||||||
|
- Props:
|
||||||
|
- String literal: `<@ Movie title="Jaws" />`
|
||||||
|
- Expression: `<@ Movie rating={ movie.rating } />`
|
||||||
|
- Boolean shorthand: `<@ Movie featured />` → `featured=true`
|
||||||
|
- Registration: components are registered in PHP (see API). Unregistered component names are a `SyntaxError` (configurable to `RuntimeError`).
|
||||||
|
- Auto‑escaping: Component outputs are escaped by context unless a component deliberately returns a `SafeHtml`‑like value.
|
||||||
|
- Context: Components receive props plus a limited view of the parent context (configurable); they do not mutate outer context.
|
||||||
|
- Layout/block interaction: Components can be used within Layouts and Pages but do not extend layouts themselves, and they do not define or fill block areas.
|
||||||
|
|
||||||
|
### 5.7 Built‑in tags
|
||||||
|
|
||||||
|
- `if/elseif/else/endif`, `foreach/endforeach`, `loop/endloop`, `include`, `extends`, `block/endblock`, `super`
|
||||||
|
|
||||||
|
### 5.8 Helpers and filters
|
||||||
|
|
||||||
|
- Helpers are registered by name in PHP and callable in `<( ... )>` or `<< >>`, e.g. `<< route('home') >>`.
|
||||||
|
- Filters chain with pipes, e.g. `<< text | truncate(120) | escape('attr') >>`.
|
||||||
|
|
||||||
|
## 6. Auto‑escaping
|
||||||
|
|
||||||
|
- Enabled by default for all `<< >>` outputs.
|
||||||
|
- Modes: `html` (default), `attr`, `url`, `js`.
|
||||||
|
- `escape(mode)` switches mode; `safe` marks trusted values to bypass escaping.
|
||||||
|
- Escaping happens after filters unless `safe` present.
|
||||||
|
- Component rendering occurs under the current escaping mode of the insertion site.
|
||||||
|
|
||||||
|
## 7. Template loaders
|
||||||
|
|
||||||
|
- FilesystemLoader: rooted directories, normalized paths, no `..` traversal.
|
||||||
|
- Names are dot‑notated within configured roots and omit the file extension (default `.eyrie.php`).
|
||||||
|
- Optional namespaces (if supported): `@emails.welcome` → resolved under the `emails` root.
|
||||||
|
- Future: StringLoader, ArrayLoader.
|
||||||
|
|
||||||
|
## 8. Caching
|
||||||
|
|
||||||
|
- Optional compiled cache keyed by template name + engine version + config hash.
|
||||||
|
- Stores: filesystem (default), PSR‑16 (future).
|
||||||
|
- Defaults: dir perms 0700; atomic writes; checksum integrity.
|
||||||
|
|
||||||
|
## 9. Public PHP API (draft)
|
||||||
|
|
||||||
|
```php
|
||||||
|
interface Loader {
|
||||||
|
public function getSource(string $name): string;
|
||||||
|
public function getCacheKey(string $name): string; // stable per source
|
||||||
|
public function exists(string $name): bool;
|
||||||
|
}
|
||||||
|
|
||||||
|
final class Environment {
|
||||||
|
public function __construct(Loader $loader, array $options = []) {}
|
||||||
|
public function addHelper(string $name, callable $helper): void {}
|
||||||
|
public function addFilter(string $name, callable $filter): void {}
|
||||||
|
/** Register a component renderer by tag name, e.g., 'Movie' */
|
||||||
|
public function addComponent(string $name, ComponentRenderer $component): void {}
|
||||||
|
public function render(string $name, array $context = []): string {}
|
||||||
|
public function compile(string $name): CompiledTemplate {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal component contract (draft) */
|
||||||
|
interface ComponentRenderer {
|
||||||
|
/**
|
||||||
|
* @param array $props Props/attributes supplied in the template
|
||||||
|
* @param array $context A read‑only view of the current render context
|
||||||
|
* @return mixed A string (escaped later) or a SafeHtml‑like value
|
||||||
|
*/
|
||||||
|
public function render(array $props, array $context = []): mixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
final class CompiledTemplate {
|
||||||
|
public function render(array $context = []): string {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- `strict_variables` option throws on missing variables.
|
||||||
|
- Helpers/filters return values still subject to escaping unless wrapped in a `SafeHtml`‑like type.
|
||||||
|
|
||||||
|
## 10. Errors and diagnostics
|
||||||
|
|
||||||
|
- Exceptions: `TemplateError`, `SyntaxError`, `RuntimeError`, `LoaderError`.
|
||||||
|
- Messages include template name, line/col, and a snippet when possible.
|
||||||
|
- Configurable verbosity for dev vs prod.
|
||||||
|
- Useful diagnostics for components: unknown component name, invalid/unknown props, and type errors include the component tag snippet.
|
||||||
|
|
||||||
|
## 11. Performance targets (initial)
|
||||||
|
|
||||||
|
- Cold render p95 < 10ms for a ~5KB template on typical server hardware.
|
||||||
|
- Warm render p95 < 2ms with cache.
|
||||||
|
- Watchdog timeouts to limit parse/render and mitigate DoS.
|
||||||
|
- Component recursion and nesting limits (e.g., max depth) to prevent pathological trees.
|
||||||
|
|
||||||
|
## 12. Security requirements (summary)
|
||||||
|
|
||||||
|
- Auto‑escape by default; `safe` is explicit opt‑in.
|
||||||
|
- No arbitrary PHP execution; only whitelisted helpers/filters.
|
||||||
|
- Loader prevents traversal; only configured roots.
|
||||||
|
- Sandboxed evaluation; no eval/reflection.
|
||||||
|
- Depth/iteration limits; template size/token limits.
|
||||||
|
- Restrictive cache dir permissions; validated paths.
|
||||||
|
- Components are pure render units: no filesystem/network access; no mutation of outer context; bounded recursion/depth.
|
||||||
|
|
||||||
|
## 13. Logging and telemetry
|
||||||
|
|
||||||
|
- Hook for timings, cache metrics, loader misses.
|
||||||
|
- PSR‑3 logger support.
|
||||||
|
|
||||||
|
## 14. Compatibility
|
||||||
|
|
||||||
|
- Minimum PHP: propose 8.1+ (finalize).
|
||||||
|
- UTF‑8 for sources and output.
|
||||||
|
|
||||||
|
## 15. Examples
|
||||||
|
|
||||||
|
Base `base.eyrie.php`:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title><< title | escape('html') >></title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>[[ block header ]]Default Header[[ endblock ]]</header>
|
||||||
|
<main>[[ block content ]][[ endblock ]]</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
Child `home.eyrie.php`:
|
||||||
|
|
||||||
|
```html
|
||||||
|
[[ extends "base" ]]
|
||||||
|
|
||||||
|
[[ block content ]]
|
||||||
|
<h1>Hello, << user.name >>!</h1>
|
||||||
|
<ul>
|
||||||
|
<( foreach item in items )>
|
||||||
|
<li><< loop.index >>. << item.title | escape('attr') >></li>
|
||||||
|
<( endforeach )>
|
||||||
|
</ul>
|
||||||
|
[[ endblock ]]
|
||||||
|
```
|
||||||
|
|
||||||
|
Component usage example:
|
||||||
|
|
||||||
|
```html
|
||||||
|
[[ extends "base" ]]
|
||||||
|
|
||||||
|
[[ block content ]]
|
||||||
|
<h2>Featured</h2>
|
||||||
|
<@ Movie id={ featured.id } title={ featured.title } featured />
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<( foreach m in movies )>
|
||||||
|
<li>
|
||||||
|
<@ Movie id={ m.id } title={ m.title } rating={ m.rating } />
|
||||||
|
</li>
|
||||||
|
<( endforeach )>
|
||||||
|
</ul>
|
||||||
|
[[ endblock ]]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 16. Open questions
|
||||||
|
|
||||||
|
- Final grammar (EBNF) for expressions/tags.
|
||||||
|
- Built‑in filters/helpers set.
|
||||||
|
- Parser error recovery strategy.
|
||||||
|
- Safe value interface semantics.
|
||||||
|
- Component children/content projection: do we allow `<Card>...children...</Card>`?
|
||||||
|
- Prop type checking and defaulting strategy.
|
||||||
100
THREAT_MODEL.md
Normal file
100
THREAT_MODEL.md
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
# Eyrie Templates — Threat Model (Draft)
|
||||||
|
|
||||||
|
Purpose: identify assets, threats, and controls relevant to a server‑side PHP templating engine and define baseline mitigations.
|
||||||
|
|
||||||
|
## 1. Assets
|
||||||
|
|
||||||
|
- Template sources (files and compiled cache outputs)
|
||||||
|
- Rendered HTML output delivered to clients
|
||||||
|
- Template context data (user data, secrets if accidentally passed)
|
||||||
|
- Component definitions/renderers and their configuration
|
||||||
|
- Helper/filter registry and configuration
|
||||||
|
- Loader configuration (roots, namespaces)
|
||||||
|
- Cache directory and compiled artifacts
|
||||||
|
- Error logs and diagnostics
|
||||||
|
|
||||||
|
## 2. Actors
|
||||||
|
|
||||||
|
- Application developers (trusted, may misconfigure)
|
||||||
|
- End users (untrusted input surfaces)
|
||||||
|
- Attackers supplying malicious input
|
||||||
|
- System administrators (manage deployment and FS perms)
|
||||||
|
|
||||||
|
## 3. Trust boundaries
|
||||||
|
|
||||||
|
- Between untrusted user input and template rendering
|
||||||
|
- Between template engine and filesystem (loaders, cache)
|
||||||
|
- Between engine and helper/filter callables (application code)
|
||||||
|
- Between dev and prod environments (verbosity, paths, timings)
|
||||||
|
|
||||||
|
## 4. Entry points and attack surfaces
|
||||||
|
|
||||||
|
- Template variables/expressions from request‑derived data (XSS)
|
||||||
|
- Helper/filter parameters (command/code injection via helpers)
|
||||||
|
- Component props/attributes (injection vectors; type confusion)
|
||||||
|
- Template name resolution (path traversal, namespace bypass)
|
||||||
|
- Include/extends directives (recursive includes, deep inheritance)
|
||||||
|
- Component trees (deep recursion/nesting)
|
||||||
|
- Large templates or pathological inputs (parser/render DoS)
|
||||||
|
- Cache poisoning or disclosure (incorrect perms or keying)
|
||||||
|
- Error pages and stack traces (info leakage)
|
||||||
|
|
||||||
|
## 5. Threats and mitigations
|
||||||
|
|
||||||
|
- Reflected/stored XSS in output
|
||||||
|
- Default auto‑escaping with context modes (`html`, `attr`, `url`, `js`)
|
||||||
|
- `safe` must be explicit and narrowly scoped
|
||||||
|
- Security lint/checks to flag `safe` usage
|
||||||
|
- Path traversal via loader
|
||||||
|
- Normalize and resolve paths; reject `..` and absolute paths unless mapped
|
||||||
|
- Restrict to configured roots; support namespaces with fixed roots
|
||||||
|
- Code execution via helpers/filters
|
||||||
|
- Only whitelisted callables registered by application
|
||||||
|
- No evaluation of template strings as PHP; no reflection access
|
||||||
|
- Optionally sandbox helpers with contracts and safe value types
|
||||||
|
- Code execution or SSRF via component renderers
|
||||||
|
- Components implement a constrained interface; no filesystem/network access by default
|
||||||
|
- Validate and sanitize props before use; avoid passing raw props to sinks
|
||||||
|
- Enforce a strict registry: only explicitly added components are callable
|
||||||
|
- DoS via deep inheritance/recursion or huge loops
|
||||||
|
- Limits: include/extends depth; loop iteration caps; template/token size limits
|
||||||
|
- Watchdog timeouts for parse/render
|
||||||
|
- Component nesting/recursion depth caps and per‑render time budgets
|
||||||
|
- Cache tampering/leakage
|
||||||
|
- Cache dir perms 0700; atomic writes; validate cache keys (include engine version and options)
|
||||||
|
- Avoid executing cache files; treat as data
|
||||||
|
- Information disclosure via errors
|
||||||
|
- Configurable verbosity; hide paths/snippets in production
|
||||||
|
- Structured error types without sensitive context values
|
||||||
|
- SSRF/RFI via helpers/filters
|
||||||
|
- No network/file IO in templates; helpers must not fetch remote resources by default
|
||||||
|
- Apply same restrictions to components
|
||||||
|
|
||||||
|
## 6. Assumptions
|
||||||
|
|
||||||
|
- Templates are developer‑authored and trusted; user‑generated templates are out of scope.
|
||||||
|
- Application controls helper registrations; helpers conform to safe contracts.
|
||||||
|
- Deployment applies standard OS hardening (FS perms, no public cache dirs).
|
||||||
|
|
||||||
|
## 7. Security requirements (binding)
|
||||||
|
|
||||||
|
- Auto‑escaping enabled by default and cannot be globally disabled in production builds
|
||||||
|
- Loader prevents traversal; cannot escape configured roots
|
||||||
|
- Depth/size/time limits are configurable with safe defaults
|
||||||
|
- Distinct dev/prod modes, with safe prod defaults
|
||||||
|
- No eval of template source; no direct PHP execution from templates
|
||||||
|
- Components are pure render units; cannot mutate outer context; registered explicitly
|
||||||
|
|
||||||
|
## 8. Validation and testing
|
||||||
|
|
||||||
|
- XSS test corpus across HTML, attribute, JS, and URL contexts
|
||||||
|
- Fuzz tests for parser stability and timeouts
|
||||||
|
- Unit tests for loader normalization and traversal blocking
|
||||||
|
- Integration tests for cache directory perms and keying
|
||||||
|
- Component test suite for prop escaping, type validation, and recursion limits
|
||||||
|
|
||||||
|
## 9. Open items
|
||||||
|
|
||||||
|
- Define safe value wrapper interface
|
||||||
|
- Decide on strict‑variables default (on/off)
|
||||||
|
- Finalize default limits (include depth, loop max, template size)
|
||||||
31
composer.json
Normal file
31
composer.json
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
{
|
||||||
|
"name": "getphred/eyrie",
|
||||||
|
"description": "The templating engine for the Phred Framework",
|
||||||
|
"license": "MIT",
|
||||||
|
"type": "library",
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Junie",
|
||||||
|
"email": "junie@example.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"require": {
|
||||||
|
"php": "^8.2"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpunit/phpunit": "^10.0"
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Eyrie\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload-dev": {
|
||||||
|
"psr-4": {
|
||||||
|
"Eyrie\\Tests\\": "tests/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"sort-packages": true
|
||||||
|
}
|
||||||
|
}
|
||||||
1690
composer.lock
generated
Normal file
1690
composer.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
17
phpunit.xml
Normal file
17
phpunit.xml
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.0/phpunit.xsd"
|
||||||
|
bootstrap="vendor/autoload.php"
|
||||||
|
colors="true"
|
||||||
|
cacheDirectory=".phpunit.cache">
|
||||||
|
<testsuites>
|
||||||
|
<testsuite name="Eyrie Test Suite">
|
||||||
|
<directory>tests</directory>
|
||||||
|
</testsuite>
|
||||||
|
</testsuites>
|
||||||
|
<source>
|
||||||
|
<include>
|
||||||
|
<directory suffix=".php">src</directory>
|
||||||
|
</include>
|
||||||
|
</source>
|
||||||
|
</phpunit>
|
||||||
276
src/Compiler/Compiler.php
Normal file
276
src/Compiler/Compiler.php
Normal file
|
|
@ -0,0 +1,276 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Eyrie\Compiler;
|
||||||
|
|
||||||
|
use Eyrie\Parser\Node\RootNode;
|
||||||
|
use Eyrie\Parser\Node\TextNode;
|
||||||
|
use Eyrie\Parser\Node\ExpressionNode;
|
||||||
|
use Eyrie\Parser\Node\IfNode;
|
||||||
|
use Eyrie\Parser\Node\ForeachNode;
|
||||||
|
use Eyrie\Parser\Node\LoopNode;
|
||||||
|
use Eyrie\Parser\Node\ExtendsNode;
|
||||||
|
use Eyrie\Parser\Node\BlockNode;
|
||||||
|
use Eyrie\Parser\Node\SuperNode;
|
||||||
|
use Eyrie\Parser\Node\IncludeNode;
|
||||||
|
use Eyrie\Parser\Node\ComponentNode;
|
||||||
|
|
||||||
|
class Compiler
|
||||||
|
{
|
||||||
|
private ?string $layout = null;
|
||||||
|
private array $blocks = [];
|
||||||
|
private bool $isLayout = false;
|
||||||
|
|
||||||
|
public function compile(RootNode|array $node): string
|
||||||
|
{
|
||||||
|
$php = "";
|
||||||
|
|
||||||
|
if ($node instanceof RootNode) {
|
||||||
|
$php .= "<?php\n\n";
|
||||||
|
$php .= "\$blocks = [];\n";
|
||||||
|
$children = $node->children;
|
||||||
|
$this->layout = null; // Reset for each compilation
|
||||||
|
} else {
|
||||||
|
$children = $node;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($children as $child) {
|
||||||
|
if ($child instanceof TextNode) {
|
||||||
|
$php .= $this->compileText($child);
|
||||||
|
} elseif ($child instanceof ExpressionNode) {
|
||||||
|
$php .= $this->compileExpression($child);
|
||||||
|
} elseif ($child instanceof IfNode) {
|
||||||
|
$php .= $this->compileIf($child);
|
||||||
|
} elseif ($child instanceof ForeachNode) {
|
||||||
|
$php .= $this->compileForeach($child);
|
||||||
|
} elseif ($child instanceof LoopNode) {
|
||||||
|
$php .= $this->compileLoop($child);
|
||||||
|
} elseif ($child instanceof ExtendsNode) {
|
||||||
|
$this->layout = $child->layout;
|
||||||
|
} elseif ($child instanceof BlockNode) {
|
||||||
|
$php .= $this->compileBlock($child);
|
||||||
|
} elseif ($child instanceof SuperNode) {
|
||||||
|
$php .= $this->compileSuper($child);
|
||||||
|
} elseif ($child instanceof IncludeNode) {
|
||||||
|
$php .= $this->compileInclude($child);
|
||||||
|
} elseif ($child instanceof ComponentNode) {
|
||||||
|
$php .= $this->compileComponent($child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($node instanceof RootNode && $this->layout !== null) {
|
||||||
|
$php .= sprintf(
|
||||||
|
"\nreturn ['layout' => %s, 'blocks' => \$blocks];\n",
|
||||||
|
var_export($this->layout, true)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $php;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function compileBlock(BlockNode $node): string
|
||||||
|
{
|
||||||
|
$blockCtx = "[]";
|
||||||
|
if (!empty($node->context)) {
|
||||||
|
$blockCtx = "[\n";
|
||||||
|
foreach ($node->context as $name => $value) {
|
||||||
|
if (is_array($value) && isset($value['expr'])) {
|
||||||
|
// Check if it's a literal string already
|
||||||
|
$expr = trim($value['expr']);
|
||||||
|
if (preg_match('/^["\'].*["\']$/', $expr)) {
|
||||||
|
$blockCtx .= sprintf(" '%s' => %s,\n", $name, $expr);
|
||||||
|
} else {
|
||||||
|
$blockCtx .= sprintf(" '%s' => %s,\n", $name, $this->transformExpression($expr));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$blockCtx .= sprintf(" '%s' => %s,\n", $name, var_export($value, true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$blockCtx .= "]";
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = $this->compile($node->body);
|
||||||
|
|
||||||
|
return sprintf(
|
||||||
|
"\$this->registerBlock('%s', %s, function(array \$context) {\n" .
|
||||||
|
"%s});\n" .
|
||||||
|
"if (!isset(\$this->renderedBlocks['%s'])) {\n" .
|
||||||
|
" echo \$this->renderBlock('%s', function() use (\$context) {\n" .
|
||||||
|
"%s }, \$context);\n" .
|
||||||
|
"}\n",
|
||||||
|
$node->name,
|
||||||
|
$blockCtx,
|
||||||
|
$body,
|
||||||
|
$node->name,
|
||||||
|
$node->name,
|
||||||
|
$body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function compileText(TextNode $node): string
|
||||||
|
{
|
||||||
|
return sprintf("echo %s;\n", var_export($node->content, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function compileExpression(ExpressionNode $node): string
|
||||||
|
{
|
||||||
|
$transformed = $this->transformExpression($node->expression);
|
||||||
|
|
||||||
|
foreach ($node->filters as $filter) {
|
||||||
|
$args = "";
|
||||||
|
if (!empty($filter['args'])) {
|
||||||
|
$args = ", " . implode(', ', array_map(function($arg) {
|
||||||
|
return $this->transformExpression($arg);
|
||||||
|
}, $filter['args']));
|
||||||
|
}
|
||||||
|
$transformed = sprintf("\$this->applyFilter('%s', %s%s)", $filter['name'], $transformed, $args);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sprintf("echo \$this->escape(%s);\n", $transformed);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function compileIf(IfNode $node): string
|
||||||
|
{
|
||||||
|
$condition = $this->transformExpression($node->condition);
|
||||||
|
|
||||||
|
$php = sprintf("if (%s) {\n%s}", $condition, $this->compile($node->body));
|
||||||
|
|
||||||
|
foreach ($node->elseifs as $elseif) {
|
||||||
|
$elseifCond = $this->transformExpression($elseif['condition']);
|
||||||
|
$php .= sprintf(" elseif (%s) {\n%s}", $elseifCond, $this->compile($elseif['body']));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($node->else !== null) {
|
||||||
|
$php .= sprintf(" else {\n%s}", $this->compile($node->else));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $php . "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
private function compileForeach(ForeachNode $node): string
|
||||||
|
{
|
||||||
|
$items = $this->transformExpression($node->items);
|
||||||
|
$item = $node->item;
|
||||||
|
|
||||||
|
return sprintf(
|
||||||
|
"foreach (%s as \$context['%s']) {\n%s}\n",
|
||||||
|
$items,
|
||||||
|
$item,
|
||||||
|
$this->compile($node->body)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function compileLoop(LoopNode $node): string
|
||||||
|
{
|
||||||
|
$from = $this->transformExpression($node->from);
|
||||||
|
$to = $this->transformExpression($node->to);
|
||||||
|
|
||||||
|
return sprintf(
|
||||||
|
"\$loop_length = count(range(%s, %s));\n" .
|
||||||
|
"\$loop_index = 0;\n" .
|
||||||
|
"foreach (range(%s, %s) as \$i) {\n" .
|
||||||
|
" \$context['loop'] = [\n" .
|
||||||
|
" 'index' => \$loop_index,\n" .
|
||||||
|
" 'first' => \$loop_index === 0,\n" .
|
||||||
|
" 'last' => \$loop_index === \$loop_length - 1,\n" .
|
||||||
|
" 'length' => \$loop_length\n" .
|
||||||
|
" ];\n" .
|
||||||
|
" %s\n" .
|
||||||
|
" \$loop_index++;\n" .
|
||||||
|
"}\n",
|
||||||
|
$from, $to, $from, $to,
|
||||||
|
$this->compile($node->body)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function compileSuper(SuperNode $node): string
|
||||||
|
{
|
||||||
|
return "echo \$this->parentBlock;\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
private function compileInclude(IncludeNode $node): string
|
||||||
|
{
|
||||||
|
return sprintf(
|
||||||
|
"echo \$this->doRender(%s, \$context);\n",
|
||||||
|
var_export($node->template, true)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function compileComponent(ComponentNode $node): string
|
||||||
|
{
|
||||||
|
$props = "[\n";
|
||||||
|
foreach ($node->props as $name => $value) {
|
||||||
|
if (is_array($value) && isset($value['expr'])) {
|
||||||
|
$props .= sprintf(" '%s' => %s,\n", $name, $this->transformExpression($value['expr']));
|
||||||
|
} else {
|
||||||
|
$props .= sprintf(" '%s' => %s,\n", $name, var_export($value, true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$props .= "]";
|
||||||
|
|
||||||
|
return sprintf(
|
||||||
|
"echo \$this->renderComponent(%s, %s);\n",
|
||||||
|
var_export($node->name, true),
|
||||||
|
$props
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function transformExpression(string $expression): string
|
||||||
|
{
|
||||||
|
$expression = trim($expression);
|
||||||
|
|
||||||
|
// Handle literal strings (double quotes)
|
||||||
|
if (str_starts_with($expression, '"') && str_ends_with($expression, '"')) {
|
||||||
|
return var_export(substr($expression, 1, -1), true);
|
||||||
|
}
|
||||||
|
// Handle literal strings (single quotes)
|
||||||
|
if (str_starts_with($expression, "'") && str_ends_with($expression, "'")) {
|
||||||
|
return var_export(substr($expression, 1, -1), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle numeric literals
|
||||||
|
if (is_numeric($expression)) {
|
||||||
|
return $expression;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle basic boolean/null literals
|
||||||
|
if (in_array(strtolower($expression), ['true', 'false', 'null'])) {
|
||||||
|
return strtolower($expression);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle function calls (e.g. "greet('Junie')")
|
||||||
|
if (preg_match('/^([a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*)\s*\((.*)\)$/', $expression, $matches)) {
|
||||||
|
$helperName = $matches[1];
|
||||||
|
$argsStr = trim($matches[2]);
|
||||||
|
|
||||||
|
$argList = [];
|
||||||
|
if ($argsStr !== '') {
|
||||||
|
// Split arguments by comma, but try to avoid splitting inside quotes
|
||||||
|
$parts = preg_split('/,(?=(?:[^\'"]*[\'"][^\'"]*[\'"])*[^\'"]*$)/', $argsStr);
|
||||||
|
foreach ($parts as $part) {
|
||||||
|
$argList[] = $this->transformExpression(trim($part));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sprintf("\$this->callHelper('%s'%s)", $helperName, $argList ? ', ' . implode(', ', $argList) : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle simple identifiers (e.g. "show", "user.name")
|
||||||
|
if (preg_match('/^[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*(\.[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*)*$/', $expression)) {
|
||||||
|
// Check if it's actually a string literal that we missed (though we should have caught it above)
|
||||||
|
$parts = explode('.', $expression);
|
||||||
|
$base = array_shift($parts);
|
||||||
|
|
||||||
|
$transformed = sprintf("(\$context['%s'] ?? null)", $base);
|
||||||
|
|
||||||
|
foreach ($parts as $part) {
|
||||||
|
$transformed = sprintf("(\$this->access(%s, '%s'))", $transformed, $part);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $transformed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $expression;
|
||||||
|
}
|
||||||
|
}
|
||||||
276
src/Engine.php
Normal file
276
src/Engine.php
Normal file
|
|
@ -0,0 +1,276 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Eyrie;
|
||||||
|
|
||||||
|
use Eyrie\Loader\LoaderInterface;
|
||||||
|
use Eyrie\Parser\Lexer;
|
||||||
|
use Eyrie\Parser\Parser;
|
||||||
|
use Eyrie\Compiler\Compiler;
|
||||||
|
|
||||||
|
class Engine
|
||||||
|
{
|
||||||
|
private LoaderInterface $loader;
|
||||||
|
private Compiler $compiler;
|
||||||
|
private array $helpers = [];
|
||||||
|
private array $filters = [];
|
||||||
|
private array $globals = [];
|
||||||
|
private string $cachePath;
|
||||||
|
private bool $debug = false;
|
||||||
|
|
||||||
|
private array $blocks = [];
|
||||||
|
private array $capturedBlocks = [];
|
||||||
|
public array $renderedBlocks = [];
|
||||||
|
private ?string $currentLayout = null;
|
||||||
|
private string $parentBlock = '';
|
||||||
|
|
||||||
|
public function __construct(LoaderInterface $loader, array $options = [])
|
||||||
|
{
|
||||||
|
$this->loader = $loader;
|
||||||
|
$this->compiler = new Compiler();
|
||||||
|
$this->cachePath = $options['cache'] ?? sys_get_temp_dir() . '/eyrie_cache';
|
||||||
|
$this->debug = $options['debug'] ?? false;
|
||||||
|
|
||||||
|
if (!is_dir($this->cachePath)) {
|
||||||
|
mkdir($this->cachePath, 0777, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(string $name, array $context = []): string
|
||||||
|
{
|
||||||
|
$this->blocks = []; // Reset blocks for each top-level render
|
||||||
|
$this->capturedBlocks = [];
|
||||||
|
$this->renderedBlocks = [];
|
||||||
|
$this->parentBlock = '';
|
||||||
|
$this->currentLayout = null;
|
||||||
|
|
||||||
|
$current = $name;
|
||||||
|
$chain = [];
|
||||||
|
|
||||||
|
// Pass 1: Collect blocks
|
||||||
|
while (true) {
|
||||||
|
$cacheKey = $this->loader->getCacheKey($current);
|
||||||
|
$cachedFile = $this->cachePath . '/' . md5($cacheKey) . '.php';
|
||||||
|
if ($this->debug || !file_exists($cachedFile) || !$this->loader->isFresh($current, filemtime($cachedFile))) {
|
||||||
|
$php = $this->compile($current);
|
||||||
|
file_put_contents($cachedFile, $php);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array($cachedFile, $chain)) {
|
||||||
|
throw new \RuntimeException("Circular inheritance detected: " . implode(' -> ', $chain) . " -> $cachedFile");
|
||||||
|
}
|
||||||
|
$chain[] = $cachedFile;
|
||||||
|
$this->capturedBlocks = [];
|
||||||
|
$this->currentLayout = null;
|
||||||
|
|
||||||
|
// Collect blocks into $this->blocks
|
||||||
|
$fullContext = array_merge($this->globals, $context);
|
||||||
|
$this->evaluate($cachedFile, $fullContext);
|
||||||
|
|
||||||
|
foreach ($this->capturedBlocks as $blockName => $callback) {
|
||||||
|
if (!isset($this->blocks[$blockName])) {
|
||||||
|
$this->blocks[$blockName] = $callback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->currentLayout !== null) {
|
||||||
|
$current = $this->currentLayout;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 2: Final render of the top-most layout
|
||||||
|
$topLayoutFile = end($chain);
|
||||||
|
$this->capturedBlocks = [];
|
||||||
|
$this->renderedBlocks = [];
|
||||||
|
$this->currentLayout = null;
|
||||||
|
|
||||||
|
return $this->evaluate($topLayoutFile, $context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function doRender(string $name, array $context = []): string
|
||||||
|
{
|
||||||
|
// For nested renders (include, component), we want a fresh block state
|
||||||
|
// but perhaps they should inherit the parent blocks?
|
||||||
|
// Eyrie specs say components encapsulate their rendering.
|
||||||
|
$oldBlocks = $this->blocks;
|
||||||
|
$this->blocks = [];
|
||||||
|
$res = $this->render($name, $context);
|
||||||
|
$this->blocks = $oldBlocks;
|
||||||
|
return $res;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function compile(string $name): string
|
||||||
|
{
|
||||||
|
$source = $this->loader->load($name);
|
||||||
|
$lexer = new Lexer($source);
|
||||||
|
$parser = new Parser($lexer->tokenize());
|
||||||
|
$ast = $parser->parse();
|
||||||
|
return $this->compiler->compile($ast);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function evaluate(string $file, array $context): string
|
||||||
|
{
|
||||||
|
ob_start();
|
||||||
|
try {
|
||||||
|
// Flatten context for extraction.
|
||||||
|
$vars = array_merge($this->globals, $context);
|
||||||
|
// Compiled expressions use ($context['var'] ?? null)
|
||||||
|
$vars['context'] = $vars;
|
||||||
|
$vars['engine'] = $this; // Provide $engine for closures that might need it
|
||||||
|
|
||||||
|
// Templates are included in the scope of this closure,
|
||||||
|
// which is bound to the Engine instance.
|
||||||
|
$result = (function ($__tpl_file, $context) {
|
||||||
|
extract($context, EXTR_SKIP);
|
||||||
|
return include $__tpl_file;
|
||||||
|
})->call($this, $file, $vars);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
ob_end_clean();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
$output = ob_get_clean();
|
||||||
|
|
||||||
|
if (is_array($result) && isset($result['layout'])) {
|
||||||
|
$this->currentLayout = $result['layout'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $output;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getGlobals(): array
|
||||||
|
{
|
||||||
|
return $this->globals;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function escape(mixed $value): string
|
||||||
|
{
|
||||||
|
if ($value instanceof SafeString) {
|
||||||
|
return (string)$value;
|
||||||
|
}
|
||||||
|
if (is_bool($value)) {
|
||||||
|
return $value ? 'true' : 'false';
|
||||||
|
}
|
||||||
|
return htmlspecialchars((string)$value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function access(mixed $object, string $property): mixed
|
||||||
|
{
|
||||||
|
if (is_array($object)) {
|
||||||
|
return $object[$property] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_object($object)) {
|
||||||
|
if (isset($object->{$property})) {
|
||||||
|
return $object->{$property};
|
||||||
|
}
|
||||||
|
$method = 'get' . ucfirst($property);
|
||||||
|
if (method_exists($object, $method)) {
|
||||||
|
return $object->$method();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function renderBlock(string $name, callable $fallback, array $context): string
|
||||||
|
{
|
||||||
|
$this->renderedBlocks[$name] = true;
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
$fallback();
|
||||||
|
$fallbackContent = ob_get_clean();
|
||||||
|
|
||||||
|
if (isset($this->blocks[$name])) {
|
||||||
|
$callback = $this->blocks[$name];
|
||||||
|
|
||||||
|
$oldParentBlock = $this->parentBlock;
|
||||||
|
$this->parentBlock = $fallbackContent;
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
$callback($context);
|
||||||
|
$result = ob_get_clean();
|
||||||
|
|
||||||
|
$this->parentBlock = $oldParentBlock;
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $fallbackContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function registerBlock(string $name, array $blockCtx, callable $callback): void
|
||||||
|
{
|
||||||
|
$this->capturedBlocks[$name] = function(array $context) use ($callback, $blockCtx) {
|
||||||
|
$vars = array_merge($context, $blockCtx);
|
||||||
|
$callback($vars);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function evaluateBlock(string $php, array $context): string
|
||||||
|
{
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
public function renderComponent(string $name, array $props): string
|
||||||
|
{
|
||||||
|
// For now, components are just templates with their own scope
|
||||||
|
return $this->doRender($name, $props);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function applyFilter(string $name, mixed $value, ...$args): mixed
|
||||||
|
{
|
||||||
|
if (isset($this->filters[$name])) {
|
||||||
|
return $this->filters[$name]($value, ...$args);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Built-in filters
|
||||||
|
switch ($name) {
|
||||||
|
case 'upper':
|
||||||
|
return strtoupper((string)$value);
|
||||||
|
case 'lower':
|
||||||
|
return strtolower((string)$value);
|
||||||
|
case 'raw':
|
||||||
|
return new SafeString((string)$value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function callHelper(string $name, ...$args): mixed
|
||||||
|
{
|
||||||
|
if (!isset($this->helpers[$name])) {
|
||||||
|
throw new \RuntimeException(sprintf('Helper "%s" not found.', $name));
|
||||||
|
}
|
||||||
|
|
||||||
|
return ($this->helpers[$name])(...$args);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addHelper(string $name, callable $helper): void
|
||||||
|
{
|
||||||
|
$this->helpers[$name] = $helper;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addFilter(string $name, callable $filter): void
|
||||||
|
{
|
||||||
|
$this->filters[$name] = $filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addGlobal(string $name, mixed $value): void
|
||||||
|
{
|
||||||
|
$this->globals[$name] = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLoader(): LoaderInterface
|
||||||
|
{
|
||||||
|
return $this->loader;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isDebug(): bool
|
||||||
|
{
|
||||||
|
return $this->debug;
|
||||||
|
}
|
||||||
|
}
|
||||||
69
src/Loader/FileLoader.php
Normal file
69
src/Loader/FileLoader.php
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Eyrie\Loader;
|
||||||
|
|
||||||
|
class FileLoader implements LoaderInterface
|
||||||
|
{
|
||||||
|
private array $paths;
|
||||||
|
private string $extension;
|
||||||
|
|
||||||
|
public function __construct(array $paths, string $extension = '.eyrie.php')
|
||||||
|
{
|
||||||
|
$this->paths = array_map(fn($path) => rtrim($path, DIRECTORY_SEPARATOR), $paths);
|
||||||
|
$this->extension = '.' . ltrim($extension, '.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function load(string $name): string
|
||||||
|
{
|
||||||
|
$path = $this->findTemplate($name);
|
||||||
|
|
||||||
|
if ($path === null) {
|
||||||
|
throw new \RuntimeException(sprintf('Template "%s" not found.', $name));
|
||||||
|
}
|
||||||
|
|
||||||
|
return file_get_contents($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exists(string $name): bool
|
||||||
|
{
|
||||||
|
return $this->findTemplate($name) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCacheKey(string $name): string
|
||||||
|
{
|
||||||
|
$path = $this->findTemplate($name);
|
||||||
|
|
||||||
|
if ($path === null) {
|
||||||
|
throw new \RuntimeException(sprintf('Template "%s" not found.', $name));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isFresh(string $name, int $timestamp): bool
|
||||||
|
{
|
||||||
|
$path = $this->findTemplate($name);
|
||||||
|
|
||||||
|
if ($path === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return filemtime($path) <= $timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findTemplate(string $name): ?string
|
||||||
|
{
|
||||||
|
$relative = str_replace('.', DIRECTORY_SEPARATOR, $name) . $this->extension;
|
||||||
|
|
||||||
|
foreach ($this->paths as $path) {
|
||||||
|
$fullPath = $path . DIRECTORY_SEPARATOR . $relative;
|
||||||
|
if (file_exists($fullPath)) {
|
||||||
|
return $fullPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
42
src/Loader/LoaderInterface.php
Normal file
42
src/Loader/LoaderInterface.php
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Eyrie\Loader;
|
||||||
|
|
||||||
|
interface LoaderInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Resolve a template name to its source content.
|
||||||
|
*
|
||||||
|
* @param string $name The dot-notated template name.
|
||||||
|
* @return string The template source content.
|
||||||
|
* @throws \RuntimeException If the template cannot be found.
|
||||||
|
*/
|
||||||
|
public function load(string $name): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a template exists.
|
||||||
|
*
|
||||||
|
* @param string $name The dot-notated template name.
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function exists(string $name): bool;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the cache key for a given template.
|
||||||
|
*
|
||||||
|
* @param string $name The dot-notated template name.
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function getCacheKey(string $name): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the template source has changed since the given timestamp.
|
||||||
|
*
|
||||||
|
* @param string $name The dot-notated template name.
|
||||||
|
* @param int $timestamp The last compilation timestamp.
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function isFresh(string $name, int $timestamp): bool;
|
||||||
|
}
|
||||||
169
src/Parser/Lexer.php
Normal file
169
src/Parser/Lexer.php
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Eyrie\Parser;
|
||||||
|
|
||||||
|
use Eyrie\Parser\TokenType;
|
||||||
|
|
||||||
|
class Lexer
|
||||||
|
{
|
||||||
|
private const DELIMITERS = [
|
||||||
|
'<<' => TokenType::OUTPUT_START,
|
||||||
|
'>>' => TokenType::OUTPUT_END,
|
||||||
|
'<(' => TokenType::CONTROL_START,
|
||||||
|
')>' => TokenType::CONTROL_END,
|
||||||
|
'[[' => TokenType::BLOCK_START,
|
||||||
|
']]' => TokenType::BLOCK_END,
|
||||||
|
'<@' => TokenType::COMPONENT_START,
|
||||||
|
'/>' => TokenType::COMPONENT_END,
|
||||||
|
];
|
||||||
|
|
||||||
|
private string $source;
|
||||||
|
private int $cursor = 0;
|
||||||
|
private int $line = 1;
|
||||||
|
private ?TokenType $state = null;
|
||||||
|
|
||||||
|
public function __construct(string $source)
|
||||||
|
{
|
||||||
|
$this->source = $source;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Token[]
|
||||||
|
*/
|
||||||
|
public function tokenize(): array
|
||||||
|
{
|
||||||
|
$tokens = [];
|
||||||
|
$length = strlen($this->source);
|
||||||
|
|
||||||
|
while ($this->cursor < $length) {
|
||||||
|
if ($this->state === null) {
|
||||||
|
$tokens[] = $this->lexText();
|
||||||
|
} else {
|
||||||
|
$tokens = array_merge($tokens, $this->lexExpression());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$tokens[] = new Token(TokenType::EOF, '', $this->line);
|
||||||
|
return array_filter($tokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function lexText(): ?Token
|
||||||
|
{
|
||||||
|
$start = $this->cursor;
|
||||||
|
$length = strlen($this->source);
|
||||||
|
$text = '';
|
||||||
|
|
||||||
|
while ($this->cursor < $length) {
|
||||||
|
foreach (self::DELIMITERS as $delim => $type) {
|
||||||
|
if (substr($this->source, $this->cursor, strlen($delim)) === $delim) {
|
||||||
|
if ($this->cursor > $start) {
|
||||||
|
return new Token(TokenType::TEXT, $text, $this->line);
|
||||||
|
}
|
||||||
|
$this->state = $type;
|
||||||
|
$this->cursor += strlen($delim);
|
||||||
|
return new Token($type, $delim, $this->line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$char = $this->source[$this->cursor];
|
||||||
|
if ($char === "\n") {
|
||||||
|
$this->line++;
|
||||||
|
}
|
||||||
|
$text .= $char;
|
||||||
|
$this->cursor++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $text !== '' ? new Token(TokenType::TEXT, $text, $this->line) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function lexExpression(): array
|
||||||
|
{
|
||||||
|
$tokens = [];
|
||||||
|
$length = strlen($this->source);
|
||||||
|
|
||||||
|
while ($this->cursor < $length) {
|
||||||
|
$this->skipWhitespace();
|
||||||
|
|
||||||
|
// Check for end delimiters
|
||||||
|
foreach (self::DELIMITERS as $delim => $type) {
|
||||||
|
if ($this->isEndDelimiter($type) && substr($this->source, $this->cursor, strlen($delim)) === $delim) {
|
||||||
|
$tokens[] = new Token($type, $delim, $this->line);
|
||||||
|
$this->cursor += strlen($delim);
|
||||||
|
$this->state = null;
|
||||||
|
return $tokens;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$char = $this->source[$this->cursor];
|
||||||
|
|
||||||
|
if (ctype_alpha($char) || $char === '_') {
|
||||||
|
$tokens[] = $this->lexIdentifier();
|
||||||
|
} elseif (ctype_digit($char)) {
|
||||||
|
$tokens[] = $this->lexNumber();
|
||||||
|
} elseif ($char === '"' || $char === "'") {
|
||||||
|
$tokens[] = $this->lexString($char);
|
||||||
|
} elseif ($char === '|') {
|
||||||
|
$tokens[] = new Token(TokenType::OPERATOR, '|', $this->line);
|
||||||
|
$this->cursor++;
|
||||||
|
} else {
|
||||||
|
// Operators or other symbols
|
||||||
|
$tokens[] = new Token(TokenType::OPERATOR, $char, $this->line);
|
||||||
|
$this->cursor++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isEndDelimiter(TokenType $type): bool
|
||||||
|
{
|
||||||
|
return in_array($type, [
|
||||||
|
TokenType::OUTPUT_END,
|
||||||
|
TokenType::CONTROL_END,
|
||||||
|
TokenType::BLOCK_END,
|
||||||
|
TokenType::COMPONENT_END
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function skipWhitespace(): void
|
||||||
|
{
|
||||||
|
while ($this->cursor < strlen($this->source) && ctype_space($this->source[$this->cursor])) {
|
||||||
|
if ($this->source[$this->cursor] === "\n") {
|
||||||
|
$this->line++;
|
||||||
|
}
|
||||||
|
$this->cursor++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function lexIdentifier(): Token
|
||||||
|
{
|
||||||
|
$start = $this->cursor;
|
||||||
|
while ($this->cursor < strlen($this->source) && (ctype_alnum($this->source[$this->cursor]) || $this->source[$this->cursor] === '_')) {
|
||||||
|
$this->cursor++;
|
||||||
|
}
|
||||||
|
return new Token(TokenType::IDENTIFIER, substr($this->source, $start, $this->cursor - $start), $this->line);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function lexNumber(): Token
|
||||||
|
{
|
||||||
|
$start = $this->cursor;
|
||||||
|
while ($this->cursor < strlen($this->source) && ctype_digit($this->source[$this->cursor])) {
|
||||||
|
$this->cursor++;
|
||||||
|
}
|
||||||
|
return new Token(TokenType::NUMBER, substr($this->source, $start, $this->cursor - $start), $this->line);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function lexString(string $quote): Token
|
||||||
|
{
|
||||||
|
$start = $this->cursor;
|
||||||
|
$this->cursor++; // Skip opening quote
|
||||||
|
while ($this->cursor < strlen($this->source) && $this->source[$this->cursor] !== $quote) {
|
||||||
|
$this->cursor++;
|
||||||
|
}
|
||||||
|
$this->cursor++; // Skip closing quote
|
||||||
|
$value = substr($this->source, $start, $this->cursor - $start);
|
||||||
|
return new Token(TokenType::STRING, $value, $this->line);
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/Parser/Node/BlockNode.php
Normal file
22
src/Parser/Node/BlockNode.php
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Eyrie\Parser\Node;
|
||||||
|
|
||||||
|
class BlockNode extends Node
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $name,
|
||||||
|
public readonly array $body,
|
||||||
|
public readonly array $context = [],
|
||||||
|
int $line = 0
|
||||||
|
) {
|
||||||
|
parent::__construct($line);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __toString(): string
|
||||||
|
{
|
||||||
|
return "[[ block " . $this->name . " ]]";
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/Parser/Node/ComponentNode.php
Normal file
21
src/Parser/Node/ComponentNode.php
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Eyrie\Parser\Node;
|
||||||
|
|
||||||
|
class ComponentNode extends Node
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $name,
|
||||||
|
public readonly array $props = [],
|
||||||
|
int $line = 0
|
||||||
|
) {
|
||||||
|
parent::__construct($line);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __toString(): string
|
||||||
|
{
|
||||||
|
return "<@ " . $this->name . " />";
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/Parser/Node/ExpressionNode.php
Normal file
21
src/Parser/Node/ExpressionNode.php
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Eyrie\Parser\Node;
|
||||||
|
|
||||||
|
class ExpressionNode extends Node
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $expression,
|
||||||
|
int $line,
|
||||||
|
public readonly array $filters = []
|
||||||
|
) {
|
||||||
|
parent::__construct($line);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __toString(): string
|
||||||
|
{
|
||||||
|
return "<< " . $this->expression . " >>";
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/Parser/Node/ExtendsNode.php
Normal file
20
src/Parser/Node/ExtendsNode.php
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Eyrie\Parser\Node;
|
||||||
|
|
||||||
|
class ExtendsNode extends Node
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $layout,
|
||||||
|
int $line = 0
|
||||||
|
) {
|
||||||
|
parent::__construct($line);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __toString(): string
|
||||||
|
{
|
||||||
|
return "[[ extends \"" . $this->layout . "\" ]]";
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/Parser/Node/ForeachNode.php
Normal file
22
src/Parser/Node/ForeachNode.php
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Eyrie\Parser\Node;
|
||||||
|
|
||||||
|
class ForeachNode extends Node
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $items,
|
||||||
|
public readonly string $item,
|
||||||
|
public readonly array $body,
|
||||||
|
int $line = 0
|
||||||
|
) {
|
||||||
|
parent::__construct($line);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __toString(): string
|
||||||
|
{
|
||||||
|
return "<( foreach " . $this->item . " in " . $this->items . " )>";
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/Parser/Node/IfNode.php
Normal file
23
src/Parser/Node/IfNode.php
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Eyrie\Parser\Node;
|
||||||
|
|
||||||
|
class IfNode extends Node
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $condition,
|
||||||
|
public readonly array $body,
|
||||||
|
public readonly array $elseifs = [],
|
||||||
|
public readonly ?array $else = null,
|
||||||
|
int $line = 0
|
||||||
|
) {
|
||||||
|
parent::__construct($line);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __toString(): string
|
||||||
|
{
|
||||||
|
return "<( if " . $this->condition . " )>";
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/Parser/Node/IncludeNode.php
Normal file
21
src/Parser/Node/IncludeNode.php
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Eyrie\Parser\Node;
|
||||||
|
|
||||||
|
class IncludeNode extends Node
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $template,
|
||||||
|
public readonly array $context = [],
|
||||||
|
int $line = 0
|
||||||
|
) {
|
||||||
|
parent::__construct($line);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __toString(): string
|
||||||
|
{
|
||||||
|
return "[[ include \"" . $this->template . "\" ]]";
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/Parser/Node/LoopNode.php
Normal file
22
src/Parser/Node/LoopNode.php
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Eyrie\Parser\Node;
|
||||||
|
|
||||||
|
class LoopNode extends Node
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $from,
|
||||||
|
public readonly string $to,
|
||||||
|
public readonly array $body,
|
||||||
|
int $line = 0
|
||||||
|
) {
|
||||||
|
parent::__construct($line);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __toString(): string
|
||||||
|
{
|
||||||
|
return "<( loop from " . $this->from . " to " . $this->to . " )>";
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/Parser/Node/Node.php
Normal file
14
src/Parser/Node/Node.php
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Eyrie\Parser\Node;
|
||||||
|
|
||||||
|
abstract class Node
|
||||||
|
{
|
||||||
|
public function __construct(public readonly int $line)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract public function __toString(): string;
|
||||||
|
}
|
||||||
21
src/Parser/Node/RootNode.php
Normal file
21
src/Parser/Node/RootNode.php
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Eyrie\Parser\Node;
|
||||||
|
|
||||||
|
class RootNode extends Node
|
||||||
|
{
|
||||||
|
/** @var Node[] */
|
||||||
|
public array $children = [];
|
||||||
|
|
||||||
|
public function __construct(int $line = 1)
|
||||||
|
{
|
||||||
|
parent::__construct($line);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __toString(): string
|
||||||
|
{
|
||||||
|
return implode('', array_map('strval', $this->children));
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/Parser/Node/SuperNode.php
Normal file
18
src/Parser/Node/SuperNode.php
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Eyrie\Parser\Node;
|
||||||
|
|
||||||
|
class SuperNode extends Node
|
||||||
|
{
|
||||||
|
public function __construct(int $line = 0)
|
||||||
|
{
|
||||||
|
parent::__construct($line);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __toString(): string
|
||||||
|
{
|
||||||
|
return "[[ super ]]";
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/Parser/Node/TextNode.php
Normal file
18
src/Parser/Node/TextNode.php
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Eyrie\Parser\Node;
|
||||||
|
|
||||||
|
class TextNode extends Node
|
||||||
|
{
|
||||||
|
public function __construct(public readonly string $content, int $line)
|
||||||
|
{
|
||||||
|
parent::__construct($line);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __toString(): string
|
||||||
|
{
|
||||||
|
return $this->content;
|
||||||
|
}
|
||||||
|
}
|
||||||
424
src/Parser/Parser.php
Normal file
424
src/Parser/Parser.php
Normal file
|
|
@ -0,0 +1,424 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Eyrie\Parser;
|
||||||
|
|
||||||
|
use Eyrie\Parser\Node\Node;
|
||||||
|
use Eyrie\Parser\Node\RootNode;
|
||||||
|
use Eyrie\Parser\Node\TextNode;
|
||||||
|
use Eyrie\Parser\Node\ExpressionNode;
|
||||||
|
use Eyrie\Parser\Node\IfNode;
|
||||||
|
use Eyrie\Parser\Node\ForeachNode;
|
||||||
|
use Eyrie\Parser\Node\LoopNode;
|
||||||
|
use Eyrie\Parser\Node\ExtendsNode;
|
||||||
|
use Eyrie\Parser\Node\BlockNode;
|
||||||
|
use Eyrie\Parser\Node\SuperNode;
|
||||||
|
use Eyrie\Parser\Node\IncludeNode;
|
||||||
|
use Eyrie\Parser\Node\ComponentNode;
|
||||||
|
|
||||||
|
class Parser
|
||||||
|
{
|
||||||
|
private array $tokens;
|
||||||
|
private int $cursor = 0;
|
||||||
|
|
||||||
|
public function __construct(array $tokens)
|
||||||
|
{
|
||||||
|
$this->tokens = $tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function parse(): RootNode
|
||||||
|
{
|
||||||
|
$root = new RootNode();
|
||||||
|
|
||||||
|
while (!$this->isEOF()) {
|
||||||
|
$node = $this->parseNode();
|
||||||
|
if ($node) {
|
||||||
|
$root->children[] = $node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $root;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parseNode(): ?Node
|
||||||
|
{
|
||||||
|
$token = $this->peek();
|
||||||
|
|
||||||
|
if ($token->type === TokenType::TEXT) {
|
||||||
|
$this->consume();
|
||||||
|
return new TextNode($token->value, $token->line);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($token->type === TokenType::OUTPUT_START) {
|
||||||
|
return $this->parseOutput();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($token->type === TokenType::CONTROL_START) {
|
||||||
|
return $this->parseControl();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($token->type === TokenType::BLOCK_START) {
|
||||||
|
return $this->parseBlockTag();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($token->type === TokenType::COMPONENT_START) {
|
||||||
|
return $this->parseComponent();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Placeholder for other tags
|
||||||
|
$this->consume();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parseOutput(): ExpressionNode
|
||||||
|
{
|
||||||
|
$startToken = $this->consume(TokenType::OUTPUT_START);
|
||||||
|
$expression = '';
|
||||||
|
$filters = [];
|
||||||
|
|
||||||
|
while (!$this->isEOF() && $this->peek()->type !== TokenType::OUTPUT_END) {
|
||||||
|
if ($this->peek()->value === '|') {
|
||||||
|
$this->consume(); // |
|
||||||
|
$filterName = $this->consume(TokenType::IDENTIFIER)->value;
|
||||||
|
$args = [];
|
||||||
|
if ($this->peek()->value === '(') {
|
||||||
|
$this->consume(); // (
|
||||||
|
while ($this->peek()->value !== ')') {
|
||||||
|
$args[] = $this->consume()->value;
|
||||||
|
if ($this->peek()->value === ',') $this->consume();
|
||||||
|
}
|
||||||
|
$this->consume(); // )
|
||||||
|
}
|
||||||
|
$filters[] = ['name' => $filterName, 'args' => $args];
|
||||||
|
} else {
|
||||||
|
$expression .= $this->consume()->value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->consume(TokenType::OUTPUT_END);
|
||||||
|
|
||||||
|
return new ExpressionNode(trim($expression), $startToken->line, $filters);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parseControl(): ?Node
|
||||||
|
{
|
||||||
|
$this->consume(TokenType::CONTROL_START);
|
||||||
|
$token = $this->peek();
|
||||||
|
|
||||||
|
if ($token->value === 'if') {
|
||||||
|
return $this->parseIf();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($token->value === 'foreach') {
|
||||||
|
return $this->parseForeach();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($token->value === 'loop') {
|
||||||
|
return $this->parseLoop();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle other control structures
|
||||||
|
while (!$this->isEOF() && $this->peek()->type !== TokenType::CONTROL_END) {
|
||||||
|
$this->consume();
|
||||||
|
}
|
||||||
|
$this->consume(TokenType::CONTROL_END);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parseIf(): IfNode
|
||||||
|
{
|
||||||
|
$startToken = $this->consume(); // consume 'if'
|
||||||
|
$condition = '';
|
||||||
|
while ($this->peek()->type !== TokenType::CONTROL_END) {
|
||||||
|
$condition .= $this->consume()->value . ' ';
|
||||||
|
}
|
||||||
|
$this->consume(TokenType::CONTROL_END);
|
||||||
|
|
||||||
|
$body = [];
|
||||||
|
$elseifs = [];
|
||||||
|
$else = null;
|
||||||
|
|
||||||
|
while (!$this->isEOF()) {
|
||||||
|
$token = $this->peek();
|
||||||
|
|
||||||
|
if ($token->type === TokenType::CONTROL_START) {
|
||||||
|
$next = $this->tokens[$this->cursor + 1] ?? null;
|
||||||
|
if ($next && $next->value === 'endif') {
|
||||||
|
$this->consume(TokenType::CONTROL_START);
|
||||||
|
$this->consume(); // endif
|
||||||
|
$this->consume(TokenType::CONTROL_END);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if ($next && $next->value === 'elseif') {
|
||||||
|
$this->consume(TokenType::CONTROL_START);
|
||||||
|
$this->consume(); // elseif
|
||||||
|
$elseifCond = '';
|
||||||
|
while ($this->peek()->type !== TokenType::CONTROL_END) {
|
||||||
|
$elseifCond .= $this->consume()->value . ' ';
|
||||||
|
}
|
||||||
|
$this->consume(TokenType::CONTROL_END);
|
||||||
|
$elseifBody = [];
|
||||||
|
while (!$this->isEOF()) {
|
||||||
|
$t = $this->peek();
|
||||||
|
if ($t->type === TokenType::CONTROL_START) {
|
||||||
|
$n = $this->tokens[$this->cursor + 1] ?? null;
|
||||||
|
if ($n && in_array($n->value, ['elseif', 'else', 'endif'])) break;
|
||||||
|
}
|
||||||
|
$elseifBody[] = $this->parseNode();
|
||||||
|
}
|
||||||
|
$elseifs[] = ['condition' => trim($elseifCond), 'body' => array_filter($elseifBody)];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($next && $next->value === 'else') {
|
||||||
|
$this->consume(TokenType::CONTROL_START);
|
||||||
|
$this->consume(); // else
|
||||||
|
$this->consume(TokenType::CONTROL_END);
|
||||||
|
$elseBody = [];
|
||||||
|
while (!$this->isEOF()) {
|
||||||
|
$t = $this->peek();
|
||||||
|
if ($t->type === TokenType::CONTROL_START) {
|
||||||
|
$n = $this->tokens[$this->cursor + 1] ?? null;
|
||||||
|
if ($n && $n->value === 'endif') break;
|
||||||
|
}
|
||||||
|
$elseBody[] = $this->parseNode();
|
||||||
|
}
|
||||||
|
$else = array_filter($elseBody);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$body[] = $this->parseNode();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new IfNode(trim($condition), array_filter($body), $elseifs, $else, $startToken->line);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parseForeach(): ForeachNode
|
||||||
|
{
|
||||||
|
$startToken = $this->consume(); // consume 'foreach'
|
||||||
|
$item = $this->consume(TokenType::IDENTIFIER)->value;
|
||||||
|
$this->consume(TokenType::IDENTIFIER); // consume 'in'
|
||||||
|
|
||||||
|
$items = '';
|
||||||
|
while ($this->peek()->type !== TokenType::CONTROL_END) {
|
||||||
|
$items .= $this->consume()->value . ' ';
|
||||||
|
}
|
||||||
|
$this->consume(TokenType::CONTROL_END);
|
||||||
|
|
||||||
|
$body = [];
|
||||||
|
while (!$this->isEOF()) {
|
||||||
|
$token = $this->peek();
|
||||||
|
if ($token->type === TokenType::CONTROL_START) {
|
||||||
|
$next = $this->tokens[$this->cursor + 1] ?? null;
|
||||||
|
if ($next && $next->value === 'endforeach') {
|
||||||
|
$this->consume(TokenType::CONTROL_START);
|
||||||
|
$this->consume(); // endforeach
|
||||||
|
$this->consume(TokenType::CONTROL_END);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$body[] = $this->parseNode();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ForeachNode(trim($items), $item, array_filter($body), $startToken->line);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parseLoop(): LoopNode
|
||||||
|
{
|
||||||
|
$startToken = $this->consume(); // consume 'loop'
|
||||||
|
$this->consume(TokenType::IDENTIFIER); // consume 'from'
|
||||||
|
|
||||||
|
$from = '';
|
||||||
|
while ($this->peek()->value !== 'to') {
|
||||||
|
$from .= $this->consume()->value . ' ';
|
||||||
|
}
|
||||||
|
$this->consume(TokenType::IDENTIFIER); // consume 'to'
|
||||||
|
|
||||||
|
$to = '';
|
||||||
|
while ($this->peek()->type !== TokenType::CONTROL_END) {
|
||||||
|
$to .= $this->consume()->value . ' ';
|
||||||
|
}
|
||||||
|
$this->consume(TokenType::CONTROL_END);
|
||||||
|
|
||||||
|
$body = [];
|
||||||
|
while (!$this->isEOF()) {
|
||||||
|
$token = $this->peek();
|
||||||
|
if ($token->type === TokenType::CONTROL_START) {
|
||||||
|
$next = $this->tokens[$this->cursor + 1] ?? null;
|
||||||
|
if ($next && $next->value === 'endloop') {
|
||||||
|
$this->consume(TokenType::CONTROL_START);
|
||||||
|
$this->consume(); // endloop
|
||||||
|
$this->consume(TokenType::CONTROL_END);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$body[] = $this->parseNode();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new LoopNode(trim($from), trim($to), array_filter($body), $startToken->line);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parseBlockTag(): ?Node
|
||||||
|
{
|
||||||
|
$this->consume(TokenType::BLOCK_START);
|
||||||
|
$token = $this->peek();
|
||||||
|
|
||||||
|
if ($token->value === 'extends') {
|
||||||
|
return $this->parseExtends();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($token->value === 'block') {
|
||||||
|
return $this->parseBlock();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($token->value === 'super') {
|
||||||
|
return $this->parseSuper();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($token->value === 'include') {
|
||||||
|
return $this->parseInclude();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle other block tags (e.g. include, super)
|
||||||
|
while (!$this->isEOF() && $this->peek()->type !== TokenType::BLOCK_END) {
|
||||||
|
$this->consume();
|
||||||
|
}
|
||||||
|
$this->consume(TokenType::BLOCK_END);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parseExtends(): ExtendsNode
|
||||||
|
{
|
||||||
|
$startToken = $this->consume(); // extends
|
||||||
|
$layout = $this->consume(TokenType::STRING)->value;
|
||||||
|
$layout = substr($layout, 1, -1); // Strip quotes
|
||||||
|
$this->consume(TokenType::BLOCK_END);
|
||||||
|
|
||||||
|
return new ExtendsNode($layout, $startToken->line);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parseBlock(): BlockNode
|
||||||
|
{
|
||||||
|
$startToken = $this->consume(); // block
|
||||||
|
$name = $this->consume(TokenType::IDENTIFIER)->value;
|
||||||
|
$context = [];
|
||||||
|
|
||||||
|
if ($this->peek()->value === 'with') {
|
||||||
|
$this->consume(); // with
|
||||||
|
if ($this->peek()->value === '{') {
|
||||||
|
$this->consume(); // {
|
||||||
|
while (!$this->isEOF() && $this->peek()->value !== '}') {
|
||||||
|
$propName = $this->consume(TokenType::IDENTIFIER)->value;
|
||||||
|
$this->consume(TokenType::OPERATOR); // :
|
||||||
|
|
||||||
|
// Consume the value correctly
|
||||||
|
$valueToken = $this->consume();
|
||||||
|
$expr = $valueToken->value;
|
||||||
|
|
||||||
|
$context[$propName] = ['expr' => $expr];
|
||||||
|
if ($this->peek()->value === ',') {
|
||||||
|
$this->consume();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($this->peek()->value === '}') {
|
||||||
|
$this->consume(); // }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->consume(TokenType::BLOCK_END);
|
||||||
|
|
||||||
|
$body = [];
|
||||||
|
while (!$this->isEOF()) {
|
||||||
|
$token = $this->peek();
|
||||||
|
if ($token->type === TokenType::BLOCK_START) {
|
||||||
|
$next = $this->tokens[$this->cursor + 1] ?? null;
|
||||||
|
if ($next && $next->value === 'endblock') {
|
||||||
|
$this->consume(TokenType::BLOCK_START);
|
||||||
|
$this->consume(); // endblock
|
||||||
|
$this->consume(TokenType::BLOCK_END);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$body[] = $this->parseNode();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new BlockNode($name, array_filter($body), $context, $startToken->line);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parseSuper(): SuperNode
|
||||||
|
{
|
||||||
|
$startToken = $this->consume(); // super
|
||||||
|
$this->consume(TokenType::BLOCK_END);
|
||||||
|
|
||||||
|
return new SuperNode($startToken->line);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parseInclude(): IncludeNode
|
||||||
|
{
|
||||||
|
$startToken = $this->consume(); // include
|
||||||
|
$template = $this->consume(TokenType::STRING)->value;
|
||||||
|
$template = substr($template, 1, -1); // Strip quotes
|
||||||
|
$this->consume(TokenType::BLOCK_END);
|
||||||
|
|
||||||
|
return new IncludeNode($template, [], $startToken->line);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parseComponent(): ComponentNode
|
||||||
|
{
|
||||||
|
$startToken = $this->consume(TokenType::COMPONENT_START);
|
||||||
|
$name = $this->consume(TokenType::IDENTIFIER)->value;
|
||||||
|
$props = [];
|
||||||
|
|
||||||
|
while ($this->peek()->type !== TokenType::COMPONENT_END) {
|
||||||
|
$propName = $this->consume(TokenType::IDENTIFIER)->value;
|
||||||
|
$this->consume(TokenType::OPERATOR); // =
|
||||||
|
|
||||||
|
$token = $this->peek();
|
||||||
|
if ($token->type === TokenType::STRING) {
|
||||||
|
$val = $this->consume()->value;
|
||||||
|
$props[$propName] = substr($val, 1, -1); // Strip quotes
|
||||||
|
} elseif ($token->value === '{') {
|
||||||
|
$this->consume(); // {
|
||||||
|
$expr = '';
|
||||||
|
while ($this->peek()->value !== '}') {
|
||||||
|
$exprToken = $this->consume();
|
||||||
|
$expr .= $exprToken->value;
|
||||||
|
}
|
||||||
|
$this->consume(); // }
|
||||||
|
$props[$propName] = ['expr' => trim($expr)];
|
||||||
|
} else {
|
||||||
|
$props[$propName] = $this->consume()->value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->consume(TokenType::COMPONENT_END);
|
||||||
|
|
||||||
|
return new ComponentNode($name, $props, $startToken->line);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function peek(): Token
|
||||||
|
{
|
||||||
|
return $this->tokens[$this->cursor];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function consume(?TokenType $type = null): Token
|
||||||
|
{
|
||||||
|
$token = $this->peek();
|
||||||
|
|
||||||
|
if ($type !== null && $token->type !== $type) {
|
||||||
|
throw new \RuntimeException(sprintf('Expected token %s, got %s at line %d', $type->value, $token->type->value, $token->line));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->cursor++;
|
||||||
|
return $token;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isEOF(): bool
|
||||||
|
{
|
||||||
|
return $this->peek()->type === TokenType::EOF;
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/Parser/Token.php
Normal file
15
src/Parser/Token.php
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Eyrie\Parser;
|
||||||
|
|
||||||
|
class Token
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly TokenType $type,
|
||||||
|
public readonly string $value,
|
||||||
|
public readonly int $line
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/Parser/TokenType.php
Normal file
23
src/Parser/TokenType.php
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Eyrie\Parser;
|
||||||
|
|
||||||
|
enum TokenType: string
|
||||||
|
{
|
||||||
|
case TEXT = 'text';
|
||||||
|
case OUTPUT_START = 'output_start'; // <<
|
||||||
|
case OUTPUT_END = 'output_end'; // >>
|
||||||
|
case CONTROL_START = 'control_start'; // <(
|
||||||
|
case CONTROL_END = 'control_end'; // )>
|
||||||
|
case BLOCK_START = 'block_start'; // [[
|
||||||
|
case BLOCK_END = 'block_end'; // ]]
|
||||||
|
case COMPONENT_START = 'component_start'; // <@
|
||||||
|
case COMPONENT_END = 'component_end'; // />
|
||||||
|
case IDENTIFIER = 'identifier';
|
||||||
|
case STRING = 'string';
|
||||||
|
case NUMBER = 'number';
|
||||||
|
case OPERATOR = 'operator';
|
||||||
|
case EOF = 'eof';
|
||||||
|
}
|
||||||
17
src/SafeString.php
Normal file
17
src/SafeString.php
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Eyrie;
|
||||||
|
|
||||||
|
class SafeString implements \Stringable
|
||||||
|
{
|
||||||
|
public function __construct(private readonly string $value)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __toString(): string
|
||||||
|
{
|
||||||
|
return $this->value;
|
||||||
|
}
|
||||||
|
}
|
||||||
212
tests/EngineTest.php
Normal file
212
tests/EngineTest.php
Normal file
|
|
@ -0,0 +1,212 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Eyrie\Tests;
|
||||||
|
|
||||||
|
use Eyrie\Engine;
|
||||||
|
use Eyrie\Loader\FileLoader;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class EngineTest extends TestCase
|
||||||
|
{
|
||||||
|
private string $tempDir;
|
||||||
|
private string $cacheDir;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->tempDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'eyrie_tests_' . uniqid();
|
||||||
|
mkdir($this->tempDir);
|
||||||
|
$this->cacheDir = $this->tempDir . DIRECTORY_SEPARATOR . 'cache';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
$this->removeDirectory($this->tempDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRenderSimpleText(): void
|
||||||
|
{
|
||||||
|
file_put_contents($this->tempDir . '/hello.eyrie.php', 'Hello World');
|
||||||
|
$loader = new FileLoader([$this->tempDir]);
|
||||||
|
$engine = new Engine($loader, ['cache' => $this->cacheDir]);
|
||||||
|
|
||||||
|
$this->assertEquals('Hello World', $engine->render('hello'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRenderVariable(): void
|
||||||
|
{
|
||||||
|
file_put_contents($this->tempDir . '/greet.eyrie.php', 'Hello << name >>!');
|
||||||
|
$loader = new FileLoader([$this->tempDir]);
|
||||||
|
$engine = new Engine($loader, ['cache' => $this->cacheDir]);
|
||||||
|
|
||||||
|
$this->assertEquals('Hello Junie!', $engine->render('greet', ['name' => 'Junie']));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAutoEscaping(): void
|
||||||
|
{
|
||||||
|
file_put_contents($this->tempDir . '/escape.eyrie.php', '<< content >>');
|
||||||
|
$loader = new FileLoader([$this->tempDir]);
|
||||||
|
$engine = new Engine($loader, ['cache' => $this->cacheDir]);
|
||||||
|
|
||||||
|
$this->assertEquals('<script>', $engine->render('escape', ['content' => '<script>']));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRenderIf(): void
|
||||||
|
{
|
||||||
|
$template = '
|
||||||
|
<( if show )>
|
||||||
|
Visible
|
||||||
|
<( endif )>
|
||||||
|
';
|
||||||
|
file_put_contents($this->tempDir . '/if.eyrie.php', $template);
|
||||||
|
$loader = new FileLoader([$this->tempDir]);
|
||||||
|
$engine = new Engine($loader, ['cache' => $this->cacheDir]);
|
||||||
|
|
||||||
|
$this->assertStringContainsString('Visible', $engine->render('if', ['show' => true]));
|
||||||
|
$this->assertStringNotContainsString('Visible', $engine->render('if', ['show' => false]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRenderIfElse(): void
|
||||||
|
{
|
||||||
|
$template = '
|
||||||
|
<( if show )>
|
||||||
|
Yes
|
||||||
|
<( else )>
|
||||||
|
No
|
||||||
|
<( endif )>
|
||||||
|
';
|
||||||
|
file_put_contents($this->tempDir . '/ifelse.eyrie.php', $template);
|
||||||
|
$loader = new FileLoader([$this->tempDir]);
|
||||||
|
$engine = new Engine($loader, ['cache' => $this->cacheDir]);
|
||||||
|
|
||||||
|
$this->assertStringContainsString('Yes', $engine->render('ifelse', ['show' => true]));
|
||||||
|
$this->assertStringContainsString('No', $engine->render('ifelse', ['show' => false]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRenderDotNotation(): void
|
||||||
|
{
|
||||||
|
file_put_contents($this->tempDir . '/dot.eyrie.php', '<< user.name >>');
|
||||||
|
$loader = new FileLoader([$this->tempDir]);
|
||||||
|
$engine = new Engine($loader, ['cache' => $this->cacheDir, 'debug' => true]);
|
||||||
|
|
||||||
|
$this->assertEquals('Junie', $engine->render('dot', ['user' => ['name' => 'Junie']]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRenderForeach(): void
|
||||||
|
{
|
||||||
|
$template = '
|
||||||
|
<( foreach item in items )>
|
||||||
|
<< item >>
|
||||||
|
<( endforeach )>
|
||||||
|
';
|
||||||
|
file_put_contents($this->tempDir . '/foreach.eyrie.php', $template);
|
||||||
|
$loader = new FileLoader([$this->tempDir]);
|
||||||
|
$engine = new Engine($loader, ['cache' => $this->cacheDir]);
|
||||||
|
|
||||||
|
$result = $engine->render('foreach', ['items' => ['A', 'B', 'C']]);
|
||||||
|
$this->assertStringContainsString('A', $result);
|
||||||
|
$this->assertStringContainsString('B', $result);
|
||||||
|
$this->assertStringContainsString('C', $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRenderLoop(): void
|
||||||
|
{
|
||||||
|
$template = '
|
||||||
|
<( loop from 1 to 3 )>
|
||||||
|
<< loop.index >>:<< loop.last >>
|
||||||
|
<( endloop )>
|
||||||
|
';
|
||||||
|
file_put_contents($this->tempDir . '/loop.eyrie.php', $template);
|
||||||
|
$loader = new FileLoader([$this->tempDir]);
|
||||||
|
$engine = new Engine($loader, ['cache' => $this->cacheDir]);
|
||||||
|
|
||||||
|
$result = $engine->render('loop');
|
||||||
|
$this->assertStringContainsString('0:false', $result);
|
||||||
|
$this->assertStringContainsString('1:false', $result);
|
||||||
|
$this->assertStringContainsString('2:true', $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRenderInheritance(): void
|
||||||
|
{
|
||||||
|
file_put_contents($this->tempDir . '/layout.eyrie.php', '<html><body>[[ block content ]]Default[[ endblock ]]</body></html>');
|
||||||
|
file_put_contents($this->tempDir . '/page.eyrie.php', '[[ extends "layout" ]] [[ block content ]]Page Content[[ endblock ]]');
|
||||||
|
|
||||||
|
$loader = new FileLoader([$this->tempDir]);
|
||||||
|
$engine = new Engine($loader, ['cache' => $this->cacheDir]);
|
||||||
|
|
||||||
|
$result = $engine->render('page');
|
||||||
|
$this->assertStringContainsString('<html><body>Page Content</body></html>', $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRenderSuper(): void
|
||||||
|
{
|
||||||
|
file_put_contents($this->tempDir . '/layout_super.eyrie.php', '[[ block content ]]Default[[ endblock ]]');
|
||||||
|
file_put_contents($this->tempDir . '/page_super.eyrie.php', '[[ extends "layout_super" ]] [[ block content ]]Child [[ super ]] Content[[ endblock ]]');
|
||||||
|
|
||||||
|
$loader = new FileLoader([$this->tempDir]);
|
||||||
|
$engine = new Engine($loader, ['cache' => $this->cacheDir]);
|
||||||
|
|
||||||
|
$result = $engine->render('page_super');
|
||||||
|
$this->assertStringContainsString('Child Default Content', $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRenderInclude(): void
|
||||||
|
{
|
||||||
|
file_put_contents($this->tempDir . '/partial.eyrie.php', 'Partial Content');
|
||||||
|
file_put_contents($this->tempDir . '/main.eyrie.php', 'Main [[ include "partial" ]]');
|
||||||
|
|
||||||
|
$loader = new FileLoader([$this->tempDir]);
|
||||||
|
$engine = new Engine($loader, ['cache' => $this->cacheDir]);
|
||||||
|
|
||||||
|
$result = $engine->render('main');
|
||||||
|
$this->assertStringContainsString('Main Partial Content', $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRenderComponent(): void
|
||||||
|
{
|
||||||
|
file_put_contents($this->tempDir . '/Movie.eyrie.php', 'Movie: << title >>');
|
||||||
|
file_put_contents($this->tempDir . '/page_comp.eyrie.php', '<@ Movie title="Inception" />');
|
||||||
|
|
||||||
|
$loader = new FileLoader([$this->tempDir]);
|
||||||
|
$engine = new Engine($loader, ['cache' => $this->cacheDir]);
|
||||||
|
|
||||||
|
$result = $engine->render('page_comp');
|
||||||
|
$this->assertStringContainsString('Movie: Inception', $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRenderFilters(): void
|
||||||
|
{
|
||||||
|
file_put_contents($this->tempDir . '/filter.eyrie.php', '<< name | upper >>');
|
||||||
|
$loader = new FileLoader([$this->tempDir]);
|
||||||
|
$engine = new Engine($loader, ['cache' => $this->cacheDir]);
|
||||||
|
|
||||||
|
$result = $engine->render('filter', ['name' => 'junie']);
|
||||||
|
$this->assertEquals('JUNIE', $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRenderBlockContext(): void
|
||||||
|
{
|
||||||
|
file_put_contents($this->tempDir . '/layout_ctx.eyrie.php', '[[ block sidebar ]]Default[[ endblock ]]');
|
||||||
|
file_put_contents($this->tempDir . '/page_ctx.eyrie.php', '[[ extends "layout_ctx" ]] [[ block sidebar with { user: "Junie" } ]]Hello << user >>[[ endblock ]]');
|
||||||
|
|
||||||
|
$loader = new FileLoader([$this->tempDir]);
|
||||||
|
$engine = new Engine($loader, ['cache' => $this->cacheDir]);
|
||||||
|
|
||||||
|
$result = $engine->render('page_ctx');
|
||||||
|
$this->assertStringContainsString('Hello Junie', $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function removeDirectory(string $dir): void
|
||||||
|
{
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$files = array_diff(scandir($dir), ['.', '..']);
|
||||||
|
foreach ($files as $file) {
|
||||||
|
(is_dir("$dir/$file")) ? $this->removeDirectory("$dir/$file") : unlink("$dir/$file");
|
||||||
|
}
|
||||||
|
rmdir($dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
55
tests/HelperTest.php
Normal file
55
tests/HelperTest.php
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Eyrie\Tests;
|
||||||
|
|
||||||
|
use Eyrie\Engine;
|
||||||
|
use Eyrie\Loader\FileLoader;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class HelperTest extends TestCase
|
||||||
|
{
|
||||||
|
private string $tempDir;
|
||||||
|
private string $cacheDir;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->tempDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'eyrie_helper_tests_' . uniqid();
|
||||||
|
mkdir($this->tempDir);
|
||||||
|
$this->cacheDir = $this->tempDir . DIRECTORY_SEPARATOR . 'cache';
|
||||||
|
mkdir($this->cacheDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
$this->removeDirectory($this->tempDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testHelperCall(): void
|
||||||
|
{
|
||||||
|
file_put_contents($this->tempDir . '/helper.eyrie.php', '<< greet("Junie") >>');
|
||||||
|
$loader = new FileLoader([$this->tempDir]);
|
||||||
|
$engine = new Engine($loader, ['cache' => $this->cacheDir, 'debug' => true]);
|
||||||
|
|
||||||
|
$engine->addHelper('greet', function(string $name) {
|
||||||
|
return "Hello, $name!";
|
||||||
|
});
|
||||||
|
|
||||||
|
$result = $engine->render('helper');
|
||||||
|
$this->assertEquals('Hello, Junie!', $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function removeDirectory(string $dir): void
|
||||||
|
{
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$files = array_diff(scandir($dir), ['.', '..']);
|
||||||
|
foreach ($files as $file) {
|
||||||
|
(is_dir("$dir/$file")) ? $this->removeDirectory("$dir/$file") : unlink("$dir/$file");
|
||||||
|
}
|
||||||
|
rmdir($dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
85
tests/Loader/FileLoaderTest.php
Normal file
85
tests/Loader/FileLoaderTest.php
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Eyrie\Tests\Loader;
|
||||||
|
|
||||||
|
use Eyrie\Loader\FileLoader;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class FileLoaderTest extends TestCase
|
||||||
|
{
|
||||||
|
private string $tempDir;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->tempDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'eyrie_tests_' . uniqid();
|
||||||
|
mkdir($this->tempDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
$this->removeDirectory($this->tempDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLoadExistingTemplate(): void
|
||||||
|
{
|
||||||
|
$templatePath = $this->tempDir . DIRECTORY_SEPARATOR . 'test.eyrie.php';
|
||||||
|
file_put_contents($templatePath, 'Hello World');
|
||||||
|
|
||||||
|
$loader = new FileLoader([$this->tempDir]);
|
||||||
|
$this->assertEquals('Hello World', $loader->load('test'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLoadDotNotationTemplate(): void
|
||||||
|
{
|
||||||
|
$subDir = $this->tempDir . DIRECTORY_SEPARATOR . 'layouts';
|
||||||
|
mkdir($subDir);
|
||||||
|
$templatePath = $subDir . DIRECTORY_SEPARATOR . 'base.eyrie.php';
|
||||||
|
file_put_contents($templatePath, 'Layout Content');
|
||||||
|
|
||||||
|
$loader = new FileLoader([$this->tempDir]);
|
||||||
|
$this->assertEquals('Layout Content', $loader->load('layouts.base'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLoadNonExistentTemplateThrowsException(): void
|
||||||
|
{
|
||||||
|
$loader = new FileLoader([$this->tempDir]);
|
||||||
|
$this->expectException(\RuntimeException::class);
|
||||||
|
$loader->load('non_existent');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testExists(): void
|
||||||
|
{
|
||||||
|
$templatePath = $this->tempDir . DIRECTORY_SEPARATOR . 'test.eyrie.php';
|
||||||
|
file_put_contents($templatePath, 'Hello World');
|
||||||
|
|
||||||
|
$loader = new FileLoader([$this->tempDir]);
|
||||||
|
$this->assertTrue($loader->exists('test'));
|
||||||
|
$this->assertFalse($loader->exists('non_existent'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIsFresh(): void
|
||||||
|
{
|
||||||
|
$templatePath = $this->tempDir . DIRECTORY_SEPARATOR . 'test.eyrie.php';
|
||||||
|
file_put_contents($templatePath, 'Hello World');
|
||||||
|
$time = time();
|
||||||
|
|
||||||
|
$loader = new FileLoader([$this->tempDir]);
|
||||||
|
$this->assertTrue($loader->isFresh('test', $time + 10));
|
||||||
|
$this->assertFalse($loader->isFresh('test', $time - 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function removeDirectory(string $dir): void
|
||||||
|
{
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$files = array_diff(scandir($dir), ['.', '..']);
|
||||||
|
foreach ($files as $file) {
|
||||||
|
(is_dir("$dir/$file")) ? $this->removeDirectory("$dir/$file") : unlink("$dir/$file");
|
||||||
|
}
|
||||||
|
rmdir($dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
65
tests/Parser/LexerTest.php
Normal file
65
tests/Parser/LexerTest.php
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Eyrie\Tests\Parser;
|
||||||
|
|
||||||
|
use Eyrie\Parser\Lexer;
|
||||||
|
use Eyrie\Parser\TokenType;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class LexerTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testTokenizeTextOnly(): void
|
||||||
|
{
|
||||||
|
$lexer = new Lexer('Hello World');
|
||||||
|
$tokens = $lexer->tokenize();
|
||||||
|
|
||||||
|
$this->assertCount(2, $tokens);
|
||||||
|
$this->assertEquals(TokenType::TEXT, $tokens[0]->type);
|
||||||
|
$this->assertEquals('Hello World', $tokens[0]->value);
|
||||||
|
$this->assertEquals(TokenType::EOF, $tokens[1]->type);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTokenizeOutput(): void
|
||||||
|
{
|
||||||
|
$lexer = new Lexer('<< name >>');
|
||||||
|
$tokens = $lexer->tokenize();
|
||||||
|
|
||||||
|
$this->assertCount(4, $tokens);
|
||||||
|
$this->assertEquals(TokenType::OUTPUT_START, $tokens[0]->type);
|
||||||
|
$this->assertEquals(TokenType::IDENTIFIER, $tokens[1]->type);
|
||||||
|
$this->assertEquals('name', $tokens[1]->value);
|
||||||
|
$this->assertEquals(TokenType::OUTPUT_END, $tokens[2]->type);
|
||||||
|
$this->assertEquals(TokenType::EOF, $tokens[3]->type);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTokenizeControl(): void
|
||||||
|
{
|
||||||
|
$lexer = new Lexer('<( if true )>');
|
||||||
|
$tokens = $lexer->tokenize();
|
||||||
|
|
||||||
|
$this->assertCount(5, $tokens);
|
||||||
|
$this->assertEquals(TokenType::CONTROL_START, $tokens[0]->type);
|
||||||
|
$this->assertEquals(TokenType::IDENTIFIER, $tokens[1]->type);
|
||||||
|
$this->assertEquals('if', $tokens[1]->value);
|
||||||
|
$this->assertEquals(TokenType::IDENTIFIER, $tokens[2]->type);
|
||||||
|
$this->assertEquals('true', $tokens[2]->value);
|
||||||
|
$this->assertEquals(TokenType::CONTROL_END, $tokens[3]->type);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTokenizeMixed(): void
|
||||||
|
{
|
||||||
|
$lexer = new Lexer('Hello << name >>!');
|
||||||
|
$tokens = $lexer->tokenize();
|
||||||
|
|
||||||
|
$this->assertCount(6, $tokens);
|
||||||
|
$this->assertEquals(TokenType::TEXT, $tokens[0]->type);
|
||||||
|
$this->assertEquals('Hello ', $tokens[0]->value);
|
||||||
|
$this->assertEquals(TokenType::OUTPUT_START, $tokens[1]->type);
|
||||||
|
$this->assertEquals(TokenType::IDENTIFIER, $tokens[2]->type);
|
||||||
|
$this->assertEquals(TokenType::OUTPUT_END, $tokens[3]->type);
|
||||||
|
$this->assertEquals(TokenType::TEXT, $tokens[4]->type);
|
||||||
|
$this->assertEquals('!', $tokens[4]->value);
|
||||||
|
}
|
||||||
|
}
|
||||||
50
tests/Parser/ParserTest.php
Normal file
50
tests/Parser/ParserTest.php
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Eyrie\Tests\Parser;
|
||||||
|
|
||||||
|
use Eyrie\Parser\Lexer;
|
||||||
|
use Eyrie\Parser\Parser;
|
||||||
|
use Eyrie\Parser\Node\RootNode;
|
||||||
|
use Eyrie\Parser\Node\TextNode;
|
||||||
|
use Eyrie\Parser\Node\ExpressionNode;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class ParserTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testParseSimpleText(): void
|
||||||
|
{
|
||||||
|
$lexer = new Lexer('Hello World');
|
||||||
|
$parser = new Parser($lexer->tokenize());
|
||||||
|
$root = $parser->parse();
|
||||||
|
|
||||||
|
$this->assertInstanceOf(RootNode::class, $root);
|
||||||
|
$this->assertCount(1, $root->children);
|
||||||
|
$this->assertInstanceOf(TextNode::class, $root->children[0]);
|
||||||
|
$this->assertEquals('Hello World', $root->children[0]->content);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testParseExpression(): void
|
||||||
|
{
|
||||||
|
$lexer = new Lexer('<< name >>');
|
||||||
|
$parser = new Parser($lexer->tokenize());
|
||||||
|
$root = $parser->parse();
|
||||||
|
|
||||||
|
$this->assertCount(1, $root->children);
|
||||||
|
$this->assertInstanceOf(ExpressionNode::class, $root->children[0]);
|
||||||
|
$this->assertEquals('name', $root->children[0]->expression);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testParseMixed(): void
|
||||||
|
{
|
||||||
|
$lexer = new Lexer('Hello << name >>!');
|
||||||
|
$parser = new Parser($lexer->tokenize());
|
||||||
|
$root = $parser->parse();
|
||||||
|
|
||||||
|
$this->assertCount(3, $root->children);
|
||||||
|
$this->assertInstanceOf(TextNode::class, $root->children[0]);
|
||||||
|
$this->assertInstanceOf(ExpressionNode::class, $root->children[1]);
|
||||||
|
$this->assertInstanceOf(TextNode::class, $root->children[2]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue