Compare commits

...

7 commits

82 changed files with 3514 additions and 2419 deletions

8
.gitattributes vendored Normal file
View file

@ -0,0 +1,8 @@
/tests export-ignore
/.scape export-ignore
/LOG.md export-ignore
/SPECS.md export-ignore
/MILESTONES.md export-ignore
/phpunit.xml export-ignore
.gitignore export-ignore
.gitattributes export-ignore

5
.gitignore vendored
View file

@ -10,5 +10,6 @@ crashlytics-build.properties
fabric.properties
composer.phar
/vendor/
.junie/
.phpunit.cache/
.junie
.phpunit.cache/
.scape

View file

@ -1,52 +0,0 @@
# 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 harassmentfree experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socioeconomic 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.

View file

@ -1,42 +0,0 @@
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 repositorys LICENSE.

19
LOG.md Normal file
View file

@ -0,0 +1,19 @@
# LOG
### Incident #001: Stale Rules for Deleted Section
- **Date**: 2026-02-08
- **Rule Broken**: [R8] - Handover requirements, [DoD 3.4] - Documentation requirements
- **Triggering Prompt**: Have you read the rules you must be following?
- **Description**: The `## Milestones` section in `guidelines.md` was removed in a previous session (at user request/approval) as redundant. However, Rule [R8], the Definition of Done (DoD), and the Lifecycle Priority list still contained mandatory steps to update this deleted section. This created a contradiction where following the rules was impossible.
- **Impact Assessment**: Medium - Affects the 'Handover' process and rule compliance verification.
- **Root Cause**: Failure to update all dependent guideline modules (Rules, DoD, Lifecycle) when the primary structure of the guidelines was modified.
- **Prevention Strategy**: Perform a project-wide search for any references to a modified or deleted guideline section to ensure all dependent rules and documentation are updated simultaneously.
### Incident #002: Mandatory Lifecycle Bypass
- **Date**: 2026-02-08
- **Rule Broken**: [R10] - Mandatory Lifecycle for All Work
- **Triggering Prompt**: Have you read the rules you must be following?
- **Description**: The agent modified multiple files (Rules, DoD, Lifecycle, and created LOG.md) without first presenting a detailed plan or obtaining explicit user permission to switch to CODE mode.
- **Impact Assessment**: Critical - Undermines the primary workflow control and user oversight.
- **Root Cause**: The agent prioritized immediate rule synchronization over the mandatory procedural steps of the lifecycle.
- **Prevention Strategy**: Strictly adhere to the Mandatory Lifecycle steps 3, 4, and 5 for every modification, regardless of the task's perceived urgency or simplicity.

View file

@ -1,59 +1,59 @@
# Milestones
# Scape Templates — Project Milestones
This document outlines the phased roadmap for building Scape Templates. Each milestone is designed to ensure "Excellence over Features," focusing on a solid foundation before adding complexity.
## 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)
- [Phase 1: Core Architecture & Environment](#phase-1-core-architecture--environment)
- [Phase 2: Lexical Analysis & Tokenization](#phase-2-lexical-analysis--tokenization)
- [Phase 3: The AST & Parser](#phase-3-the-ast--parser)
- [Phase 4: The Interpreter (Rendering Engine)](#phase-4-the-interpreter-rendering-engine)
- [Phase 5: Inheritance & Reusability](#phase-5-inheritance--reusability)
- [Phase 6: Extensibility & Performance](#phase-6-extensibility--performance)
- [Phase 7: Final Polish & Release](#phase-7-final-polish--release)
## Foundation
- [x] Project initialization (Composer, PHPUnit, Directory structure)
- [x] Template Loader implementation
- [x] Basic Configuration system
## Phase 1: Core Architecture & Environment
*Focus: Establishing the contracts, error handling, and runtime configuration.*
- [x] Define and implement `Scape\Interfaces\FilterInterface` and `Scape\Interfaces\HostProviderInterface`.
- [x] Implement the Exception hierarchy in `Scape\Exceptions`.
- [x] Implement `Scape\Config` to handle environment variables (`SCAPE_*_DIR`) and programmatic overrides.
- [x] Create the `Scape\Engine` boilerplate with the `render()` method signature.
## Core Rendering
- [x] Lexer for Eyrie syntax
- [x] Parser for expressions and output tags
- [x] Auto-escaping implementation
- [x] Basic variable and expression output (`<< >>`)
## Phase 2: Lexical Analysis & Tokenization
*Focus: Turning template strings into a stream of tokens that the parser can understand.*
- [x] Implement `Scape\Parser\Lexer` to identify interpolation `{{ }}`, raw `{{{ }}}`, logic `{( )}`, and block `{[ ]}` tags.
- [x] Support white-space independence within tags.
- [x] Implement the white-space control rule (logic tags consuming one trailing newline).
- [x] Comprehensive unit tests for all tag variations.
## Control Structures
- [x] Function calls and control blocks (`<( )>`)
- [x] If/Elseif/Else logic
- [x] Foreach loop implementation
- [x] Range loop implementation
## Phase 3: The AST & Parser
*Focus: Building the Abstract Syntax Tree (AST) representing the template's structure.*
- [x] Implement `Scape\Parser\Parser` to convert tokens into an AST.
- [x] Define AST Nodes (Text, Variable, Loop, Block, Include, Filter).
- [x] Implement the `foreach` grammar (with optional keys).
- [x] Implement data access logic (dot-notation for objects, brackets for arrays).
## Template Inheritance
- [x] Extends mechanism (`[[ extends ]]`)
- [x] Block definition and overrides (`[[ block ]]`)
- [x] Super call implementation (`[[ super ]]`)
## Phase 4: The Interpreter (Rendering Engine)
*Focus: Turning the AST and data into the final HTML output.*
- [x] Implement the AST Interpreter to walk the tree and resolve variables.
- [x] Implement standard HTML escaping (`ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5`).
- [x] Implement Loop logic with `index`, `pos`, and positional rendering (`first`, `inner`, `last`).
- [x] Implement `debug` vs `production` variable access modes.
## Components and Partials
- [x] Partial inclusion (`[[ include ]]`)
- [x] Component rendering (`<@ />`)
- [x] Component props handling
## Phase 5: Inheritance & Reusability
*Focus: Blocks, Layouts, and Partials.*
- [x] Implement `{[ extends ]}` and the Block override system (including `{[ parent ]}`).
- [x] Implement `{[ include ]}` with data scoping rules (`with context`, `with data_source`, inline arrays).
- [x] Implement the recursion limit check (default 20).
- [x] Implement the 404 fallback mechanism.
## Advanced Features
- [x] Filters implementation (`|`)
- [x] Custom Helpers support
- [x] Custom Tags support
## Phase 6: Extensibility & Performance
*Focus: Filters, Host IoC, and Caching.*
- [x] Implement the Filter pipeline (piping and arguments).
- [x] Implement `uses` and `load_filter` mechanisms.
- [x] Implement the `host` namespace delegation.
- [x] Implement AST Caching (local storage in `.scape/cache`) with `mtime` invalidation for dev mode.
## 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
## Phase 7: Final Polish & Release
- [x] Final project-wide code style audit.
- [x] Ensure 100% test coverage for core rendering logic.
- [x] Draft the final README (Getting Started and examples).

193
README.md
View file

@ -1,180 +1,93 @@
# Eyrie Templates
# Scape Templates
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.
A lightweight, standalone PHP template engine designed for simplicity, security, and performance. Scape focuses on being an **Output Engine first**, marrying pre-processed data with design while enforcing a "logic-light" philosophy.
## 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.
- **Dot Notation & Bracket Access**: Effortlessly access nested objects and arrays.
- **Inheritance & Blocks**: Define base layouts and override sections in child templates.
- **Partials & Includes**: Reuse template snippets with controlled data scoping.
- **Filter Pipeline**: Transform data using built-in or custom filters (e.g., `{{ var | lower | ucfirst }}`).
- Built-in filters: `lower`, `upper`, `ucfirst`, `currency`, `float`, `date`, `truncate`, `default`, `json`, `url_encode`, `join`, `first`, `last`, `word_count`, `keys`.
- Filters can be used in variable interpolations, `foreach` loops, and `include` tags.
- **Secure by Default**: Automatic contextual HTML escaping for all variables.
- **AST Caching**: High performance via Abstract Syntax Tree caching with automatic dev-mode invalidation.
- **Host Integration (IoC)**: Easy integration with frameworks through the reserved `host` namespace.
- **Logic-Light**: Encourages separation of concerns by supporting only necessary logic like `foreach`.
## Installation
Install Eyrie via Composer:
```bash
composer require getphred/eyrie
composer require getphred/scape
```
## Quick Start
```php
use Eyrie\Engine;
use Eyrie\Loader\FileLoader;
use Scape\Engine;
// 1. Configure the loader
$loader = new FileLoader(['./templates']);
// 2. Initialize the engine
$engine = new Engine($loader, [
'cache' => './cache',
'debug' => true,
$engine = new Engine([
'templates_dir' => __DIR__ . '/templates',
'mode' => 'debug' // or 'production'
]);
// 3. Render a template
echo $engine->render('welcome', ['name' => 'Phred']);
echo $engine->render('index', [
'title' => 'Welcome to Scape',
'user' => ['name' => 'Funky']
]);
```
## Template Syntax
### Basic Syntax
### Output
Use `<< >>` to output variables or expressions. All output is automatically escaped.
#### Interpolation (Escaped)
`{{ user.name }}`
```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 )>
```
#### Raw Interpolation
`{{{ raw_html }}}`
#### Loops
```html
<ul>
<( foreach item in items )>
<li><< item >></li>
<( endforeach )>
</ul>
{( foreach item in items )}
<li>{{ item }}</li>
{( endforeach )}
```
#### Range Loops
The `loop` tag provides a convenient way to iterate over a range. It also provides a `loop` variable with metadata.
#### Filtering
`{{ price | currency('USD') }}`
#### Advanced Expressions
`{( foreach key in user_data | keys )}`
`{[ include 'partial' with data | first ]}`
#### Inheritance
`layout.scape.php`:
```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>
<title>{[ block 'title' ]}Default Title{[ endblock ]}</title>
<body>{[ block 'content' ]}{[ endblock ]}</body>
</html>
```
#### Page (`home.eyrie.php`)
`page.scape.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" ]]
{[ extends 'layout' ]}
{[ block 'title' ]}My Page{[ endblock ]}
{[ block 'content' ]}
<h1>Hello World</h1>
{[ endblock ]}
```
## Configuration
### Loader
The `FileLoader` accepts an array of paths and an optional file extension (default is `.eyrie.php`).
Scape uses environment variables or programmatic configuration:
```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');
```
- `SCAPE_TEMPLATES_DIR`: Default `./templates`
- `SCAPE_LAYOUTS_DIR`: Default `./templates/layouts`
- `SCAPE_PARTIALS_DIR`: Default `./templates/partials`
- `SCAPE_FILTERS_DIR`: Default `./filters`
- `SCAPE_CACHE_DIR`: Default `./.scape/cache`
- `SCAPE_MODE`: `production` (default) or `debug`
## License

View file

@ -1,49 +0,0 @@
# Security Policy (Draft)
## Supported versions
- Until first stable release, only the latest minor/patch is supported.
- Post1.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
- Autoescape by default; minimize usage of `safe` and review all instances.
- No dynamic eval; no templatedriven 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 leastprivilege account; restrict template directories to readonly for the runtime.
- Disable display of errors in production; enable structured logging.

393
SPECS.md
View file

@ -1,264 +1,161 @@
# Eyrie Templates — Product Specification (Draft)
# Scape Templates — Product Specification
This specification captures the initial scope for Eyrie Templates, the templating engine used by the Phred Framework.
## Project Name
Scape Templates
## 1. Goals
## Project Description
A lightweight, standalone PHP template engine designed for simplicity, security, and performance. Scape is built as an **Output Engine first**, focusing on marrying pre-processed data with design rather than performing additional business logic or data manipulation.
- Fast, safe, ergonomic serverside templating for PHP apps in the Phred ecosystem.
- Separation of concerns: business logic in PHP, presentation logic in templates.
- Security by default via autoescaping with contextual modes.
- Predictable, minimal syntax optimized for HTML.
- DRY composition with inheritance and partials.
## Project Purpose
To provide a modern, framework-agnostic alternative for template rendering that maintains a strict separation of concerns. Scape enforces a "logic-light" philosophy to ensure templates remain readable and focused purely on presentation.
## 2. NonGoals
- 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 toplevel 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, taglike 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 autoescaping.
- 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 } />` selfclosing. 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 (readonly): `item`
- Loop (range/repeat):
- `<( loop from 0 to 10 )>` ... `<( endloop )>`
- Loop vars (readonly): `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
- Tagbased 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`).
- Autoescaping: 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 Builtin 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. Autoescaping
- 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 dotnotated 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), PSR16 (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 readonly view of the current render context
* @return mixed A string (escaped later) or a SafeHtmllike value
*/
public function render(array $props, array $context = []): mixed;
}
final class CompiledTemplate {
public function render(array $context = []): string {}
}
## Project Installation Instructions
Scape can be installed via Composer:
```bash
composer require getphred/scape
```
Notes:
- `strict_variables` option throws on missing variables.
- Helpers/filters return values still subject to escaping unless wrapped in a `SafeHtml`like type.
## Project Features
## 10. Errors and diagnostics
### 1. File Handling & Configuration
- **Template Extensions**:
- All templates, layouts, and partials must use the `.scape.php` extension.
- **Filter Extensions**:
- Custom filters must use the standard `.php` extension.
- All custom filters must implement the `Scape\Interfaces\FilterInterface` to ensure they provide the necessary transformation methods.
- **Directory Configuration**: Template locations are managed via environment variables (with the following defaults when not provided):
- `SCAPE_TEMPLATES_DIR`: Main directory for application templates.
- Default: `./templates`
- `SCAPE_LAYOUTS_DIR`: Directory for base layouts and parent templates.
- Default: `./templates/layouts`
- `SCAPE_PARTIALS_DIR`: Directory for reusable snippets/partials.
- Default: `./templates/partials`
- `SCAPE_FILTERS_DIR`: Directory for user-defined filters.
- Default: `./filters`
- `SCAPE_CACHE_DIR`: Directory for cached AST files.
- Default: `./.scape/cache`
- **Dot Notation Pathing**: All internal paths (extends, includes) use dot notation (e.g., `sidebar.login_form`) relative to their respective directories, omitting the file extension.
- 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.
### 2. Syntax & White-space
- All opening and closing tags are white-space independent (e.g., `{{var}}` is equivalent to `{{ var }}`).
- **Variable Interpolation**:
- `{{ var }}`: Automatically HTML-escaped output (uses `ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5`).
- `{{{ var }}}`: Raw, unescaped output.
- **Data Access**:
- Use dot-notation for accessing class properties/attributes (e.g., `user.name`).
- Use bracket notation for accessing array elements (e.g., `items['title']` or `users[0]`).
## 11. Performance targets (initial)
### 3. Logic & Control Flow
- **Syntax**: Uses `{( ... )}` for logic tags.
- **White-space Management**: Logic tags `{( ... )}` and block/inheritance tags `{[ ... ]}` automatically consume one trailing newline immediately following their closing `)}` or `]}` to prevent unintended vertical spacing in the rendered output.
- **"Logic-Light" Constraints**:
- No generic programming or complex expressions.
- No `if` statements or conditional branching (by design).
- No manual variable assignment within templates.
- **Loops**:
- Only bounded iteration is supported: `foreach`.
- **Grammar**:
- `{( foreach item in collection )} ... {( endforeach )}`
- `{( foreach key, item in collection )} ... {( endforeach )}`
- Every loop provides access to two local integer variables:
- `index`: The current iteration index, 0-indexed (0, 1, 2...).
- `pos`: The current human-readable position, 1-indexed (1, 2, 3...).
- **Positional Rendering**: Special tags are available within loops to handle presentation based on position:
- `{( first )} ... {( endfirst )}`: Renders only on the first iteration.
- `{( inner )} ... {( endinner )}`: Renders on all iterations except the first and last.
- `{( last )} ... {( endlast )}`: Renders only on the last iteration.
- 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.
### 4. Inheritance & Reusability
- **Syntax**: Uses `{[ ... ]}` for block and inheritance tags.
- **Layouts & Inheritance**:
- Templates can extend layouts from `SCAPE_LAYOUTS_DIR` using `{[ extends 'path' ]}`.
- The `extends` tag must be the very first thing in a template.
- **Blocks**:
- **Placeholder**: Layouts define placeholders using `{[ block 'name' ]} ... {[ endblock ]}`.
- **Override**: Child templates provide content for these placeholders by defining a block with the same name.
- **Parent Content**: Within an override block, the child can render the layout's default content using the `{[ parent ]}` tag.
- **Default Content**: If a child template does not provide a block, the content within the layout's `block` tags is rendered as a default.
- **Nested Blocks**: Blocks can be nested within other blocks.
- **Partials**:
- Reusable snippets from `SCAPE_PARTIALS_DIR` can be included using `{[ include 'path' ]}`.
- **Encapsulation**: Partials are siloed by default and do not inherit the parent template's variables.
- **Passing Data**:
- Data can be passed as an array: `{[ include 'path' with data_source ]}`.
- The `data_source` can be a local variable, a nested array element (`items['meta']`), a class attribute (`user.profile`), or an inline array declaration (e.g., `['user' => user, 'id' => 1]`).
- The array keys from the source are expanded into individual local variables within the partial.
- The full parent context can be passed explicitly: `{[ include 'path' with context ]}`.
- **Nesting**: Partials can include other partials. To prevent infinite recursion, the engine enforces a maximum nesting depth (default: 20).
## 12. Security requirements (summary)
### 5. Extensibility
- **Filters**:
- Used with variable interpolation to transform data.
- **Piping**: Supports chaining multiple filters: `{{ var | lower | ucfirst }}`.
- **Arguments**: Supports passing simple arguments (strings, numbers, or other variables): `{{ price | currency('USD') }}`.
- **The `FilterInterface`**: All filters must implement `Scape\Interfaces\FilterInterface`:
```php
public function transform(mixed $value, array $args = []): mixed;
```
- **Loading**: Filters must be pre-loaded at the top of the template.
- **Internal Libraries**: Engine-provided filters are loaded using the `uses` keyword:
- Syntax: `{( uses namespace:library )}` (e.g., `{( uses filters:string )}`).
- **filters:string** library includes:
- `lower`: Converts to lowercase.
- `upper`: Converts to uppercase.
- `ucfirst`: Capitalizes the first character.
- `currency(code)`: Formats numeric value as currency (default: 'USD').
- `float(precision)`: Formats numeric value as a float with fixed precision (default: 2).
- `date(format)`: Formats a timestamp or date string (default: 'Y-m-d H:i:s').
- `truncate(length, suffix)`: Truncates string to length (default length: 100, suffix: '...').
- `default(fallback)`: Returns fallback if value is empty.
- `json`: Returns JSON encoded string.
- `url_encode`: Returns URL encoded string.
- `join(glue)`: Joins array elements with glue (default glue: '').
- `first`: Returns first element of a collection.
- `last`: Returns last element of a collection.
- `word_count`: Returns word count of a string.
- `keys`: Returns keys of an associative array.
- **Custom Filters**: User-defined filters are loaded from `SCAPE_FILTERS_DIR` using `load_filter`.
- Syntax: `{( load_filter('path') )}` where path is dot-notated.
- Autoescape by default; `safe` is explicit optin.
- 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.
### 6. Security & Error Handling
- **Contextual Escaping**: Standard `{{ }}` interpolation ensures XSS protection.
- **Missing Assets**: If a layout or partial is missing, the engine looks for a user-provided `404.scape.php` in `SCAPE_TEMPLATES_DIR`. If not found, it renders a built-in "Template 404" placeholder.
- **Exceptions**: The engine throws specific exceptions within the `Scape\Exceptions` namespace. **All exceptions must include helpful, context-rich error messages** (e.g., template name, line number, and specific failure reason) to assist in debugging:
- `TemplateNotFoundException`: Main template, layout, or partial missing.
- `SyntaxException`: Malformed tags or disallowed logic (e.g. `if`).
- `FilterNotFoundException`: Target of `uses` or `load_filter` missing.
- `PropertyNotFoundException`: (Debug only) Accessing undefined key/property.
- `RecursionLimitException`: Partials exceed nesting limit (default 20).
- **Variable Access**:
- **Debug Mode**: Accessing a non-existent object property or array key throws a `PropertyNotFoundException`.
- **Production Mode**: Accessing a non-existent property or key fails silently and renders an empty string.
## 13. Logging and telemetry
### 7. Performance
- **AST Caching**: The engine caches the parsed Abstract Syntax Tree (AST) of templates to speed up subsequent renders.
- **Cache Location**: Cache files are stored locally in the project directory under `.scape/cache`.
- **No Compiling**: Scape does not compile templates into raw PHP files; it interprets the cached AST directly.
- **Cache Modes**:
- **Development**: Engine checks file modification times (`mtime`) to invalidate the cache when a template changes.
- **Production**: Engine skips `mtime` checks and serves the cached AST directly for maximum performance.
- Hook for timings, cache metrics, loader misses.
- PSR3 logger support.
### 8. Host Integration (IoC) & i18n
- **The `host` Namespace**: Scape provides a reserved `host` namespace that can be used with the `uses` keyword or as a filter/function prefix.
- **Provider Registration**: Host frameworks (e.g., Phred) can register custom providers to handle calls in this namespace.
- **Localization (i18n)**:
- **Philosophy**: Scape is "Simple for Blogs, Powerful for Enterprise." Localization is an **opt-in** feature.
- **Responsibility**: Translation logic and message catalogs reside in the Application/Framework. Scape provides the **access layer**.
- **Usage**: Templates can use `host.translate('key')` or the `|t` filter alias to fetch localized strings on-demand.
- **Portability**: If no host provider is registered, i18n calls return the input key/value unchanged, ensuring templates remain portable across environments.
- **Use Cases**: Used for framework-level features like feature flags (Flagpole), routing, or translations without creating a hard dependency within the engine.
## 14. Compatibility
### 9. Runtime API
- **The `Scape\Engine` Class**: The primary entry point for the library.
- **Configuration Precedence**: Programmatic Config > Environment Variables > Defaults.
- **Rendering**:
- Method: `public function render(string $template, array $data = []): string`
- The `$template` argument uses dot notation.
- **Mode Control**: The engine operating mode (`debug` vs `production`) can be set via the `SCAPE_MODE` environment variable or explicitly during instantiation.
- Minimum PHP: propose 8.1+ (finalize).
- UTF8 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.
- Builtin 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.
## Project Dependencies
- **PHP**: ^8.2
- **PHPUnit**: ^10.0 (Development)

View file

@ -1,100 +0,0 @@
# Eyrie Templates — Threat Model (Draft)
Purpose: identify assets, threats, and controls relevant to a serverside 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 requestderived 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 autoescaping 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 perrender 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 developerauthored and trusted; usergenerated 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)
- Autoescaping 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 strictvariables default (on/off)
- Finalize default limits (include depth, loop max, template size)

View file

@ -1,28 +1,31 @@
{
"name": "getphred/eyrie",
"description": "The templating engine for the Phred Framework",
"name": "getphred/scape",
"description": "A Lightweight PHP Template Engine",
"license": "MIT",
"type": "library",
"authors": [
{
"name": "Junie",
"email": "junie@example.com"
"name": "Phred",
"email": "phred@getphred.com",
"homepage": "https://getphred.com",
"role": "Owner"
}
],
"require": {
"php": "^8.2"
"php": "^8.2",
"ext-intl": "*"
},
"require-dev": {
"phpunit/phpunit": "^10.0"
},
"autoload": {
"psr-4": {
"Eyrie\\": "src/"
"Scape\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Eyrie\\Tests\\": "tests/"
"Scape\\Tests\\": "tests/"
}
},
"config": {

31
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "a04d8f4360b838804edefd62253383ac",
"content-hash": "4f8a56ae9cae9b8b16e124fc4ef58da9",
"packages": [],
"packages-dev": [
{
@ -566,16 +566,16 @@
},
{
"name": "phpunit/phpunit",
"version": "10.5.60",
"version": "10.5.63",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git",
"reference": "f2e26f52f80ef77832e359205f216eeac00e320c"
"reference": "33198268dad71e926626b618f3ec3966661e4d90"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/f2e26f52f80ef77832e359205f216eeac00e320c",
"reference": "f2e26f52f80ef77832e359205f216eeac00e320c",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/33198268dad71e926626b618f3ec3966661e4d90",
"reference": "33198268dad71e926626b618f3ec3966661e4d90",
"shasum": ""
},
"require": {
@ -596,7 +596,7 @@
"phpunit/php-timer": "^6.0.0",
"sebastian/cli-parser": "^2.0.1",
"sebastian/code-unit": "^2.0.0",
"sebastian/comparator": "^5.0.4",
"sebastian/comparator": "^5.0.5",
"sebastian/diff": "^5.1.1",
"sebastian/environment": "^6.1.0",
"sebastian/exporter": "^5.1.4",
@ -647,7 +647,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
"source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.60"
"source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.63"
},
"funding": [
{
@ -671,7 +671,7 @@
"type": "tidelift"
}
],
"time": "2025-12-06T07:50:42+00:00"
"time": "2026-01-27T05:48:37+00:00"
},
{
"name": "sebastian/cli-parser",
@ -843,16 +843,16 @@
},
{
"name": "sebastian/comparator",
"version": "5.0.4",
"version": "5.0.5",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/comparator.git",
"reference": "e8e53097718d2b53cfb2aa859b06a41abf58c62e"
"reference": "55dfef806eb7dfeb6e7a6935601fef866f8ca48d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/e8e53097718d2b53cfb2aa859b06a41abf58c62e",
"reference": "e8e53097718d2b53cfb2aa859b06a41abf58c62e",
"url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/55dfef806eb7dfeb6e7a6935601fef866f8ca48d",
"reference": "55dfef806eb7dfeb6e7a6935601fef866f8ca48d",
"shasum": ""
},
"require": {
@ -908,7 +908,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/comparator/issues",
"security": "https://github.com/sebastianbergmann/comparator/security/policy",
"source": "https://github.com/sebastianbergmann/comparator/tree/5.0.4"
"source": "https://github.com/sebastianbergmann/comparator/tree/5.0.5"
},
"funding": [
{
@ -928,7 +928,7 @@
"type": "tidelift"
}
],
"time": "2025-09-07T05:25:07+00:00"
"time": "2026-01-24T09:25:16+00:00"
},
{
"name": "sebastian/complexity",
@ -1683,7 +1683,8 @@
"prefer-stable": false,
"prefer-lowest": false,
"platform": {
"php": "^8.2"
"php": "^8.2",
"ext-intl": "*"
},
"platform-dev": {},
"plugin-api-version": "2.6.0"

View file

@ -1,276 +0,0 @@
<?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;
}
}

72
src/Config.php Normal file
View file

@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace Scape;
/**
* Class Config
*
* Handles Scape configuration, merging defaults, environment variables, and programmatic overrides.
*
* @package Scape
*/
class Config
{
private string $mode;
private string $templatesDir;
private string $layoutsDir;
private string $partialsDir;
private string $filtersDir;
private string $cacheDir;
/**
* Config constructor.
*
* @param array $overrides Programmatic configuration overrides.
*/
public function __construct(array $overrides = [])
{
$this->mode = $overrides['mode'] ?? (getenv('SCAPE_MODE') ?: 'production');
$this->templatesDir = $overrides['templates_dir'] ?? (getenv('SCAPE_TEMPLATES_DIR') ?: './templates');
$this->layoutsDir = $overrides['layouts_dir'] ?? (getenv('SCAPE_LAYOUTS_DIR') ?: './templates/layouts');
$this->partialsDir = $overrides['partials_dir'] ?? (getenv('SCAPE_PARTIALS_DIR') ?: './templates/partials');
$this->filtersDir = $overrides['filters_dir'] ?? (getenv('SCAPE_FILTERS_DIR') ?: './filters');
$this->cacheDir = $overrides['cache_dir'] ?? (getenv('SCAPE_CACHE_DIR') ?: './.scape/cache');
}
public function getMode(): string
{
return $this->mode;
}
public function getTemplatesDir(): string
{
return $this->templatesDir;
}
public function getLayoutsDir(): string
{
return $this->layoutsDir;
}
public function getPartialsDir(): string
{
return $this->partialsDir;
}
public function getFiltersDir(): string
{
return $this->filtersDir;
}
public function getCacheDir(): string
{
return $this->cacheDir;
}
public function isDebug(): bool
{
return $this->mode === 'debug';
}
}

View file

@ -2,275 +2,297 @@
declare(strict_types=1);
namespace Eyrie;
namespace Scape;
use Eyrie\Loader\LoaderInterface;
use Eyrie\Parser\Lexer;
use Eyrie\Parser\Parser;
use Eyrie\Compiler\Compiler;
use Scape\Interpreter\Interpreter;
use Scape\Parser\Lexer;
use Scape\Parser\Parser;
use Scape\Exceptions\TemplateNotFoundException;
use Scape\Exceptions\FilterNotFoundException;
use Scape\Interfaces\FilterInterface;
use Scape\Interfaces\HostProviderInterface;
/**
* Class Engine
*
* The primary entry point for the Scape template engine.
*
* @package Scape
*/
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 Config $config;
private Interpreter $interpreter;
private array $blocks = [];
private array $capturedBlocks = [];
public array $renderedBlocks = [];
private ?string $currentLayout = null;
private string $parentBlock = '';
/** @var FilterInterface[] */
private array $loadedFilters = [];
public function __construct(LoaderInterface $loader, array $options = [])
/** @var HostProviderInterface|null */
private ?HostProviderInterface $hostProvider = null;
/**
* Engine constructor.
*
* @param array|Config|null $config Optional configuration.
*/
public function __construct(array|Config|null $config = null)
{
$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);
if ($config instanceof Config) {
$this->config = $config;
} else {
$this->config = new Config($config ?? []);
}
$this->interpreter = new Interpreter($this->config, $this);
}
public function render(string $name, array $context = []): string
/**
* Renders a template with the provided data.
*
* @param string $template The dot-notated path to the template.
* @param array $data The data context for rendering.
*
* @return string The rendered output.
*
* @throws TemplateNotFoundException If the template cannot be found.
*/
public function render(string $template, array $data = []): 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
$ast = $this->loadAst($template);
} catch (TemplateNotFoundException $e) {
return $this->handle404($template, $e);
}
// Check for extends
if (!empty($ast) && $ast[0] instanceof \Scape\Parser\Node\ExtendsNode) {
$extendsNode = array_shift($ast);
$layoutPath = $extendsNode->path;
try {
$layoutAst = $this->loadAst($layoutPath, true);
} catch (TemplateNotFoundException $e) {
return $this->handle404($layoutPath, $e);
}
// 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();
return $this->interpreter->interpretWithLayout($layoutAst, $ast, $data);
}
return $this->interpreter->interpret($ast, $data);
}
/**
* Handles template not found errors.
*
* @param string $template
* @param TemplateNotFoundException $e
* @return string
* @throws TemplateNotFoundException
*/
private function handle404(string $template, TemplateNotFoundException $e): string
{
$fallbackPath = rtrim($this->config->getTemplatesDir(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . '404.scape.php';
if (file_exists($fallbackPath)) {
$source = file_get_contents($fallbackPath);
$lexer = new Lexer($source);
$tokens = $lexer->tokenize();
$parser = new Parser($tokens);
$ast = $parser->parse();
return $this->interpreter->interpret($ast, ['missing_template' => $template]);
}
if ($this->config->isDebug()) {
throw $e;
}
$output = ob_get_clean();
if (is_array($result) && isset($result['layout'])) {
$this->currentLayout = $result['layout'];
}
return $output;
return "<!-- Scape: Template '{$template}' not found -->";
}
public function getGlobals(): array
/**
* Loads and parses a template into an AST.
*
* @param string $template
* @param bool $isLayout
* @return array
* @throws TemplateNotFoundException
*/
private function loadAst(string $template, bool $isLayout = false): array
{
return $this->globals;
}
$filePath = $isLayout ? $this->resolveLayoutPath($template) : $this->resolveTemplatePath($template);
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 (!file_exists($filePath)) {
throw new TemplateNotFoundException("Template '{$template}' not found at '{$filePath}'.");
}
if (is_object($object)) {
if (isset($object->{$property})) {
return $object->{$property};
$cacheDir = $this->config->getCacheDir();
$cacheKey = md5($filePath . ($isLayout ? ':layout' : ''));
$cachePath = rtrim($cacheDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $cacheKey . '.ast';
if (file_exists($cachePath)) {
$useCache = true;
if (!$this->config->isDebug()) {
// In production, always use cache if it exists
$useCache = true;
} else {
// In debug mode, check mtime
if (filemtime($filePath) > filemtime($cachePath)) {
$useCache = false;
}
}
$method = 'get' . ucfirst($property);
if (method_exists($object, $method)) {
return $object->$method();
if ($useCache) {
$cachedAst = unserialize(file_get_contents($cachePath));
if (is_array($cachedAst)) {
return $cachedAst;
}
}
}
return null;
$source = file_get_contents($filePath);
$lexer = new Lexer($source);
$tokens = $lexer->tokenize();
$parser = new Parser($tokens, $isLayout);
$ast = $parser->parse();
// Save to cache
if (!is_dir($cacheDir)) {
mkdir($cacheDir, 0777, true);
}
file_put_contents($cachePath, serialize($ast));
return $ast;
}
public function renderBlock(string $name, callable $fallback, array $context): string
/**
* Resolves a dot-notated template path to a file path.
*
* @param string $template
* @return string
*/
private function resolveTemplatePath(string $template): string
{
$this->renderedBlocks[$name] = true;
ob_start();
$fallback();
$fallbackContent = ob_get_clean();
$path = str_replace('.', DIRECTORY_SEPARATOR, $template);
return rtrim($this->config->getTemplatesDir(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $path . '.scape.php';
}
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;
/**
* Resolves a dot-notated layout path to a file path.
*
* @param string $layout
* @return string
*/
private function resolveLayoutPath(string $layout): string
{
$path = str_replace('.', DIRECTORY_SEPARATOR, $layout);
return rtrim($this->config->getLayoutsDir(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $path . '.scape.php';
}
/**
* Resolves a dot-notated partial path to a file path.
*
* @param string $partial
* @return string
*/
public function resolvePartialPath(string $partial): string
{
$path = str_replace('.', DIRECTORY_SEPARATOR, $partial);
return rtrim($this->config->getPartialsDir(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $path . '.scape.php';
}
/**
* Loads a filter by its alias.
*
* @param string $name
* @param FilterInterface $filter
*/
public function registerFilter(string $name, FilterInterface $filter): void
{
$this->loadedFilters[$name] = $filter;
}
/**
* Gets a loaded filter.
*
* @param string $name
* @return FilterInterface
* @throws FilterNotFoundException
*/
public function getFilter(string $name): FilterInterface
{
if (!isset($this->loadedFilters[$name])) {
throw new FilterNotFoundException("Filter '{$name}' is not loaded.");
}
return $fallbackContent;
return $this->loadedFilters[$name];
}
public function registerBlock(string $name, array $blockCtx, callable $callback): void
/**
* Registers a host provider.
*
* @param HostProviderInterface $provider
*/
public function registerHostProvider(HostProviderInterface $provider): void
{
$this->capturedBlocks[$name] = function(array $context) use ($callback, $blockCtx) {
$vars = array_merge($context, $blockCtx);
$callback($vars);
};
$this->hostProvider = $provider;
}
private function evaluateBlock(string $php, array $context): string
/**
* Gets the host provider.
*
* @return HostProviderInterface|null
*/
public function getHostProvider(): ?HostProviderInterface
{
return "";
return $this->hostProvider;
}
public function renderComponent(string $name, array $props): string
/**
* Internal method for Interpreter to load partial ASTs.
*
* @param string $partial
* @return array
* @throws TemplateNotFoundException
*/
public function loadPartialAst(string $partial): array
{
// For now, components are just templates with their own scope
return $this->doRender($name, $props);
}
$filePath = $this->resolvePartialPath($partial);
public function applyFilter(string $name, mixed $value, ...$args): mixed
{
if (isset($this->filters[$name])) {
return $this->filters[$name]($value, ...$args);
if (!file_exists($filePath)) {
throw new TemplateNotFoundException("Partial '{$partial}' not found at '{$filePath}'.");
}
// Built-in filters
switch ($name) {
case 'upper':
return strtoupper((string)$value);
case 'lower':
return strtolower((string)$value);
case 'raw':
return new SafeString((string)$value);
$cacheDir = $this->config->getCacheDir();
$cacheKey = md5($filePath . ':partial');
$cachePath = rtrim($cacheDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $cacheKey . '.ast';
if (file_exists($cachePath)) {
$useCache = true;
if ($this->config->isDebug()) {
if (filemtime($filePath) > filemtime($cachePath)) {
$useCache = false;
}
}
if ($useCache) {
$cachedAst = unserialize(file_get_contents($cachePath));
if (is_array($cachedAst)) {
return $cachedAst;
}
}
}
return $value;
}
$source = file_get_contents($filePath);
$lexer = new Lexer($source);
$tokens = $lexer->tokenize();
$parser = new Parser($tokens, false);
$ast = $parser->parse();
public function callHelper(string $name, ...$args): mixed
{
if (!isset($this->helpers[$name])) {
throw new \RuntimeException(sprintf('Helper "%s" not found.', $name));
// Save to cache
if (!is_dir($cacheDir)) {
mkdir($cacheDir, 0777, true);
}
file_put_contents($cachePath, serialize($ast));
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;
return $ast;
}
}

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Scape\Exceptions;
/**
* Class FilterNotFoundException
*
* Thrown when a 'uses' or 'load_filter' target is missing.
*
* @package Scape\Exceptions
*/
class FilterNotFoundException extends ScapeException
{
}

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Scape\Exceptions;
/**
* Class PropertyNotFoundException
*
* Thrown in Debug mode when accessing undefined keys/properties.
*
* @package Scape\Exceptions
*/
class PropertyNotFoundException extends ScapeException
{
}

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Scape\Exceptions;
/**
* Class RecursionLimitException
*
* Thrown if partial nesting exceeds the limit (default 20).
*
* @package Scape\Exceptions
*/
class RecursionLimitException extends ScapeException
{
}

View file

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Scape\Exceptions;
use Exception;
/**
* Class ScapeException
*
* Base exception for all Scape-related errors.
*
* @package Scape\Exceptions
*/
class ScapeException extends Exception
{
}

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Scape\Exceptions;
/**
* Class SyntaxException
*
* Thrown when the parser encounters malformed tags or illegal logic.
*
* @package Scape\Exceptions
*/
class SyntaxException extends ScapeException
{
}

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Scape\Exceptions;
/**
* Class TemplateNotFoundException
*
* Thrown when a main template, layout, or partial cannot be resolved.
*
* @package Scape\Exceptions
*/
class TemplateNotFoundException extends ScapeException
{
}

View file

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Scape\Filters;
use Scape\Interfaces\FilterInterface;
use NumberFormatter;
/**
* Class CurrencyFilter
*
* Converts a numeric value into a formatted currency string.
*
* @package Scape\Filters
*/
class CurrencyFilter implements FilterInterface
{
/**
* @param mixed $value The numeric value to format.
* @param array $args [0 => string $currencyCode] (e.g., 'USD', 'EUR').
* @return mixed
*/
public function transform(mixed $value, array $args = []): mixed
{
$currencyCode = $args[0] ?? 'USD';
// Ensure value is numeric
if (!is_numeric($value)) {
return $value;
}
$formatter = new NumberFormatter('en_US', NumberFormatter::CURRENCY);
return $formatter->formatCurrency((float)$value, (string)$currencyCode);
}
}

View file

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Scape\Filters;
use Scape\Interfaces\FilterInterface;
use DateTime;
use Exception;
/**
* Class DateFilter
*
* Formats a timestamp or date string.
*
* @package Scape\Filters
*/
class DateFilter implements FilterInterface
{
/**
* @param mixed $value The date value (timestamp, string, or DateTime).
* @param array $args [0 => string $format] (e.g., 'Y-m-d').
* @return mixed
*/
public function transform(mixed $value, array $args = []): mixed
{
$format = $args[0] ?? 'Y-m-d H:i:s';
if ($value === null || $value === '') {
return $value;
}
try {
if (is_numeric($value)) {
$date = new DateTime();
$date->setTimestamp((int)$value);
} elseif (is_string($value)) {
$date = new DateTime($value);
} elseif ($value instanceof DateTime) {
$date = $value;
} else {
return $value;
}
return $date->format($format);
} catch (Exception) {
return $value;
}
}
}

View file

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Scape\Filters;
use Scape\Interfaces\FilterInterface;
/**
* Class DefaultFilter
*
* Provides a fallback value if the input is empty.
*
* @package Scape\Filters
*/
class DefaultFilter implements FilterInterface
{
/**
* @param mixed $value The value to check.
* @param array $args [0 => mixed $default]
* @return mixed
*/
public function transform(mixed $value, array $args = []): mixed
{
$default = $args[0] ?? '';
if ($value === null || $value === '' || (is_array($value) && empty($value))) {
return $default;
}
return $value;
}
}

View file

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Scape\Filters;
use Scape\Interfaces\FilterInterface;
/**
* Class FirstFilter
*
* Returns the first element of a collection.
*
* @package Scape\Filters
*/
class FirstFilter implements FilterInterface
{
/**
* @param mixed $value The collection.
* @param array $args Not used.
* @return mixed
*/
public function transform(mixed $value, array $args = []): mixed
{
if (is_iterable($value)) {
foreach ($value as $item) {
return $item;
}
}
return null;
}
}

View file

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Scape\Filters;
use Scape\Interfaces\FilterInterface;
/**
* Class FloatFilter
*
* Formats a numeric value as a string with a fixed number of decimal places.
*
* @package Scape\Filters
*/
class FloatFilter implements FilterInterface
{
/**
* @param mixed $value The numeric value to format.
* @param array $args [0 => int $precision]
* @return mixed
*/
public function transform(mixed $value, array $args = []): mixed
{
$precision = isset($args[0]) ? (int)$args[0] : 2;
if (!is_numeric($value)) {
return $value;
}
return number_format((float)$value, $precision, '.', ',');
}
}

View file

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Scape\Filters;
use Scape\Interfaces\FilterInterface;
/**
* Class JoinFilter
*
* Joins array elements with a glue string.
*
* @package Scape\Filters
*/
class JoinFilter implements FilterInterface
{
/**
* @param mixed $value The array to join.
* @param array $args [0 => string $glue]
* @return mixed
*/
public function transform(mixed $value, array $args = []): mixed
{
$glue = $args[0] ?? '';
if (is_iterable($value)) {
$array = is_array($value) ? $value : iterator_to_array($value);
return implode((string)$glue, $array);
}
return $value;
}
}

View file

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Scape\Filters;
use Scape\Interfaces\FilterInterface;
/**
* Class JsonFilter
*
* Encodes a value into a JSON string.
*
* @package Scape\Filters
*/
class JsonFilter implements FilterInterface
{
/**
* @param mixed $value The value to encode.
* @param array $args Not used.
* @return mixed
*/
public function transform(mixed $value, array $args = []): mixed
{
return json_encode($value, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP | JSON_UNESCAPED_UNICODE);
}
}

View file

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Scape\Filters;
use Scape\Interfaces\FilterInterface;
/**
* Class KeysFilter
*
* Returns the keys of an associative array.
*
* @package Scape\Filters
*/
class KeysFilter implements FilterInterface
{
/**
* @param mixed $value The array/collection.
* @param array $args Not used.
* @return mixed
*/
public function transform(mixed $value, array $args = []): mixed
{
if (is_iterable($value)) {
$array = is_array($value) ? $value : iterator_to_array($value);
return array_keys($array);
}
return [];
}
}

View file

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Scape\Filters;
use Scape\Interfaces\FilterInterface;
/**
* Class LastFilter
*
* Returns the last element of a collection.
*
* @package Scape\Filters
*/
class LastFilter implements FilterInterface
{
/**
* @param mixed $value The collection.
* @param array $args Not used.
* @return mixed
*/
public function transform(mixed $value, array $args = []): mixed
{
if (is_iterable($value)) {
$array = is_array($value) ? $value : iterator_to_array($value);
if (empty($array)) {
return null;
}
return end($array);
}
return null;
}
}

View file

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Scape\Filters;
use Scape\Interfaces\FilterInterface;
class LowerFilter implements FilterInterface
{
public function transform(mixed $value, array $args = []): mixed
{
return strtolower((string)$value);
}
}

View file

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Scape\Filters;
use Scape\Interfaces\FilterInterface;
/**
* Class TruncateFilter
*
* Truncates a string to a specified length.
*
* @package Scape\Filters
*/
class TruncateFilter implements FilterInterface
{
/**
* @param mixed $value The string to truncate.
* @param array $args [0 => int $length, 1 => string $suffix]
* @return mixed
*/
public function transform(mixed $value, array $args = []): mixed
{
$length = (int)($args[0] ?? 100);
$suffix = $args[1] ?? '...';
$str = (string)$value;
if (mb_strlen($str) <= $length) {
return $str;
}
return mb_substr($str, 0, $length) . $suffix;
}
}

View file

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Scape\Filters;
use Scape\Interfaces\FilterInterface;
class UcfirstFilter implements FilterInterface
{
public function transform(mixed $value, array $args = []): mixed
{
return ucfirst((string)$value);
}
}

View file

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Scape\Filters;
use Scape\Interfaces\FilterInterface;
class UpperFilter implements FilterInterface
{
public function transform(mixed $value, array $args = []): mixed
{
return strtoupper((string)$value);
}
}

View file

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Scape\Filters;
use Scape\Interfaces\FilterInterface;
/**
* Class UrlEncodeFilter
*
* URL encodes a string.
*
* @package Scape\Filters
*/
class UrlEncodeFilter implements FilterInterface
{
/**
* @param mixed $value The string to encode.
* @param array $args Not used.
* @return mixed
*/
public function transform(mixed $value, array $args = []): mixed
{
return urlencode((string)$value);
}
}

View file

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Scape\Filters;
use Scape\Interfaces\FilterInterface;
/**
* Class WordCountFilter
*
* Counts words in a string.
*
* @package Scape\Filters
*/
class WordCountFilter implements FilterInterface
{
/**
* @param mixed $value The string to count words in.
* @param array $args Not used.
* @return mixed
*/
public function transform(mixed $value, array $args = []): mixed
{
return str_word_count((string)$value);
}
}

View file

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Scape\Interfaces;
/**
* Interface FilterInterface
*
* Defines the contract for all Scape filters.
*
* @package Scape\Interfaces
*/
interface FilterInterface
{
/**
* Transforms the given value using the provided arguments.
*
* @param mixed $value The value to be transformed.
* @param array $args An array of optional arguments for the filter.
*
* @return mixed The transformed value.
*/
public function transform(mixed $value, array $args = []): mixed;
}

View file

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Scape\Interfaces;
/**
* Interface HostProviderInterface
*
* Defines the contract for registering custom providers to handle calls in the 'host' namespace.
*
* @package Scape\Interfaces
*/
interface HostProviderInterface
{
/**
* Checks if the host provider can handle the given name.
*
* @param string $name The name of the property or method being queried.
*
* @return bool True if the provider can handle the call; otherwise false.
*/
public function has(string $name): bool;
/**
* Invokes the host provider for the given name.
*
* @param string $name The name of the property or method being called.
* @param array $args Optional arguments for the call.
*
* @return mixed The resolved value.
*/
public function call(string $name, array $args = []): mixed;
}

View file

@ -0,0 +1,395 @@
<?php
declare(strict_types=1);
namespace Scape\Interpreter;
use Scape\Engine;
use Scape\Config;
use Scape\Parser\Node\BlockNode;
use Scape\Parser\Node\ExtendsNode;
use Scape\Parser\Node\FilterLoadNode;
use Scape\Parser\Node\ForeachNode;
use Scape\Parser\Node\IncludeNode;
use Scape\Parser\Node\Node;
use Scape\Parser\Node\ParentNode;
use Scape\Parser\Node\TextNode;
use Scape\Parser\Node\VariableNode;
use Scape\Exceptions\RecursionLimitException;
/**
* Class Interpreter
*
* Walks the AST and renders the final output.
*
* @package Scape\Interpreter
*/
class Interpreter
{
private ValueResolver $resolver;
/** @var BlockNode[] */
private array $blocks = [];
/** @var Node[] */
private array $activeBlockStack = [];
private int $recursionDepth = 0;
private const MAX_RECURSION_DEPTH = 20;
/**
* @param Config $config
* @param Engine|null $engine The engine instance to load partials.
*/
public function __construct(
private readonly Config $config,
private readonly ?Engine $engine = null
) {
$this->resolver = new ValueResolver($config, $engine);
}
/**
* Interprets a layout with child blocks.
*
* @param Node[] $layoutAst
* @param Node[] $childAst
* @param array $context
* @return string
*/
public function interpretWithLayout(array $layoutAst, array $childAst, array $context): string
{
// First, collect all blocks from child
$this->blocks = [];
$this->collectBlocks($childAst);
return $this->interpret($layoutAst, $context);
}
/**
* Collects all BlockNodes from the given AST.
*
* @param Node[] $nodes
*/
private function collectBlocks(array $nodes): void
{
foreach ($nodes as $node) {
if ($node instanceof BlockNode) {
$this->blocks[$node->name] = $node;
// Also collect nested blocks
$this->collectBlocks($node->body);
} elseif ($node instanceof ForeachNode) {
$this->collectBlocks($node->body);
$this->collectBlocks($node->first);
$this->collectBlocks($node->inner);
$this->collectBlocks($node->last);
}
}
}
/**
* Interprets a list of nodes.
*
* @param Node[] $nodes The AST nodes.
* @param array $context The data context.
*
* @return string The rendered output.
*/
public function interpret(array $nodes, array $context): string
{
$output = '';
foreach ($nodes as $node) {
$output .= $this->interpretNode($node, $context);
}
return $output;
}
private function interpretNode(Node $node, array $context): string
{
return match (true) {
$node instanceof TextNode => $node->content,
$node instanceof VariableNode => $this->interpretVariable($node, $context),
$node instanceof ForeachNode => $this->interpretForeach($node, $context),
$node instanceof BlockNode => $this->interpretBlock($node, $context),
$node instanceof ParentNode => $this->interpretParent($node, $context),
$node instanceof IncludeNode => $this->interpretInclude($node, $context),
$node instanceof FilterLoadNode => $this->interpretFilterLoad($node, $context),
$node instanceof ExtendsNode => '',
default => '',
};
}
private function interpretInclude(IncludeNode $node, array $context): string
{
if ($this->engine === null) {
return '';
}
if ($this->recursionDepth >= self::MAX_RECURSION_DEPTH) {
throw new RecursionLimitException("Maximum recursion depth of " . self::MAX_RECURSION_DEPTH . " exceeded.");
}
$includeContext = [];
if ($node->context === 'context') {
$includeContext = $context;
} elseif ($node->context !== null) {
// Check if it's an inline array e.g. ['a' => 1]
if (str_starts_with($node->context, '[') && str_ends_with($node->context, ']')) {
$includeContext = $this->parseInlineArray($node->context, $context);
} else {
// If it contains a pipe, use the full expression
$expression = $node->context;
$val = $this->resolver->resolve($expression, $context);
if (is_array($val)) {
$includeContext = $val;
} elseif (is_object($val)) {
$includeContext = (array)$val;
}
}
}
try {
$partialAst = $this->engine->loadPartialAst($node->path);
} catch (\Scape\Exceptions\TemplateNotFoundException $e) {
if ($this->config->isDebug()) {
throw $e;
}
return "<!-- Scape: Partial '{$node->path}' not found -->";
}
// SAVE the current blocks state, as partials are siloed
$oldBlocks = $this->blocks;
$this->blocks = [];
// Save current interpreter state that might be affected by recursion
// (activeBlockStack is already managed by push/pop in interpretBlock)
$this->recursionDepth++;
$output = $this->interpret($partialAst, $includeContext);
$this->recursionDepth--;
// RESTORE blocks state
$this->blocks = $oldBlocks;
return $output;
}
private function parseInlineArray(string $expression, array $context): array
{
// expression is like ['user' => user, 'id' => 1]
$inner = trim(substr($expression, 1, -1));
if ($inner === '') {
return [];
}
// Improved splitting to handle spaces and potential commas in values (though KISS says simple)
$pairs = explode(',', $inner);
$result = [];
foreach ($pairs as $pair) {
if (str_contains($pair, '=>')) {
[$key, $value] = explode('=>', $pair, 2);
$key = trim($key, " \t\n\r\0\x0B'\"");
$value = trim($value);
// Handle string literals in values
if ((str_starts_with($value, "'") && str_ends_with($value, "'")) ||
(str_starts_with($value, '"') && str_ends_with($value, '"'))) {
$resolvedValue = substr($value, 1, -1);
} elseif (is_numeric($value)) {
$resolvedValue = $value + 0; // cast to int or float
} else {
$resolvedValue = $this->resolver->resolve($value, $context);
}
$result[$key] = $resolvedValue;
}
}
return $result;
}
private function interpretBlock(BlockNode $node, array $context): string
{
// If we have an override for this block
if (isset($this->blocks[$node->name])) {
$override = $this->blocks[$node->name];
// Push current layout block to stack for {[ parent ]}
$this->activeBlockStack[] = $node;
$output = $this->interpret($override->body, $context);
array_pop($this->activeBlockStack);
return $output;
}
// No override, render layout default content
return $this->interpret($node->body, $context);
}
private function interpretParent(ParentNode $node, array $context): string
{
if (empty($this->activeBlockStack)) {
return '';
}
// Render the body of the layout block we are currently overriding
$layoutBlock = end($this->activeBlockStack);
return $this->interpret($layoutBlock->body, $context);
}
private function interpretVariable(VariableNode $node, array $context): string
{
// If it's the special 'context' variable (used in partials)
if ($node->path === 'context') {
$value = $context;
} else {
$value = $this->resolver->resolve($node->path, $context);
}
if (!empty($node->filters) && $this->engine !== null) {
foreach ($node->filters as $filterInfo) {
$filterName = $filterInfo['name'];
$args = $filterInfo['args'];
// Resolve arguments (if they are variables)
$resolvedArgs = [];
foreach ($args as $arg) {
if ((str_starts_with($arg, "'") && str_ends_with($arg, "'")) ||
(str_starts_with($arg, '"') && str_ends_with($arg, '"'))) {
$resolvedArgs[] = substr($arg, 1, -1);
} elseif (is_numeric($arg)) {
$resolvedArgs[] = $arg + 0;
} else {
$resolvedArgs[] = $this->resolver->resolve($arg, $context);
}
}
$filter = $this->engine->getFilter($filterName);
$value = $filter->transform($value, $resolvedArgs);
}
}
if ($value === null) {
return '';
}
$stringValue = (string)$value;
if ($node->isRaw) {
return $stringValue;
}
return htmlspecialchars($stringValue, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8');
}
private function interpretFilterLoad(FilterLoadNode $node, array $context): string
{
if ($this->engine === null) {
return '';
}
if ($node->isInternal) {
$this->loadInternalFilter($node->path);
} else {
$this->loadCustomFilter($node->path);
}
return '';
}
private function loadInternalFilter(string $path): void
{
// Example: filters:string
[$namespace, $library] = explode(':', $path);
if ($namespace === 'filters' && $library === 'string') {
$this->engine->registerFilter('lower', new \Scape\Filters\LowerFilter());
$this->engine->registerFilter('upper', new \Scape\Filters\UpperFilter());
$this->engine->registerFilter('ucfirst', new \Scape\Filters\UcfirstFilter());
$this->engine->registerFilter('currency', new \Scape\Filters\CurrencyFilter());
$this->engine->registerFilter('float', new \Scape\Filters\FloatFilter());
$this->engine->registerFilter('date', new \Scape\Filters\DateFilter());
$this->engine->registerFilter('truncate', new \Scape\Filters\TruncateFilter());
$this->engine->registerFilter('default', new \Scape\Filters\DefaultFilter());
$this->engine->registerFilter('json', new \Scape\Filters\JsonFilter());
$this->engine->registerFilter('url_encode', new \Scape\Filters\UrlEncodeFilter());
$this->engine->registerFilter('join', new \Scape\Filters\JoinFilter());
$this->engine->registerFilter('first', new \Scape\Filters\FirstFilter());
$this->engine->registerFilter('last', new \Scape\Filters\LastFilter());
$this->engine->registerFilter('word_count', new \Scape\Filters\WordCountFilter());
$this->engine->registerFilter('keys', new \Scape\Filters\KeysFilter());
}
}
private function loadCustomFilter(string $path): void
{
$filePath = rtrim($this->config->getFiltersDir(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . str_replace('.', DIRECTORY_SEPARATOR, $path) . '.php';
if (!file_exists($filePath)) {
throw new \Scape\Exceptions\FilterNotFoundException("Custom filter file not found at '{$filePath}'.");
}
$filter = require $filePath;
if (!$filter instanceof \Scape\Interfaces\FilterInterface) {
throw new \Scape\Exceptions\FilterNotFoundException("Custom filter must return an instance of FilterInterface.");
}
// Use the last part of dot notation as alias
$parts = explode('.', $path);
$alias = end($parts);
$this->engine->registerFilter($alias, $filter);
}
private function interpretForeach(ForeachNode $node, array $context): string
{
$collection = $this->resolver->resolve($node->collection, $context);
if (!is_iterable($collection)) {
return '';
}
$output = '';
$items = is_array($collection) ? $collection : iterator_to_array($collection);
$total = count($items);
$currentIndex = 0;
foreach ($items as $key => $item) {
$loopContext = $context;
$loopContext[$node->valueVar] = $item;
if ($node->keyVar !== null) {
$loopContext[$node->keyVar] = $key;
}
// Loop variables
$loopContext['index'] = $currentIndex;
$loopContext['pos'] = $currentIndex + 1;
// Positional rendering
if ($currentIndex === 0 && !empty($node->first)) {
$output .= $this->interpret($node->first, $loopContext);
}
if ($currentIndex > 0 && $currentIndex < $total - 1 && !empty($node->inner)) {
$output .= $this->interpret($node->inner, $loopContext);
}
if ($currentIndex === $total - 1 && $currentIndex > 0 && !empty($node->last)) {
// Last is only for iterations > 1 if we follow "inner" logic,
// but usually "last" should render even if there's only one item?
// Specs say: "Renders only on the last iteration."
// If there's 1 item, it's first AND last.
// Let's re-read: "{( first )} ... {( endfirst )}: Renders only on the first iteration."
// "{( last )} ... {( endlast )}: Renders only on the last iteration."
// So if 1 item, both should render.
$output .= $this->interpret($node->last, $loopContext);
} elseif ($currentIndex === $total - 1 && $total === 1) {
// Single item case
$output .= $this->interpret($node->last, $loopContext);
}
$output .= $this->interpret($node->body, $loopContext);
$currentIndex++;
}
return $output;
}
}

View file

@ -0,0 +1,300 @@
<?php
declare(strict_types=1);
namespace Scape\Interpreter;
use Scape\Config;
use Scape\Engine;
use Scape\Exceptions\PropertyNotFoundException;
/**
* Class ValueResolver
*
* Handles resolving variable paths (dot-notation and bracket-notation) against a data context.
*
* @package Scape\Interpreter
*/
class ValueResolver
{
public function __construct(
private readonly Config $config,
private readonly ?Engine $engine = null
) {
}
public function resolve(string $expression, array $context): mixed
{
if (empty($expression)) {
return null;
}
// Split expression by pipe symbol, respecting quotes
$parts = $this->splitByPipe($expression);
$path = trim(array_shift($parts));
$value = $this->resolvePath($path, $context);
foreach ($parts as $filterExpression) {
$value = $this->applyFilter($value, $filterExpression, $context);
}
return $value;
}
/**
* Splits a string by pipes, but only if they are not inside quotes.
*
* @param string $expression
* @return array
*/
private function splitByPipe(string $expression): array
{
$parts = [];
$currentPart = '';
$inQuote = false;
$quoteChar = '';
$length = strlen($expression);
for ($i = 0; $i < $length; $i++) {
$char = $expression[$i];
if (($char === "'" || $char === '"') && ($i === 0 || $expression[$i - 1] !== '\\')) {
if (!$inQuote) {
$inQuote = true;
$quoteChar = $char;
} elseif ($char === $quoteChar) {
$inQuote = false;
}
}
if ($char === '|' && !$inQuote) {
$parts[] = $currentPart;
$currentPart = '';
} else {
$currentPart .= $char;
}
}
$parts[] = $currentPart;
return $parts;
}
/**
* Applies a filter to a value.
*
* @param mixed $value
* @param string $filterExpression
* @param array $context
* @return mixed
*/
private function applyFilter(mixed $value, string $filterExpression, array $context): mixed
{
$filterExpression = trim($filterExpression);
if ($filterExpression === '') {
return $value;
}
if (preg_match('/^([\w:]+)(?:\((.*)\))?$/', $filterExpression, $matches)) {
$filterName = $matches[1];
// Special case for 'raw' filter which is handled by VariableNode
if ($filterName === 'raw') {
return $value;
}
$argsString = $matches[2] ?? '';
$args = [];
if ($argsString !== '') {
preg_match_all('/\'(?:\\\\\'|[^\'])*\'|"(?:\\\\"|[^"])*"|[^,]+/', $argsString, $argMatches);
foreach ($argMatches[0] as $match) {
$match = trim($match);
if (str_starts_with($match, ',')) {
$match = ltrim(substr($match, 1));
}
if (str_ends_with($match, ',')) {
$match = rtrim(substr($match, 0, -1));
}
$match = trim($match);
if ($match !== '') {
// Resolve argument
if ((str_starts_with($match, "'") && str_ends_with($match, "'")) ||
(str_starts_with($match, '"') && str_ends_with($match, '"'))) {
$args[] = substr($match, 1, -1);
} elseif (is_numeric($match)) {
$args[] = $match + 0;
} else {
$args[] = $this->resolve($match, $context);
}
}
}
}
if ($this->engine !== null) {
try {
$filter = $this->engine->getFilter($filterName);
return $filter->transform($value, $args);
} catch (\Scape\Exceptions\FilterNotFoundException) {
// Fallback or ignore if filter not found?
// Usually Engine::getFilter throws, but maybe we should fail silently in production?
// For now, let it throw to maintain Excellence.
throw new \Scape\Exceptions\FilterNotFoundException("Filter '{$filterName}' not found.");
}
}
}
return $value;
}
/**
* Resolves a path against the given context.
*
* @param string $path The path to resolve (e.g., 'user.name' or 'items[0]').
* @param array $context The data context.
*
* @return mixed The resolved value.
*/
private function resolvePath(string $path, array $context): mixed
{
if (empty($path)) {
return null;
}
// Handle special host namespace
if (str_starts_with($path, 'host.')) {
if ($this->engine === null) {
return null;
}
$provider = $this->engine->getHostProvider();
if ($provider === null) {
return null;
}
$expression = substr($path, 5); // remove 'host.'
// Handle method-like call: host.translate('key')
if (preg_match('/^(\w+)\((.*)\)$/', $expression, $matches)) {
$method = $matches[1];
$argsString = $matches[2];
$args = [];
if ($argsString !== '') {
// Simple argument parsing for now
$rawArgs = array_map('trim', explode(',', $argsString));
foreach ($rawArgs as $arg) {
if ((str_starts_with($arg, "'") && str_ends_with($arg, "'")) ||
(str_starts_with($arg, '"') && str_ends_with($arg, '"'))) {
$args[] = substr($arg, 1, -1);
} elseif (is_numeric($arg)) {
$args[] = $arg + 0;
} else {
$args[] = $this->resolve($arg, $context);
}
}
}
if ($provider->has($method)) {
return $provider->call($method, $args);
}
}
// Handle property-like access: host.version
if ($provider->has($expression)) {
return $provider->call($expression);
}
return null;
}
// Split path into parts, preserving bracket content
$parts = $this->parsePath($path);
$current = $context;
foreach ($parts as $part) {
if ($this->isBracket($part)) {
$key = $this->extractBracketKey($part, $context);
if ($this->hasKey($current, $key)) {
$current = $this->getValue($current, $key);
} else {
return $this->handleMissing($path, $key);
}
} else {
if ($this->hasKey($current, $part)) {
$current = $this->getValue($current, $part);
} else {
return $this->handleMissing($path, $part);
}
}
}
return $current;
}
private function parsePath(string $path): array
{
// Simple regex to split by dot or bracket
// e.g. user.items[0]['title'] -> ['user', 'items', '[0]', "['title']"]
preg_match_all('/[^.\[\]]+|\[[^\]]+\]/', $path, $matches);
return $matches[0];
}
private function isBracket(string $part): bool
{
return str_starts_with($part, '[');
}
private function extractBracketKey(string $part, array $context): string|int
{
$inner = trim($part, '[]');
// Check if it's a string literal 'key' or "key"
if ((str_starts_with($inner, "'") && str_ends_with($inner, "'")) ||
(str_starts_with($inner, '"') && str_ends_with($inner, '"'))) {
return substr($inner, 1, -1);
}
// Check if it's numeric
if (is_numeric($inner)) {
return (int)$inner;
}
// Otherwise it's a variable reference inside brackets (e.g. items[index])
// Recursively resolve it
return (string)$this->resolve($inner, $context);
}
private function hasKey(mixed $container, string|int $key): bool
{
if (is_array($container)) {
return array_key_exists($key, $container);
}
if (is_object($container)) {
return property_exists($container, (string)$key) || isset($container->{$key});
}
return false;
}
private function getValue(mixed $container, string|int $key): mixed
{
if (is_array($container)) {
return $container[$key];
}
if (is_object($container)) {
return $container->{$key};
}
return null;
}
private function handleMissing(string $fullPath, string|int $key): mixed
{
if ($this->config->isDebug()) {
throw new PropertyNotFoundException("Property '{$key}' not found in path '{$fullPath}'.");
}
return null;
}
}

View file

@ -1,69 +0,0 @@
<?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;
}
}

View file

@ -1,42 +0,0 @@
<?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;
}

View file

@ -2,168 +2,208 @@
declare(strict_types=1);
namespace Eyrie\Parser;
use Eyrie\Parser\TokenType;
namespace Scape\Parser;
/**
* Class Lexer
*
* Tokenizes a Scape template string into a stream of Tokens.
*
* @package Scape\Parser
*/
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 const TAG_MAP = [
'{{{' => TokenType::RAW_START,
'}}}' => TokenType::RAW_END,
'{{' => TokenType::INTERPOLATION_START,
'}}' => TokenType::INTERPOLATION_END,
'{(' => TokenType::LOGIC_START,
')}' => TokenType::LOGIC_END,
'{[' => TokenType::BLOCK_START,
']}' => TokenType::BLOCK_END,
];
private string $source;
private int $cursor = 0;
private string $input;
private int $position = 0;
private int $line = 1;
private ?TokenType $state = null;
private int $length;
public function __construct(string $source)
/**
* Lexer constructor.
*
* @param string $input The template string to tokenize.
*/
public function __construct(string $input)
{
$this->source = $source;
$this->input = $input;
$this->length = strlen($input);
}
/**
* Tokenizes the input string.
*
* @return Token[]
*/
public function tokenize(): array
{
$tokens = [];
$length = strlen($this->source);
while ($this->cursor < $length) {
if ($this->state === null) {
$tokens[] = $this->lexText();
while ($this->position < $this->length) {
$char = $this->input[$this->position];
if ($char === '{' && $this->peek(1) !== '') {
$tokens = array_merge($tokens, $this->tokenizeTag());
} else {
$tokens = array_merge($tokens, $this->lexExpression());
$tokens[] = $this->tokenizeText();
}
}
$tokens[] = new Token(TokenType::EOF, '', $this->line);
return array_filter($tokens);
return $tokens;
}
private function lexText(): ?Token
/**
* Tokenizes a tag and its contents.
*
* @return Token[]
*/
private function tokenizeTag(): array
{
$start = $this->cursor;
$length = strlen($this->source);
$text = '';
$tagType = null;
$startTag = '';
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);
}
// Check for 3-char tags first ({{{)
$triple = substr($this->input, $this->position, 3);
if ($triple === '{{{') {
$startTag = $triple;
$tagType = TokenType::RAW_START;
} else {
// Check for 2-char tags
$double = substr($this->input, $this->position, 2);
if (isset(self::TAG_MAP[$double])) {
$startTag = $double;
$tagType = self::TAG_MAP[$double];
}
$char = $this->source[$this->cursor];
if ($char === "\n") {
$this->line++;
}
$text .= $char;
$this->cursor++;
}
return $text !== '' ? new Token(TokenType::TEXT, $text, $this->line) : null;
}
if ($tagType === null) {
return [$this->tokenizeText()];
}
private function lexExpression(): array
{
$tokens = [];
$length = strlen($this->source);
$tokens[] = new Token($tagType, $startTag, $this->line);
$this->position += strlen($startTag);
while ($this->cursor < $length) {
$this->skipWhitespace();
// Find the matching end tag
$endTag = match($tagType) {
TokenType::RAW_START => '}}}',
TokenType::INTERPOLATION_START => '}}',
TokenType::LOGIC_START => ')}',
TokenType::BLOCK_START => ']}',
default => '',
};
// 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;
$endPos = strpos($this->input, $endTag, $this->position);
if ($endPos === false) {
// Malformed tag, treat as text (though in a real engine we might throw a SyntaxException)
// For now, we'll just consume until EOF
$content = substr($this->input, $this->position);
$tokens[] = new Token(TokenType::TEXT, $content, $this->line);
$this->updateLineCount($content);
$this->position = $this->length;
return $tokens;
}
$content = substr($this->input, $this->position, $endPos - $this->position);
$tokens[] = new Token(TokenType::TEXT, $content, $this->line);
$this->updateLineCount($content);
$this->position = $endPos;
$tokens[] = new Token(self::TAG_MAP[$endTag], $endTag, $this->line);
$this->position += strlen($endTag);
// Special Rule: Logic and Block tags consume one trailing newline
if ($tagType === TokenType::LOGIC_START || $tagType === TokenType::BLOCK_START) {
if ($this->peek() === "\r") {
$this->position++;
if ($this->peek() === "\n") {
$this->position++;
}
}
$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++;
$this->line++;
} elseif ($this->peek() === "\n") {
$this->position++;
$this->line++;
}
}
return $tokens;
}
private function isEndDelimiter(TokenType $type): bool
/**
* Tokenizes a block of plain text.
*
* @return Token
*/
private function tokenizeText(): Token
{
return in_array($type, [
TokenType::OUTPUT_END,
TokenType::CONTROL_END,
TokenType::BLOCK_END,
TokenType::COMPONENT_END
]);
}
$nextTagPos = strpos($this->input, '{', $this->position);
private function skipWhitespace(): void
{
while ($this->cursor < strlen($this->source) && ctype_space($this->source[$this->cursor])) {
if ($this->source[$this->cursor] === "\n") {
$this->line++;
if ($nextTagPos === false) {
$content = substr($this->input, $this->position);
$this->position = $this->length;
} else {
// Peek to see if it's a real tag or just a stray '{'
$peek = substr($this->input, $nextTagPos, 2);
if (isset(self::TAG_MAP[$peek]) || substr($this->input, $nextTagPos, 3) === '{{{') {
$content = substr($this->input, $this->position, $nextTagPos - $this->position);
$this->position = $nextTagPos;
} else {
// Not a tag, consume the '{' and continue
$content = substr($this->input, $this->position, $nextTagPos - $this->position + 1);
$this->position = $nextTagPos + 1;
}
$this->cursor++;
}
// Determine the starting line for this text token. If the text begins with a newline,
// the visible content effectively starts on the next line; record that for better diagnostics.
$lineForToken = $this->line;
if ($content !== '') {
if ($content[0] === "\n") {
$lineForToken++;
} elseif ($content[0] === "\r" && (strlen($content) > 1 && $content[1] === "\n")) {
$lineForToken++;
}
}
$token = new Token(TokenType::TEXT, $content, $lineForToken);
$this->updateLineCount($content);
return $token;
}
private function lexIdentifier(): Token
/**
* Peeks at the character at a relative offset.
*
* @param int $offset
* @return string
*/
private function peek(int $offset = 0): string
{
$start = $this->cursor;
while ($this->cursor < strlen($this->source) && (ctype_alnum($this->source[$this->cursor]) || $this->source[$this->cursor] === '_')) {
$this->cursor++;
if ($this->position + $offset >= $this->length) {
return '';
}
return new Token(TokenType::IDENTIFIER, substr($this->source, $start, $this->cursor - $start), $this->line);
return $this->input[$this->position + $offset];
}
private function lexNumber(): Token
/**
* Updates the internal line counter based on newlines in the content.
*
* @param string $content
*/
private function updateLineCount(string $content): void
{
$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);
$this->line += substr_count($content, "\n");
}
}

View file

@ -2,21 +2,29 @@
declare(strict_types=1);
namespace Eyrie\Parser\Node;
namespace Scape\Parser\Node;
/**
* Class BlockNode
*
* Represents an inheritance block {[ block 'name' ]}.
*
* @package Scape\Parser\Node
*/
class BlockNode extends Node
{
/**
* @param string $name
* @param Node[] $body
* @param int $line
* @param bool $isLayoutOnly Whether this block was defined in a layout (placeholder)
*/
public function __construct(
public readonly string $name,
public readonly array $body,
public readonly array $context = [],
int $line = 0
int $line,
public readonly bool $isLayoutOnly = false
) {
parent::__construct($line);
}
public function __toString(): string
{
return "[[ block " . $this->name . " ]]";
}
}

View file

@ -1,21 +0,0 @@
<?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 . " />";
}
}

View file

@ -1,21 +0,0 @@
<?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 . " >>";
}
}

View file

@ -2,19 +2,21 @@
declare(strict_types=1);
namespace Eyrie\Parser\Node;
namespace Scape\Parser\Node;
/**
* Class ExtendsNode
*
* Represents an {[ extends 'path' ]} tag.
*
* @package Scape\Parser\Node
*/
class ExtendsNode extends Node
{
public function __construct(
public readonly string $layout,
int $line = 0
public readonly string $path,
int $line
) {
parent::__construct($line);
}
public function __toString(): string
{
return "[[ extends \"" . $this->layout . "\" ]]";
}
}

View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Scape\Parser\Node;
/**
* Class FilterLoadNode
*
* Represents {( uses ... )} or {( load_filter(...) )}.
*
* @package Scape\Parser\Node
*/
class FilterLoadNode extends Node
{
/**
* @param string $path The path or namespace:library.
* @param bool $isInternal True if 'uses', false if 'load_filter'.
* @param int $line
*/
public function __construct(
public readonly string $path,
public readonly bool $isInternal,
int $line
) {
parent::__construct($line);
}
}

View file

@ -2,21 +2,37 @@
declare(strict_types=1);
namespace Eyrie\Parser\Node;
namespace Scape\Parser\Node;
/**
* Class ForeachNode
*
* Represents a {( foreach )} loop.
*
* @package Scape\Parser\Node
*/
class ForeachNode extends Node
{
/**
* @param string $collection The collection being iterated.
* @param string $valueVar The variable name for the value.
* @param string|null $keyVar The optional variable name for the key.
* @param Node[] $body The AST nodes inside the loop.
* @param Node[] $first Nodes inside {( first )}.
* @param Node[] $inner Nodes inside {( inner )}.
* @param Node[] $last Nodes inside {( last )}.
* @param int $line
*/
public function __construct(
public readonly string $items,
public readonly string $item,
public readonly string $collection,
public readonly string $valueVar,
public readonly ?string $keyVar,
public readonly array $body,
int $line = 0
int $line,
public readonly array $first = [],
public readonly array $inner = [],
public readonly array $last = []
) {
parent::__construct($line);
}
public function __toString(): string
{
return "<( foreach " . $this->item . " in " . $this->items . " )>";
}
}

View file

@ -1,23 +0,0 @@
<?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 . " )>";
}
}

View file

@ -2,20 +2,27 @@
declare(strict_types=1);
namespace Eyrie\Parser\Node;
namespace Scape\Parser\Node;
/**
* Class IncludeNode
*
* Represents an {[ include 'path' ]} tag.
*
* @package Scape\Parser\Node
*/
class IncludeNode extends Node
{
/**
* @param string $path The dot-notated path to the partial.
* @param string|null $context The data source (variable name, 'context', or null).
* @param int $line
*/
public function __construct(
public readonly string $template,
public readonly array $context = [],
int $line = 0
public readonly string $path,
public readonly ?string $context,
int $line
) {
parent::__construct($line);
}
public function __toString(): string
{
return "[[ include \"" . $this->template . "\" ]]";
}
}

View file

@ -1,22 +0,0 @@
<?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 . " )>";
}
}

View file

@ -2,13 +2,24 @@
declare(strict_types=1);
namespace Eyrie\Parser\Node;
namespace Scape\Parser\Node;
/**
* Class Node
*
* Abstract base class for all AST nodes in Scape.
*
* @package Scape\Parser\Node
*/
abstract class Node
{
public function __construct(public readonly int $line)
{
/**
* Node constructor.
*
* @param int $line The line number where this node begins.
*/
public function __construct(
public readonly int $line
) {
}
abstract public function __toString(): string;
}

View file

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Scape\Parser\Node;
/**
* Class ParentNode
*
* Represents a {[ parent ]} tag.
*
* @package Scape\Parser\Node
*/
class ParentNode extends Node
{
public function __construct(int $line)
{
parent::__construct($line);
}
}

View file

@ -1,21 +0,0 @@
<?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));
}
}

View file

@ -1,18 +0,0 @@
<?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 ]]";
}
}

View file

@ -2,17 +2,21 @@
declare(strict_types=1);
namespace Eyrie\Parser\Node;
namespace Scape\Parser\Node;
/**
* Class TextNode
*
* Represents a block of plain text.
*
* @package Scape\Parser\Node
*/
class TextNode extends Node
{
public function __construct(public readonly string $content, int $line)
{
public function __construct(
public readonly string $content,
int $line
) {
parent::__construct($line);
}
public function __toString(): string
{
return $this->content;
}
}

View file

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Scape\Parser\Node;
/**
* Class VariableNode
*
* Represents variable interpolation ({{ var }} or {{{ var }}}).
*
* @package Scape\Parser\Node
*/
class VariableNode extends Node
{
/**
* @param string $expression The full expression.
* @param string $path The variable path (before first pipe).
* @param array $filters List of filters and their arguments.
* @param bool $isRaw Whether it's raw interpolation.
* @param int $line
*/
public function __construct(
public readonly string $expression,
public readonly string $path,
public readonly array $filters,
public readonly bool $isRaw,
int $line
) {
parent::__construct($line);
}
}

View file

@ -2,423 +2,382 @@
declare(strict_types=1);
namespace Eyrie\Parser;
namespace Scape\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;
use Scape\Exceptions\SyntaxException;
use Scape\Parser\Node\BlockNode;
use Scape\Parser\Node\ExtendsNode;
use Scape\Parser\Node\FilterLoadNode;
use Scape\Parser\Node\ForeachNode;
use Scape\Parser\Node\IncludeNode;
use Scape\Parser\Node\Node;
use Scape\Parser\Node\ParentNode;
use Scape\Parser\Node\TextNode;
use Scape\Parser\Node\VariableNode;
/**
* Class Parser
*
* Converts a stream of tokens into an AST.
*
* @package Scape\Parser
*/
class Parser
{
/** @var Token[] */
private array $tokens;
private int $cursor = 0;
private int $position = 0;
private bool $isLayout = false;
public function __construct(array $tokens)
/**
* @param Token[] $tokens
* @param bool $isLayout
*/
public function __construct(array $tokens, bool $isLayout = false)
{
$this->tokens = $tokens;
$this->isLayout = $isLayout;
}
public function parse(): RootNode
/**
* Parses the tokens into an array of AST nodes.
*
* @return Node[]
* @throws SyntaxException
*/
public function parse(): array
{
$root = new RootNode();
while (!$this->isEOF()) {
$node = $this->parseNode();
if ($node) {
$root->children[] = $node;
}
$nodes = [];
while (!$this->isAtEnd()) {
$nodes[] = $this->parseNode();
}
return $root;
return $nodes;
}
private function parseNode(): ?Node
/**
* @return Node
* @throws SyntaxException
*/
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;
return match ($token->type) {
TokenType::TEXT => $this->parseText(),
TokenType::INTERPOLATION_START => $this->parseVariable(false),
TokenType::RAW_START => $this->parseVariable(true),
TokenType::LOGIC_START => $this->parseLogic(),
TokenType::BLOCK_START => $this->parseBlockTag(),
default => throw new SyntaxException("Unexpected token: {$token->type->value} at line {$token->line}"),
};
}
private function parseOutput(): ExpressionNode
private function parseText(): TextNode
{
$startToken = $this->consume(TokenType::OUTPUT_START);
$token = $this->consume(TokenType::TEXT);
return new TextNode($token->value, $token->line);
}
private function parseVariable(bool $isRaw): VariableNode
{
$startType = $isRaw ? TokenType::RAW_START : TokenType::INTERPOLATION_START;
$endType = $isRaw ? TokenType::RAW_END : TokenType::INTERPOLATION_END;
$startToken = $this->consume($startType);
$expression = '';
while (!$this->check($endType) && !$this->isAtEnd()) {
$expression .= $this->advance()->value;
}
$this->consume($endType, "Expected closing tag for variable interpolation at line " . $startToken->line);
$trimmedExpression = trim($expression);
$isRawFromFilter = false;
// Handle nested pipes in arguments (very basic)
// We split by | but only if it's not inside quotes
$parts = [];
$currentPart = '';
$inQuote = false;
$quoteChar = '';
for ($i = 0; $i < strlen($trimmedExpression); $i++) {
$char = $trimmedExpression[$i];
if (($char === "'" || $char === '"') && ($i === 0 || $trimmedExpression[$i-1] !== '\\')) {
if (!$inQuote) {
$inQuote = true;
$quoteChar = $char;
} elseif ($char === $quoteChar) {
$inQuote = false;
}
}
if ($char === '|' && !$inQuote) {
$parts[] = $currentPart;
$currentPart = '';
} else {
$currentPart .= $char;
}
}
$parts[] = $currentPart;
$path = trim(array_shift($parts));
$filters = [];
while (!$this->isEOF() && $this->peek()->type !== TokenType::OUTPUT_END) {
if ($this->peek()->value === '|') {
$this->consume(); // |
$filterName = $this->consume(TokenType::IDENTIFIER)->value;
foreach ($parts as $part) {
$part = trim($part);
if (empty($part)) continue;
if (preg_match('/^([\w:]+)(?:\((.*)\))?$/', $part, $matches)) {
$filterName = $matches[1];
if ($filterName === 'raw') {
$isRawFromFilter = true;
continue;
}
$argsString = $matches[2] ?? '';
$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;
if ($argsString !== '') {
// Simple comma-separated args
$args = [];
$argsString = trim($argsString);
if ($argsString !== '') {
// Improved regex to handle quoted strings with commas
$args = [];
preg_match_all('/\'(?:\\\\\'|[^\'])*\'|"(?:\\\\"|[^"])*"|[^,]+/', $argsString, $argMatches);
foreach ($argMatches[0] as $match) {
$match = trim($match);
// If it starts with comma, remove it
if (str_starts_with($match, ',')) {
$match = ltrim(substr($match, 1));
}
// If it ends with comma, remove it
if (str_ends_with($match, ',')) {
$match = rtrim(substr($match, 0, -1));
}
$match = trim($match);
if ($match !== '') {
$args[] = $match;
}
}
$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;
}
$filters[] = [
'name' => $filterName,
'args' => $args
];
}
$body[] = $this->parseNode();
}
return new IfNode(trim($condition), array_filter($body), $elseifs, $else, $startToken->line);
return new VariableNode($trimmedExpression, $path, $filters, $isRaw || $isRawFromFilter, $startToken->line);
}
private function parseForeach(): ForeachNode
private function parseLogic(): Node
{
$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 . ' ';
$startToken = $this->consume(TokenType::LOGIC_START);
$content = '';
while (!$this->check(TokenType::LOGIC_END) && !$this->isAtEnd()) {
$content .= $this->advance()->value;
}
$this->consume(TokenType::CONTROL_END);
$this->consume(TokenType::LOGIC_END, "Expected ')}' at line " . $startToken->line);
$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();
$content = trim($content);
if (str_starts_with($content, 'foreach ')) {
return $this->parseForeach($content, $startToken->line);
}
return new ForeachNode(trim($items), $item, array_filter($body), $startToken->line);
if (str_starts_with($content, 'uses ')) {
$path = trim(substr($content, 5));
return new FilterLoadNode($path, true, $startToken->line);
}
if (preg_match('/^load_filter\([\'"](.+?)[\'"]\)$/', $content, $matches)) {
return new FilterLoadNode($matches[1], false, $startToken->line);
}
throw new SyntaxException("Unknown logic tag: '{( $content )}' at line {$startToken->line}");
}
private function parseLoop(): LoopNode
private function parseForeach(string $content, int $line): ForeachNode
{
$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);
// {( foreach item in collection )}
// {( foreach key, item in collection )}
if (preg_match('/^foreach\s+(?:(?P<key>\w+)\s*,\s*)?(?P<value>\w+)\s+in\s+(?P<collection>.+)$/', $content, $matches)) {
$keyVar = $matches['key'] ?: null;
$valueVar = $matches['value'];
$collection = $matches['collection'];
$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 = [];
$first = [];
$inner = [];
$last = [];
while (!$this->isAtEnd() && !$this->isLogicEnd('endforeach')) {
if ($this->isLogicStart('first')) {
$this->consumeLogic('first');
$first = $this->parseUntilLogicEnd('endfirst');
} elseif ($this->isLogicStart('inner')) {
$this->consumeLogic('inner');
$inner = $this->parseUntilLogicEnd('endinner');
} elseif ($this->isLogicStart('last')) {
$this->consumeLogic('last');
$last = $this->parseUntilLogicEnd('endlast');
} else {
$body[] = $this->parseNode();
}
}
$body[] = $this->parseNode();
$this->consumeLogic('endforeach');
return new ForeachNode($collection, $valueVar, $keyVar, $body, $line, $first, $inner, $last);
}
return new LoopNode(trim($from), trim($to), array_filter($body), $startToken->line);
throw new SyntaxException("Invalid foreach syntax: '{( $content )}' at line $line");
}
private function parseBlockTag(): ?Node
private function parseBlockTag(): Node
{
$startToken = $this->consume(TokenType::BLOCK_START);
$content = '';
while (!$this->check(TokenType::BLOCK_END) && !$this->isAtEnd()) {
$content .= $this->advance()->value;
}
$this->consume(TokenType::BLOCK_END, "Expected ']}' at line " . $startToken->line);
$content = trim($content);
if (preg_match('/^extends\s+[\'"](.+?)[\'"]$/', $content, $matches)) {
return new ExtendsNode($matches[1], $startToken->line);
}
if (preg_match('/^block\s+[\'"](.+?)[\'"]$/', $content, $matches)) {
$name = $matches[1];
$body = $this->parseUntilBlockEnd('endblock');
return new BlockNode($name, $body, $startToken->line, $this->isLayout);
}
if ($content === 'parent') {
return new ParentNode($startToken->line);
}
if (preg_match('/^include\s+[\'"](.+?)[\'"](?:\s+with\s+(.+))?$/', $content, $matches)) {
$path = $matches[1];
$context = isset($matches[2]) ? trim($matches[2]) : null;
return new IncludeNode($path, $context, $startToken->line);
}
if (preg_match('/^include\s+[\'"](.+?)[\'"]\s+with\s+(\[.*\])$/', $content, $matches)) {
$path = $matches[1];
$context = $matches[2];
return new IncludeNode($path, $context, $startToken->line);
}
throw new SyntaxException("Unknown block tag: '{[ $content ]}' at line {$startToken->line}");
}
private function parseUntilLogicEnd(string $endTag): array
{
$nodes = [];
while (!$this->isAtEnd() && !$this->isLogicEnd($endTag)) {
$nodes[] = $this->parseNode();
}
$this->consumeLogic($endTag);
return $nodes;
}
private function parseUntilBlockEnd(string $endTag): array
{
$nodes = [];
while (!$this->isAtEnd() && !$this->isBlockEnd($endTag)) {
$nodes[] = $this->parseNode();
}
$this->consumeBlock($endTag);
return $nodes;
}
private function isLogicStart(string $tag): bool
{
if ($this->peek()->type !== TokenType::LOGIC_START) {
return false;
}
$contentToken = $this->peek(1);
return $contentToken && trim($contentToken->value) === $tag;
}
private function isLogicEnd(string $tag): bool
{
return $this->isLogicStart($tag);
}
private function isBlockEnd(string $tag): bool
{
if ($this->peek()->type !== TokenType::BLOCK_START) {
return false;
}
$contentToken = $this->peek(1);
return $contentToken && trim($contentToken->value) === $tag;
}
private function consumeLogic(string $tag): void
{
$this->consume(TokenType::LOGIC_START);
$content = '';
while (!$this->check(TokenType::LOGIC_END) && !$this->isAtEnd()) {
$content .= $this->advance()->value;
}
if (trim($content) !== $tag) {
throw new SyntaxException("Expected '{( $tag )}' but got '{( $content )}'");
}
$this->consume(TokenType::LOGIC_END);
}
private function consumeBlock(string $tag): void
{
$this->consume(TokenType::BLOCK_START);
$token = $this->peek();
if ($token->value === 'extends') {
return $this->parseExtends();
$content = '';
while (!$this->check(TokenType::BLOCK_END) && !$this->isAtEnd()) {
$content .= $this->advance()->value;
}
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();
if (trim($content) !== $tag) {
throw new SyntaxException("Expected '{[ $tag ]}' but got '{[ $content ]}'");
}
$this->consume(TokenType::BLOCK_END);
return null;
}
private function parseExtends(): ExtendsNode
private function peek(int $offset = 0): Token
{
$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);
if ($this->position + $offset >= count($this->tokens)) {
return new Token(TokenType::EOF, '', -1);
}
return $this->tokens[$this->position + $offset];
}
private function parseBlock(): BlockNode
private function advance(): Token
{
$startToken = $this->consume(); // block
$name = $this->consume(TokenType::IDENTIFIER)->value;
$context = [];
if (!$this->isAtEnd()) {
$this->position++;
}
return $this->tokens[$this->position - 1];
}
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(); // }
}
}
private function isAtEnd(): bool
{
return $this->peek()->type === TokenType::EOF || $this->position >= count($this->tokens);
}
private function check(TokenType $type): bool
{
if ($this->isAtEnd()) {
return false;
}
return $this->peek()->type === $type;
}
private function consume(TokenType $type, string $message = ''): Token
{
if ($this->check($type)) {
return $this->advance();
}
$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;
throw new SyntaxException($message ?: "Expected token $type->name but found " . $this->peek()->type->name);
}
}

View file

@ -2,10 +2,24 @@
declare(strict_types=1);
namespace Eyrie\Parser;
namespace Scape\Parser;
/**
* Class Token
*
* Represents a single token identified in a Scape template.
*
* @package Scape\Parser
*/
class Token
{
/**
* Token constructor.
*
* @param TokenType $type The type of the token.
* @param string $value The raw value of the token.
* @param int $line The line number where the token was found.
*/
public function __construct(
public readonly TokenType $type,
public readonly string $value,

View file

@ -2,22 +2,25 @@
declare(strict_types=1);
namespace Eyrie\Parser;
namespace Scape\Parser;
/**
* Enum TokenType
*
* Defines the types of tokens identified by the Lexer.
*
* @package Scape\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';
case TEXT = 'TEXT';
case INTERPOLATION_START = 'INTERPOLATION_START'; // {{
case INTERPOLATION_END = 'INTERPOLATION_END'; // }}
case RAW_START = 'RAW_START'; // {{{
case RAW_END = 'RAW_END'; // }}}
case LOGIC_START = 'LOGIC_START'; // {(
case LOGIC_END = 'LOGIC_END'; // )}
case BLOCK_START = 'BLOCK_START'; // {[
case BLOCK_END = 'BLOCK_END'; // ]}
case EOF = 'EOF';
}

View file

@ -1,17 +0,0 @@
<?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;
}
}

102
tests/CacheTest.php Normal file
View file

@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace Scape\Tests;
use PHPUnit\Framework\TestCase;
use Scape\Engine;
class CacheTest extends TestCase
{
private string $templatesDir;
private string $cacheDir;
protected function setUp(): void
{
$this->templatesDir = __DIR__ . '/fixtures';
$this->cacheDir = __DIR__ . '/../.scape/cache_test';
if (!is_dir($this->cacheDir)) {
mkdir($this->cacheDir, 0777, true);
}
}
protected function tearDown(): void
{
$this->rmdirRecursive($this->cacheDir);
}
private function rmdirRecursive(string $dir): void
{
if (!is_dir($dir)) return;
$files = array_diff(scandir($dir), ['.', '..']);
foreach ($files as $file) {
$path = $dir . '/' . $file;
is_dir($path) ? $this->rmdirRecursive($path) : unlink($path);
}
rmdir($dir);
}
public function testCreatesCacheFile(): void
{
$engine = new Engine([
'templates_dir' => $this->templatesDir,
'cache_dir' => $this->cacheDir,
'mode' => 'production'
]);
$templatePath = $this->templatesDir . '/tests/simple.scape.php';
$cacheKey = md5($templatePath);
$cacheFile = $this->cacheDir . '/' . $cacheKey . '.ast';
$this->assertFileDoesNotExist($cacheFile);
$engine->render('tests.simple', ['name' => 'Funky', 'items' => []]);
$this->assertFileExists($cacheFile);
}
public function testUsesCacheInProduction(): void
{
$engine = new Engine([
'templates_dir' => $this->templatesDir,
'cache_dir' => $this->cacheDir,
'mode' => 'production'
]);
$engine->render('tests.simple', ['name' => 'First', 'items' => []]);
// Modify template file but production should still use old cache
$templatePath = $this->templatesDir . '/tests/simple.scape.php';
$originalContent = file_get_contents($templatePath);
file_put_contents($templatePath, 'Modified Content');
$output = $engine->render('tests.simple', ['name' => 'Second', 'items' => []]);
$this->assertStringContainsString('Hello Second!', $output); // simple.scape.php has "Hello {{ name }}!"
// Restore
file_put_contents($templatePath, $originalContent);
}
public function testInvalidatesCacheInDebug(): void
{
$engine = new Engine([
'templates_dir' => $this->templatesDir,
'cache_dir' => $this->cacheDir,
'mode' => 'debug'
]);
$templatePath = $this->templatesDir . '/tests/cache_debug.scape.php';
file_put_contents($templatePath, 'Original');
$engine->render('tests.cache_debug');
// Sleep to ensure mtime difference
sleep(1);
file_put_contents($templatePath, 'Modified');
$output = $engine->render('tests.cache_debug');
$this->assertEquals('Modified', $output);
unlink($templatePath);
}
}

60
tests/ConfigTest.php Normal file
View file

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Scape\Tests;
use PHPUnit\Framework\TestCase;
use Scape\Config;
class ConfigTest extends TestCase
{
protected function setUp(): void
{
// Clear environment variables before each test
putenv('SCAPE_TEMPLATES_DIR');
putenv('SCAPE_LAYOUTS_DIR');
putenv('SCAPE_PARTIALS_DIR');
putenv('SCAPE_FILTERS_DIR');
putenv('SCAPE_MODE');
}
public function testDefaultValues(): void
{
$config = new Config();
$this->assertEquals('production', $config->getMode());
$this->assertEquals('./templates', $config->getTemplatesDir());
$this->assertEquals('./templates/layouts', $config->getLayoutsDir());
$this->assertEquals('./templates/partials', $config->getPartialsDir());
$this->assertEquals('./filters', $config->getFiltersDir());
$this->assertEquals('./.scape/cache', $config->getCacheDir());
}
public function testEnvironmentVariableOverrides(): void
{
putenv('SCAPE_MODE=debug');
putenv('SCAPE_TEMPLATES_DIR=/tmp/templates');
$config = new Config();
$this->assertEquals('debug', $config->getMode());
$this->assertEquals('/tmp/templates', $config->getTemplatesDir());
// Layouts/Partials should still fall back relative to templates if not set?
// Or remain as defaults? Let's check spec.
// Spec says they are managed via env vars. We'll assume they have independent defaults.
}
public function testProgrammaticOverridesPrecedence(): void
{
putenv('SCAPE_MODE=debug');
$config = new Config([
'mode' => 'production',
'templates_dir' => '/custom/path'
]);
$this->assertEquals('production', $config->getMode());
$this->assertEquals('/custom/path', $config->getTemplatesDir());
}
}

View file

@ -2,211 +2,122 @@
declare(strict_types=1);
namespace Eyrie\Tests;
namespace Scape\Tests;
use Eyrie\Engine;
use Eyrie\Loader\FileLoader;
use PHPUnit\Framework\TestCase;
use Scape\Engine;
use Scape\Exceptions\TemplateNotFoundException;
use Scape\Exceptions\RecursionLimitException;
class EngineTest extends TestCase
{
private string $tempDir;
private string $cacheDir;
private string $templatesDir;
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';
$this->templatesDir = __DIR__ . '/fixtures';
}
protected function tearDown(): void
public function testRenderSignature(): void
{
$this->removeDirectory($this->tempDir);
$engine = new Engine();
$this->assertTrue(method_exists($engine, 'render'));
}
public function testRenderSimpleText(): void
public function testThrowsTemplateNotFoundExceptionInDebug(): 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('&lt;script&gt;', $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 ]]');
$this->expectException(TemplateNotFoundException::class);
$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);
$engine = new Engine(['mode' => 'debug']);
$engine->render('missing.template');
}
public function testRenderSuper(): void
public function testRendersPlaceholderInProduction(): 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 ]]');
$engine = new Engine(['mode' => 'production']);
$output = $engine->render('missing.template');
$this->assertEquals("<!-- Scape: Template 'missing.template' not found -->", $output);
}
public function testRenders404Fallback(): void
{
file_put_contents($this->templatesDir . '/404.scape.php', 'Custom 404: {{ missing_template }}');
$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" ]]');
$engine = new Engine(['templates_dir' => $this->templatesDir, 'mode' => 'production']);
$output = $engine->render('missing.template');
$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" />');
$this->assertEquals('Custom 404: missing.template', $output);
$loader = new FileLoader([$this->tempDir]);
$engine = new Engine($loader, ['cache' => $this->cacheDir]);
$result = $engine->render('page_comp');
$this->assertStringContainsString('Movie: Inception', $result);
unlink($this->templatesDir . '/404.scape.php');
}
public function testRenderFilters(): void
public function testRendersSimpleTemplate(): void
{
file_put_contents($this->tempDir . '/filter.eyrie.php', '<< name | upper >>');
$loader = new FileLoader([$this->tempDir]);
$engine = new Engine($loader, ['cache' => $this->cacheDir]);
$engine = new Engine([
'templates_dir' => $this->templatesDir,
'mode' => 'production'
]);
$result = $engine->render('filter', ['name' => 'junie']);
$this->assertEquals('JUNIE', $result);
$output = $engine->render('tests.simple', [
'name' => 'Funky',
'items' => ['Apple', 'Banana']
]);
$expected = "Hello Funky!\n - Apple\n - Banana\n";
$this->assertEquals($expected, $output);
}
public function testRenderBlockContext(): void
public function testRendersTemplateWithInheritance(): 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 ]]');
$engine = new Engine([
'templates_dir' => $this->templatesDir,
'layouts_dir' => $this->templatesDir . '/layouts',
'mode' => 'production'
]);
$output = $engine->render('tests.child', [
'name' => 'Funky'
]);
$this->assertStringContainsString('<title>Child Page - Funky</title>', $output);
$this->assertStringContainsString('<h1>Site Header</h1>', $output);
$this->assertStringContainsString('<h2>Welcome</h2>', $output);
$this->assertStringContainsString('Nested default', $output);
$this->assertStringContainsString('&copy; 2026 - Custom Footer', $output);
}
public function testRendersIncludeWithContext(): void
{
$engine = new Engine([
'templates_dir' => $this->templatesDir,
'partials_dir' => $this->templatesDir . '/partials',
'mode' => 'production'
]);
$output = $engine->render('tests.include', [
'name' => 'Funky',
'id' => 123,
'person' => ['name' => 'John', 'id' => 456]
]);
$this->assertStringContainsString('Hello Funky!', $output);
$this->assertStringContainsString('Name: Funky', $output);
$this->assertStringContainsString('ID: 123', $output);
$this->assertStringContainsString('Name: John', $output);
$this->assertStringContainsString('ID: 456', $output);
}
public function testRecursionLimit(): void
{
$this->expectException(RecursionLimitException::class);
$loader = new FileLoader([$this->tempDir]);
$engine = new Engine($loader, ['cache' => $this->cacheDir]);
$engine = new Engine([
'templates_dir' => $this->templatesDir,
'partials_dir' => $this->templatesDir . '/partials',
'mode' => 'production'
]);
$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);
$engine->render('partials.recursive');
}
}

144
tests/FilterTest.php Normal file
View file

@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
namespace Scape\Tests;
use PHPUnit\Framework\TestCase;
use Scape\Engine;
class FilterTest extends TestCase
{
private string $templatesDir;
private string $filtersDir;
protected function setUp(): void
{
$this->templatesDir = __DIR__ . '/fixtures';
$this->filtersDir = __DIR__ . '/fixtures/filters';
}
public function testInternalFilters(): void
{
file_put_contents($this->templatesDir . '/tests/filters_internal.scape.php',
'{( uses filters:string )}' . "\n" .
'{{ name | lower }}' . "\n" .
'{{ name | upper }}' . "\n" .
'{{ name | lower | ucfirst }}' . "\n" .
'{{ price | currency(\'USD\') }}' . "\n" .
'{{ val | float(3) }}' . "\n" .
'{{ date_val | date(\'Y-m-d\') }}' . "\n" .
'{{ bio | truncate(10) }}' . "\n" .
'{{ missing | default(\'N/A\') }}' . "\n" .
'{{ tags | join(\', \') }}' . "\n" .
'{{ tags | first }}' . "\n" .
'{{ tags | last }}' . "\n" .
'{{ bio | word_count }}' . "\n" .
'{{ user_data | keys | join(\',\') }}' . "\n" .
'{{ query | url_encode }}' . "\n" .
'{{{ user_data | json }}}'
);
$engine = new Engine(['templates_dir' => $this->templatesDir]);
$output = $engine->render('tests.filters_internal', [
'name' => 'fUnKy',
'price' => 1234.56,
'val' => 123.45678,
'date_val' => 1707693300,
'bio' => 'A very long biography indeed.',
'missing' => '',
'tags' => ['PHP', 'Scape', 'Templates'],
'user_data' => ['id' => 1, 'name' => 'Funky'],
'query' => 'hello world'
]);
$lines = explode("\n", $output);
$this->assertEquals('funky', $lines[0]);
$this->assertEquals('FUNKY', $lines[1]);
$this->assertEquals('Funky', $lines[2]);
$this->assertEquals('$1,234.56', $lines[3]);
$this->assertEquals('123.457', $lines[4]);
$this->assertStringContainsString('2024-02-11', $output);
$this->assertStringContainsString('A very lon...', $output);
$this->assertStringContainsString('N/A', $output);
$this->assertStringContainsString('PHP, Scape, Templates', $output);
$this->assertStringContainsString('PHP', $output);
$this->assertStringContainsString('Templates', $output);
$this->assertStringContainsString('5', $output);
$this->assertStringContainsString('id,name', $output);
$this->assertStringContainsString('hello+world', $output);
$this->assertStringContainsString('{"id":1,"name":"Funky"}', $output);
unlink($this->templatesDir . '/tests/filters_internal.scape.php');
}
public function testCustomFilterWithArguments(): void
{
file_put_contents($this->templatesDir . '/tests/filters_custom.scape.php',
'{( load_filter(\'currency\') )}' . "\n" .
'{{ price | currency }}' . "\n" .
'{{ price | currency(\'£\') }}' . "\n" .
'{{ price | currency(sym) }}'
);
$engine = new Engine([
'templates_dir' => $this->templatesDir,
'filters_dir' => $this->filtersDir
]);
$output = $engine->render('tests.filters_custom', [
'price' => 1234.567,
'sym' => '€'
]);
$expected = "$1,234.57\n£1,234.57\n€1,234.57";
$this->assertEquals($expected, $output);
unlink($this->templatesDir . '/tests/filters_custom.scape.php');
}
public function testFiltersInForeach(): void
{
file_put_contents($this->templatesDir . '/tests/filters_foreach.scape.php',
'{( uses filters:string )}' . "\n" .
'{( foreach key in user_data | keys )}' . "\n" .
'{{ key }}: {{ user_data[key] }}' . "\n" .
'{( endforeach )}'
);
$engine = new Engine(['templates_dir' => $this->templatesDir]);
$output = $engine->render('tests.filters_foreach', [
'user_data' => ['id' => 1, 'name' => 'Funky']
]);
$this->assertStringContainsString('id: 1', $output);
$this->assertStringContainsString('name: Funky', $output);
unlink($this->templatesDir . '/tests/filters_foreach.scape.php');
}
public function testFiltersInInclude(): void
{
file_put_contents($this->templatesDir . '/partials/keys_list.scape.php',
'Keys: {{ context | join(\', \') }}'
);
file_put_contents($this->templatesDir . '/tests/filters_include.scape.php',
'{( uses filters:string )}' . "\n" .
'{[ include \'keys_list\' with user_data | keys ]}'
);
$engine = new Engine([
'templates_dir' => $this->templatesDir,
'partials_dir' => $this->templatesDir . '/partials'
]);
$output = $engine->render('tests.filters_include', [
'user_data' => ['id' => 1, 'name' => 'Funky']
]);
$this->assertEquals('Keys: id, name', trim($output));
unlink($this->templatesDir . '/partials/keys_list.scape.php');
unlink($this->templatesDir . '/tests/filters_include.scape.php');
}
}

View file

@ -1,55 +0,0 @@
<?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);
}
}

View file

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Scape\Tests;
use PHPUnit\Framework\TestCase;
use Scape\Engine;
use Scape\Interfaces\HostProviderInterface;
class HostProviderTest extends TestCase
{
public function testHostNamespaceDelegation(): void
{
$mockProvider = new class implements HostProviderInterface {
public function has(string $name): bool {
return in_array($name, ['version', 'translate']);
}
public function call(string $name, array $args = []): mixed {
if ($name === 'version') return '1.0.0';
if ($name === 'translate') {
$key = $args[0] ?? '';
$lang = $args[1] ?? 'en';
return "Translated '$key' to $lang";
}
return null;
}
};
$engine = new Engine();
$engine->registerHostProvider($mockProvider);
$templatesDir = __DIR__ . '/../templates';
file_put_contents($templatesDir . '/tests/host_test.scape.php',
'Version: {{ host.version }}' . "\n" .
'i18n: {{ host.translate(\'welcome\', \'fr\') }}' . "\n" .
'Var arg: {{ host.translate(my_key) }}'
);
$output = $engine->render('tests.host_test', ['my_key' => 'hello']);
$this->assertStringContainsString('Version: 1.0.0', $output);
$this->assertStringContainsString('i18n: Translated &apos;welcome&apos; to fr', $output);
$this->assertStringContainsString('Var arg: Translated &apos;hello&apos; to en', $output);
unlink($templatesDir . '/tests/host_test.scape.php');
}
}

144
tests/InterpreterTest.php Normal file
View file

@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
namespace Scape\Tests;
use PHPUnit\Framework\TestCase;
use Scape\Config;
use Scape\Interpreter\Interpreter;
use Scape\Parser\Node\ForeachNode;
use Scape\Parser\Node\TextNode;
use Scape\Parser\Node\VariableNode;
use Scape\Exceptions\PropertyNotFoundException;
class InterpreterTest extends TestCase
{
private Interpreter $interpreter;
private Config $config;
protected function setUp(): void
{
$this->config = new Config(['mode' => 'production']);
$this->interpreter = new Interpreter($this->config);
}
public function testRendersText(): void
{
$nodes = [new TextNode("Hello World", 1)];
$this->assertEquals("Hello World", $this->interpreter->interpret($nodes, []));
}
public function testRendersVariable(): void
{
$nodes = [new VariableNode("{{ name }}", "name", [], false, 1)];
$this->assertEquals("Phred", $this->interpreter->interpret($nodes, ['name' => 'Phred']));
}
public function testEscapesVariable(): void
{
$nodes = [new VariableNode("{{ name }}", "name", [], false, 1)];
$this->assertEquals("&lt;script&gt;", $this->interpreter->interpret($nodes, ['name' => '<script>']));
}
public function testRendersRawVariable(): void
{
$nodes = [new VariableNode("{{{ name }}}", "name", [], true, 1)];
$this->assertEquals("<script>", $this->interpreter->interpret($nodes, ['name' => '<script>']));
}
public function testRendersNestedVariable(): void
{
$nodes = [new VariableNode("{{ user.name }}", "user.name", [], false, 1)];
$this->assertEquals("Phred", $this->interpreter->interpret($nodes, ['user' => ['name' => 'Phred']]));
}
public function testRendersBracketVariable(): void
{
$nodes = [new VariableNode("{{ items[0] }}", "items[0]", [], false, 1)];
$this->assertEquals("First", $this->interpreter->interpret($nodes, ['items' => ['First', 'Second']]));
}
public function testMissingVariableInProductionReturnsEmpty(): void
{
$nodes = [new VariableNode("{{ missing }}", "missing", [], false, 1)];
$this->assertEquals("", $this->interpreter->interpret($nodes, []));
}
public function testMissingVariableInDebugThrowsException(): void
{
$config = new Config(['mode' => 'debug']);
$interpreter = new Interpreter($config);
$nodes = [new VariableNode("{{ missing }}", "missing", [], false, 1)];
$this->expectException(PropertyNotFoundException::class);
$interpreter->interpret($nodes, []);
}
public function testRendersForeach(): void
{
$nodes = [
new ForeachNode(
"items",
"item",
null,
[
new VariableNode("{{ item }}", "item", [], false, 1),
new TextNode(" ", 1)
],
1
)
];
$this->assertEquals("A B C ", $this->interpreter->interpret($nodes, ['items' => ['A', 'B', 'C']]));
}
public function testRendersForeachWithKey(): void
{
$nodes = [
new ForeachNode(
"items",
"item",
"key",
[
new VariableNode("{{ key }}", "key", [], false, 1),
new TextNode(":", 1),
new VariableNode("{{ item }}", "item", [], false, 1),
new TextNode(" ", 1)
],
1
)
];
$this->assertEquals("a:1 b:2 ", $this->interpreter->interpret($nodes, ['items' => ['a' => 1, 'b' => 2]]));
}
public function testForeachPositionalRendering(): void
{
$nodes = [
new ForeachNode(
"items",
"item",
null,
[new VariableNode("{{ item }}", "item", [], false, 1)],
1,
[new TextNode("[FIRST]", 1)],
[new TextNode("[INNER]", 1)],
[new TextNode("[LAST]", 1)]
)
];
// 3 items:
// 1: [FIRST]A
// 2: [INNER]B
// 3: [LAST]C
$this->assertEquals("[FIRST]A[INNER]B[LAST]C", $this->interpreter->interpret($nodes, ['items' => ['A', 'B', 'C']]));
// 2 items:
// 1: [FIRST]A
// 2: [LAST]B
$this->assertEquals("[FIRST]A[LAST]B", $this->interpreter->interpret($nodes, ['items' => ['A', 'B']]));
// 1 item:
// 1: [FIRST][LAST]A
$this->assertEquals("[FIRST][LAST]A", $this->interpreter->interpret($nodes, ['items' => ['A']]));
}
}

105
tests/LexerTest.php Normal file
View file

@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace Scape\Tests;
use PHPUnit\Framework\TestCase;
use Scape\Parser\Lexer;
use Scape\Parser\TokenType;
class LexerTest extends TestCase
{
public function testTokenizesSimpleText(): void
{
$lexer = new Lexer("Hello World");
$tokens = $lexer->tokenize();
$this->assertCount(2, $tokens); // TEXT + EOF
$this->assertEquals(TokenType::TEXT, $tokens[0]->type);
$this->assertEquals("Hello World", $tokens[0]->value);
}
public function testTokenizesInterpolation(): void
{
$lexer = new Lexer("{{ var }}");
$tokens = $lexer->tokenize();
$this->assertCount(4, $tokens); // START, TEXT, END, EOF
$this->assertEquals(TokenType::INTERPOLATION_START, $tokens[0]->type);
$this->assertEquals(TokenType::TEXT, $tokens[1]->type);
$this->assertEquals(" var ", $tokens[1]->value);
$this->assertEquals(TokenType::INTERPOLATION_END, $tokens[2]->type);
}
public function testTokenizesRawInterpolation(): void
{
$lexer = new Lexer("{{{ raw }}}");
$tokens = $lexer->tokenize();
$this->assertCount(4, $tokens);
$this->assertEquals(TokenType::RAW_START, $tokens[0]->type);
$this->assertEquals(" raw ", $tokens[1]->value);
$this->assertEquals(TokenType::RAW_END, $tokens[2]->type);
}
public function testTokenizesLogicTags(): void
{
$lexer = new Lexer("{( foreach item in items )}content{( endforeach )}");
$tokens = $lexer->tokenize();
$this->assertEquals(TokenType::LOGIC_START, $tokens[0]->type);
$this->assertEquals(" foreach item in items ", $tokens[1]->value);
$this->assertEquals(TokenType::LOGIC_END, $tokens[2]->type);
$this->assertEquals("content", $tokens[3]->value);
}
public function testLogicTagConsumesTrailingNewline(): void
{
// Logic tag followed by newline should consume the newline
$lexer = new Lexer("{( foreach )}\nLine 2");
$tokens = $lexer->tokenize();
// 0: START, 1: TEXT(" foreach "), 2: END, 3: TEXT("Line 2"), 4: EOF
$this->assertEquals("Line 2", $tokens[3]->value);
$this->assertEquals(TokenType::TEXT, $tokens[3]->type);
$this->assertEquals(2, $tokens[3]->line);
}
public function testLogicTagConsumesTrailingNewlineWithHeredoc(): void
{
$template = <<<TEMPLATE
{( foreach )}
Line 2
TEMPLATE;
$lexer = new Lexer($template);
$tokens = $lexer->tokenize();
// 0: START, 1: TEXT(" foreach "), 2: END, 3: TEXT("Line 2"), 4: EOF
$this->assertEquals("Line 2", $tokens[3]->value);
$this->assertEquals(TokenType::TEXT, $tokens[3]->type);
$this->assertEquals(2, $tokens[3]->line);
}
public function testBlockTagConsumesTrailingNewline(): void
{
$lexer = new Lexer("{[ extends 'layout' ]}\nContent");
$tokens = $lexer->tokenize();
$this->assertEquals("Content", $tokens[3]->value);
}
public function testLineNumberTracking(): void
{
$template = "Line 1\n{{ var }}\nLine 3";
$lexer = new Lexer($template);
$tokens = $lexer->tokenize();
$this->assertEquals(1, $tokens[0]->line); // "Line 1\n"
$this->assertEquals(2, $tokens[1]->line); // "{{"
$this->assertEquals(2, $tokens[2]->line); // " var "
$this->assertEquals(2, $tokens[3]->line); // "}}"
$this->assertEquals(3, $tokens[4]->line); // "\nLine 3"
}
}

View file

@ -1,85 +0,0 @@
<?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);
}
}

View file

@ -1,65 +0,0 @@
<?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);
}
}

View file

@ -1,50 +0,0 @@
<?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]);
}
}

186
tests/ParserTest.php Normal file
View file

@ -0,0 +1,186 @@
<?php
declare(strict_types=1);
namespace Scape\Tests;
use PHPUnit\Framework\TestCase;
use Scape\Parser\Lexer;
use Scape\Parser\Parser;
use Scape\Parser\Node\TextNode;
use Scape\Parser\Node\VariableNode;
use Scape\Parser\Node\ForeachNode;
use Scape\Parser\Node\BlockNode;
use Scape\Parser\Node\ExtendsNode;
use Scape\Parser\Node\IncludeNode;
use Scape\Parser\Node\FilterLoadNode;
use Scape\Parser\Node\ParentNode;
class ParserTest extends TestCase
{
public function testParsesSimpleText(): void
{
$lexer = new Lexer("Hello World");
$parser = new Parser($lexer->tokenize());
$nodes = $parser->parse();
$this->assertCount(1, $nodes);
$this->assertInstanceOf(TextNode::class, $nodes[0]);
$this->assertEquals("Hello World", $nodes[0]->content);
}
public function testParsesInterpolation(): void
{
$lexer = new Lexer("{{ var }}");
$parser = new Parser($lexer->tokenize());
$nodes = $parser->parse();
$this->assertCount(1, $nodes);
$this->assertInstanceOf(VariableNode::class, $nodes[0]);
$this->assertEquals("var", $nodes[0]->path);
$this->assertFalse($nodes[0]->isRaw);
}
public function testParsesInterpolationWithFilters(): void
{
$template = "{{ price | currency('USD') | lower }}";
$lexer = new Lexer($template);
$parser = new Parser($lexer->tokenize());
$nodes = $parser->parse();
$this->assertInstanceOf(VariableNode::class, $nodes[0]);
$this->assertEquals("price", $nodes[0]->path);
$this->assertCount(2, $nodes[0]->filters);
$this->assertEquals("currency", $nodes[0]->filters[0]['name']);
$this->assertEquals(["'USD'"], $nodes[0]->filters[0]['args']);
$this->assertEquals("lower", $nodes[0]->filters[1]['name']);
}
public function testParsesForeach(): void
{
$template = "{( foreach item in items )} {{ item }} {( endforeach )}";
$lexer = new Lexer($template);
$parser = new Parser($lexer->tokenize());
$nodes = $parser->parse();
$this->assertCount(1, $nodes);
$this->assertInstanceOf(ForeachNode::class, $nodes[0]);
$this->assertEquals("items", $nodes[0]->collection);
$this->assertEquals("item", $nodes[0]->valueVar);
$this->assertNull($nodes[0]->keyVar);
$this->assertCount(3, $nodes[0]->body); // Space, Variable, Space
}
public function testParsesForeachWithKey(): void
{
$template = "{( foreach k, v in items )}{( endforeach )}";
$lexer = new Lexer($template);
$parser = new Parser($lexer->tokenize());
$nodes = $parser->parse();
$this->assertInstanceOf(ForeachNode::class, $nodes[0]);
$this->assertEquals("k", $nodes[0]->keyVar);
$this->assertEquals("v", $nodes[0]->valueVar);
}
public function testParsesForeachWithPositionalTags(): void
{
$template = "{( foreach item in items )}{( first )}FIRST{( endfirst )}{( inner )}INNER{( endinner )}{( last )}LAST{( endlast )}{( endforeach )}";
$lexer = new Lexer($template);
$parser = new Parser($lexer->tokenize());
$nodes = $parser->parse();
$node = $nodes[0];
$this->assertInstanceOf(ForeachNode::class, $node);
$this->assertCount(1, $node->first);
$this->assertInstanceOf(TextNode::class, $node->first[0]);
$this->assertEquals("FIRST", $node->first[0]->content);
$this->assertCount(1, $node->inner);
$this->assertEquals("INNER", $node->inner[0]->content);
$this->assertCount(1, $node->last);
$this->assertEquals("LAST", $node->last[0]->content);
}
public function testParsesExtends(): void
{
$template = "{[ extends 'layout.main' ]}";
$lexer = new Lexer($template);
$parser = new Parser($lexer->tokenize());
$nodes = $parser->parse();
$this->assertInstanceOf(ExtendsNode::class, $nodes[0]);
$this->assertEquals("layout.main", $nodes[0]->path);
}
public function testParsesBlock(): void
{
$template = "{[ block 'content' ]}Block Content{[ endblock ]}";
$lexer = new Lexer($template);
$parser = new Parser($lexer->tokenize());
$nodes = $parser->parse();
$this->assertInstanceOf(BlockNode::class, $nodes[0]);
$this->assertEquals("content", $nodes[0]->name);
$this->assertCount(1, $nodes[0]->body);
$this->assertEquals("Block Content", $nodes[0]->body[0]->content);
}
public function testParsesInclude(): void
{
$template = "{[ include 'partials.nav' ]}";
$lexer = new Lexer($template);
$parser = new Parser($lexer->tokenize());
$nodes = $parser->parse();
$this->assertInstanceOf(IncludeNode::class, $nodes[0]);
$this->assertEquals("partials.nav", $nodes[0]->path);
$this->assertNull($nodes[0]->context);
}
public function testParsesIncludeWithContext(): void
{
$template = "{[ include 'partials.user' with user_data ]}";
$lexer = new Lexer($template);
$parser = new Parser($lexer->tokenize());
$nodes = $parser->parse();
$this->assertInstanceOf(IncludeNode::class, $nodes[0]);
$this->assertEquals("user_data", $nodes[0]->context);
}
public function testParsesParent(): void
{
$template = "{[ parent ]}";
$lexer = new Lexer($template);
$parser = new Parser($lexer->tokenize());
$nodes = $parser->parse();
$this->assertInstanceOf(ParentNode::class, $nodes[0]);
}
public function testParsesUses(): void
{
$template = "{( uses filters:string )}";
$lexer = new Lexer($template);
$parser = new Parser($lexer->tokenize());
$nodes = $parser->parse();
$this->assertInstanceOf(FilterLoadNode::class, $nodes[0]);
$this->assertTrue($nodes[0]->isInternal);
$this->assertEquals("filters:string", $nodes[0]->path);
}
public function testParsesLoadFilter(): void
{
$template = "{( load_filter('custom.my_filter') )}";
$lexer = new Lexer($template);
$parser = new Parser($lexer->tokenize());
$nodes = $parser->parse();
$this->assertInstanceOf(FilterLoadNode::class, $nodes[0]);
$this->assertFalse($nodes[0]->isInternal);
$this->assertEquals("custom.my_filter", $nodes[0]->path);
}
}

10
tests/fixtures/filters/currency.php vendored Normal file
View file

@ -0,0 +1,10 @@
<?php
use Scape\Interfaces\FilterInterface;
return new class implements FilterInterface {
public function transform(mixed $value, array $args = []): mixed {
$symbol = $args[0] ?? '$';
return $symbol . number_format((float)$value, 2);
}
};

10
tests/fixtures/layouts/base.scape.php vendored Normal file
View file

@ -0,0 +1,10 @@
<html>
<head><title>{[ block 'title' ]}Default Title{[ endblock ]}</title></head>
<body>
<header><h1>Site Header</h1></header>
<main>
{[ block 'content' ]}Default Content{[ endblock ]}
</main>
<footer>{[ block 'footer' ]}&copy; 2026{[ endblock ]}</footer>
</body>
</html>

View file

@ -0,0 +1 @@
Hello {{ name }}!

View file

@ -0,0 +1 @@
{[ include 'recursive' ]}

View file

@ -0,0 +1,2 @@
Name: {{ name }}
ID: {{ id }}

10
tests/fixtures/tests/child.scape.php vendored Normal file
View file

@ -0,0 +1,10 @@
{[ extends 'base' ]}
{[ block 'title' ]}Child Page - {{ name }}{[ endblock ]}
{[ block 'content' ]}
<h2>Welcome</h2>
<p>This is the content for {{ name }}.</p>
{[ block 'nested' ]}Nested default{[ endblock ]}
{[ endblock ]}
{[ block 'footer' ]}
{[ parent ]} - Custom Footer
{[ endblock ]}

View file

@ -0,0 +1,6 @@
Include test:
{[ include 'hello' with ['name' => 'Funky'] ]}
Context test:
{[ include 'user_info' with context ]}
Data source test:
{[ include 'user_info' with person ]}

4
tests/fixtures/tests/simple.scape.php vendored Normal file
View file

@ -0,0 +1,4 @@
Hello {{ name }}!
{( foreach item in items )}
- {{ item }}
{( endforeach )}