Compare commits
7 commits
c137333ead
...
ef58018697
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef58018697 | ||
|
|
9697400c0c | ||
|
|
439e4b99fb | ||
|
|
10aac20afb | ||
|
|
7c4d5518dd | ||
|
|
50bbd4ef5c | ||
|
|
d3c8688f24 |
8
.gitattributes
vendored
Normal file
8
.gitattributes
vendored
Normal 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
5
.gitignore
vendored
|
|
@ -10,5 +10,6 @@ crashlytics-build.properties
|
|||
fabric.properties
|
||||
composer.phar
|
||||
/vendor/
|
||||
.junie/
|
||||
.phpunit.cache/
|
||||
.junie
|
||||
.phpunit.cache/
|
||||
.scape
|
||||
|
|
@ -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 harassment‑free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio‑economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment include:
|
||||
- Using welcoming and inclusive language
|
||||
- Being respectful of differing viewpoints and experiences
|
||||
- Gracefully accepting constructive criticism
|
||||
- Focusing on what is best for the community
|
||||
- Showing empathy towards other community members
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
- The use of sexualized language or imagery and unwelcome sexual attention or advances
|
||||
- Trolling, insulting/derogatory comments, and personal or political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others’ private information without explicit permission
|
||||
- Other conduct which could reasonably be considered inappropriate in a professional setting
|
||||
|
||||
## Responsibilities and Enforcement
|
||||
|
||||
Project maintainers are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
|
||||
|
||||
Maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to temporarily or permanently ban any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all project spaces and also applies when an individual is officially representing the project in public spaces.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the project team at conduct@your-domain.example (replace with your contact). All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All maintainers are obligated to respect the privacy and security of the reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
1. Correction — A private, written warning with clarity around the nature of the violation and an explanation of why the behavior was inappropriate.
|
||||
2. Warning — A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time.
|
||||
3. Temporary Ban — A temporary ban from any sort of interaction or public communication with the community for a specified period of time.
|
||||
4. Permanent Ban — A permanent ban from any sort of public interaction within the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 2.1.
|
||||
|
|
@ -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 repository’s LICENSE.
|
||||
19
LOG.md
Normal file
19
LOG.md
Normal 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.
|
||||
100
MILESTONES.md
100
MILESTONES.md
|
|
@ -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
193
README.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
49
SECURITY.md
49
SECURITY.md
|
|
@ -1,49 +0,0 @@
|
|||
# Security Policy (Draft)
|
||||
|
||||
## Supported versions
|
||||
|
||||
- Until first stable release, only the latest minor/patch is supported.
|
||||
- Post‑1.0: last two minor versions receive security fixes.
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
- Please email security reports to: security@your-domain.example (replace with your contact)
|
||||
- Provide a minimal reproduction, affected version, environment details, and impact if possible.
|
||||
- We aim to acknowledge within 3 business days and provide a timeline after triage.
|
||||
|
||||
## Disclosure process
|
||||
|
||||
1. Private triage and fix development.
|
||||
2. Coordinated disclosure with reporter; optional CVE request if applicable.
|
||||
3. Security release notes summarizing impact, severity, and upgrade guidance.
|
||||
|
||||
## Secure development guidelines
|
||||
|
||||
- Auto‑escape by default; minimize usage of `safe` and review all instances.
|
||||
- No dynamic eval; no template‑driven file/network access.
|
||||
- Validate and normalize template names; prohibit `..` traversal and absolute paths unless namespaced.
|
||||
- Keep clear separation: helpers/filters are whitelisted and reviewed.
|
||||
- Enforce depth/iteration/time and size limits with safe defaults.
|
||||
- Prefer exceptions with sanitized messages over warnings or silent failures.
|
||||
- Components:
|
||||
- Register components explicitly; unregistered components must not be invokable.
|
||||
- Treat component props as untrusted input; validate types and ranges before use.
|
||||
- Components must not perform filesystem/network access by default and must not mutate outer template context.
|
||||
- Cap component recursion/nesting depth to avoid DoS.
|
||||
|
||||
## Secrets and sensitive data
|
||||
|
||||
- Do not pass secrets (tokens, passwords) through template contexts unless absolutely necessary; prefer redacted representations.
|
||||
- Ensure logs and error messages do not include raw context values in production.
|
||||
|
||||
## Dependencies and updates
|
||||
|
||||
- Target PHP 8.1+ (finalize at release); use supported versions only.
|
||||
- Pin constraints to secure versions; audit dependencies regularly (e.g., `composer audit`).
|
||||
- Security fixes are backported per Supported versions policy.
|
||||
|
||||
## Hardening recommendations for deployers
|
||||
|
||||
- Place cache directory outside web root with perms `0700`.
|
||||
- Run PHP under least‑privilege account; restrict template directories to read‑only for the runtime.
|
||||
- Disable display of errors in production; enable structured logging.
|
||||
393
SPECS.md
393
SPECS.md
|
|
@ -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 server‑side templating for PHP apps in the Phred ecosystem.
|
||||
- Separation of concerns: business logic in PHP, presentation logic in templates.
|
||||
- Security by default via auto‑escaping with contextual modes.
|
||||
- Predictable, minimal syntax optimized for HTML.
|
||||
- DRY composition with inheritance and partials.
|
||||
## 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. Non‑Goals
|
||||
|
||||
- No arbitrary code execution in templates; no general programming.
|
||||
- No dynamic variable creation inside templates (only loop counters).
|
||||
- No while loops or unbounded iteration constructs.
|
||||
- No filesystem or network access from templates.
|
||||
|
||||
## 3. Installation
|
||||
|
||||
- Composer package: `composer require getphred/eyrie`
|
||||
|
||||
### 3.1 Engine configuration
|
||||
|
||||
- Directory roots can be configured for Layouts, Pages, Partials, and Components.
|
||||
- Template names are referenced using dot-notation relative to their configured roots and do not include file extensions.
|
||||
- Default template file extension: `.eyrie.php`.
|
||||
|
||||
## 4. Core concepts
|
||||
|
||||
- Template: Text file (typically HTML) with Eyrie syntax.
|
||||
- Context: Associative array passed at render time.
|
||||
- Page: A top‑level template representing a view. Pages may extend a Layout and fill named Blocks.
|
||||
- Layout: A template that defines the overarching structure (shell) and exposes named Blocks with optional defaults.
|
||||
- Block: A named content region. Pages/components can override block content; `[[ super ]]` can include parent content.
|
||||
- Component: A reusable, tag‑like unit rendered with a custom element syntax (e.g., `<@ Movie id="1" />`). Components encapsulate their own rendering and accept props.
|
||||
- Props: Data passed to components (and optionally pages) akin to attributes; values flow from the rendering context and are subject to escaping.
|
||||
- Helper: Whitelisted callable exposed to templates.
|
||||
- Filter: Unary transformation applied via `|` pipe.
|
||||
- Tag: Control structure/directive (loops, conditionals, includes, etc.).
|
||||
- Loader: Resolves template names to sources.
|
||||
- Cache: Compiled/intermediate representation for speed.
|
||||
|
||||
## 5. Syntax overview
|
||||
|
||||
- Output: `<< expression >>` prints value with auto‑escaping.
|
||||
- Calls/control: control blocks start with `<(` and end with `)>`; examples use `<( if ... )>` / `<( endif )>`.
|
||||
- Blocks/extends: `[[ ... ]]` for inheritance, blocks, includes.
|
||||
- Components: `<@ ComponentName prop1="value" prop2={ expr } />` self‑closing. Support for children/content projection is TBD.
|
||||
|
||||
### 5.1 Expressions
|
||||
|
||||
- Property/array access: `user.name`, `order.items[0]`.
|
||||
- Literals: strings, numbers, booleans, null.
|
||||
- Operators: arithmetic, comparison, logical, ternary.
|
||||
- Filters: `value | lower | escape('attr') | ...`.
|
||||
|
||||
### 5.2 Output examples
|
||||
|
||||
- Basic: `<< user.name >>`
|
||||
- With filters: `<< title | upper >>`
|
||||
- Raw (discouraged): `<< user.bio | raw >>`
|
||||
|
||||
### 5.3 Control structures (inside `<( ... )>`)
|
||||
|
||||
- If/elseif/else/end:
|
||||
- `<( if cond )>` ... `<( elseif other )>` ... `<( else )>` ... `<( endif )>`
|
||||
- Foreach:
|
||||
- `<( foreach item in items )>` ... `<( endforeach )>`
|
||||
- Loop vars (read‑only): `item`
|
||||
- Loop (range/repeat):
|
||||
- `<( loop from 0 to 10 )>` ... `<( endloop )>`
|
||||
- Loop vars (read‑only): `loop.index` (int), `loop.first` (bool), `loop.last` (bool), `loop.length` (int)
|
||||
|
||||
### 5.4 Inheritance and blocks (inside `[[ ... ]]`)
|
||||
|
||||
- Extends: `[[ extends "layouts.base" ]]` (dot-notated paths, final part is parent file)
|
||||
- Extends syntax does not include the layout file extension.
|
||||
- Extends must be the first line of a template file.
|
||||
- Declare/override:
|
||||
- `[[ block content ]] ... [[ endblock ]]`
|
||||
- `[[ super ]]` inside override to include parent content
|
||||
- Block context (optional): `[[ block sidebar with { user: user } ]]`
|
||||
|
||||
### 5.5 Partials
|
||||
- Basic Include: `[[ include "partials.footer" ]]`
|
||||
- Include with optional context: `[[ include "partials.footer" with { x: 1 } ]]` (dot-notated paths, final part is partial file)
|
||||
- Include syntax does not include the partial file extension.
|
||||
|
||||
### 5.6 Components
|
||||
|
||||
- Tag‑based components are referenced by PascalCase names and invoked with the `<@` prefix: `<@ Movie id="1" />`.
|
||||
- Props:
|
||||
- String literal: `<@ Movie title="Jaws" />`
|
||||
- Expression: `<@ Movie rating={ movie.rating } />`
|
||||
- Boolean shorthand: `<@ Movie featured />` → `featured=true`
|
||||
- Registration: components are registered in PHP (see API). Unregistered component names are a `SyntaxError` (configurable to `RuntimeError`).
|
||||
- Auto‑escaping: Component outputs are escaped by context unless a component deliberately returns a `SafeHtml`‑like value.
|
||||
- Context: Components receive props plus a limited view of the parent context (configurable); they do not mutate outer context.
|
||||
- Layout/block interaction: Components can be used within Layouts and Pages but do not extend layouts themselves, and they do not define or fill block areas.
|
||||
|
||||
### 5.7 Built‑in tags
|
||||
|
||||
- `if/elseif/else/endif`, `foreach/endforeach`, `loop/endloop`, `include`, `extends`, `block/endblock`, `super`
|
||||
|
||||
### 5.8 Helpers and filters
|
||||
|
||||
- Helpers are registered by name in PHP and callable in `<( ... )>` or `<< >>`, e.g. `<< route('home') >>`.
|
||||
- Filters chain with pipes, e.g. `<< text | truncate(120) | escape('attr') >>`.
|
||||
|
||||
## 6. Auto‑escaping
|
||||
|
||||
- Enabled by default for all `<< >>` outputs.
|
||||
- Modes: `html` (default), `attr`, `url`, `js`.
|
||||
- `escape(mode)` switches mode; `safe` marks trusted values to bypass escaping.
|
||||
- Escaping happens after filters unless `safe` present.
|
||||
- Component rendering occurs under the current escaping mode of the insertion site.
|
||||
|
||||
## 7. Template loaders
|
||||
|
||||
- FilesystemLoader: rooted directories, normalized paths, no `..` traversal.
|
||||
- Names are dot‑notated within configured roots and omit the file extension (default `.eyrie.php`).
|
||||
- Optional namespaces (if supported): `@emails.welcome` → resolved under the `emails` root.
|
||||
- Future: StringLoader, ArrayLoader.
|
||||
|
||||
## 8. Caching
|
||||
|
||||
- Optional compiled cache keyed by template name + engine version + config hash.
|
||||
- Stores: filesystem (default), PSR‑16 (future).
|
||||
- Defaults: dir perms 0700; atomic writes; checksum integrity.
|
||||
|
||||
## 9. Public PHP API (draft)
|
||||
|
||||
```php
|
||||
interface Loader {
|
||||
public function getSource(string $name): string;
|
||||
public function getCacheKey(string $name): string; // stable per source
|
||||
public function exists(string $name): bool;
|
||||
}
|
||||
|
||||
final class Environment {
|
||||
public function __construct(Loader $loader, array $options = []) {}
|
||||
public function addHelper(string $name, callable $helper): void {}
|
||||
public function addFilter(string $name, callable $filter): void {}
|
||||
/** Register a component renderer by tag name, e.g., 'Movie' */
|
||||
public function addComponent(string $name, ComponentRenderer $component): void {}
|
||||
public function render(string $name, array $context = []): string {}
|
||||
public function compile(string $name): CompiledTemplate {}
|
||||
}
|
||||
|
||||
/** Minimal component contract (draft) */
|
||||
interface ComponentRenderer {
|
||||
/**
|
||||
* @param array $props Props/attributes supplied in the template
|
||||
* @param array $context A read‑only view of the current render context
|
||||
* @return mixed A string (escaped later) or a SafeHtml‑like value
|
||||
*/
|
||||
public function render(array $props, array $context = []): mixed;
|
||||
}
|
||||
|
||||
final class CompiledTemplate {
|
||||
public function render(array $context = []): string {}
|
||||
}
|
||||
## 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.
|
||||
|
||||
- Auto‑escape by default; `safe` is explicit opt‑in.
|
||||
- No arbitrary PHP execution; only whitelisted helpers/filters.
|
||||
- Loader prevents traversal; only configured roots.
|
||||
- Sandboxed evaluation; no eval/reflection.
|
||||
- Depth/iteration limits; template size/token limits.
|
||||
- Restrictive cache dir permissions; validated paths.
|
||||
- Components are pure render units: no filesystem/network access; no mutation of outer context; bounded recursion/depth.
|
||||
### 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.
|
||||
- PSR‑3 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).
|
||||
- UTF‑8 for sources and output.
|
||||
|
||||
## 15. Examples
|
||||
|
||||
Base `base.eyrie.php`:
|
||||
|
||||
```html
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title><< title | escape('html') >></title>
|
||||
</head>
|
||||
<body>
|
||||
<header>[[ block header ]]Default Header[[ endblock ]]</header>
|
||||
<main>[[ block content ]][[ endblock ]]</main>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
Child `home.eyrie.php`:
|
||||
|
||||
```html
|
||||
[[ extends "base" ]]
|
||||
|
||||
[[ block content ]]
|
||||
<h1>Hello, << user.name >>!</h1>
|
||||
<ul>
|
||||
<( foreach item in items )>
|
||||
<li><< loop.index >>. << item.title | escape('attr') >></li>
|
||||
<( endforeach )>
|
||||
</ul>
|
||||
[[ endblock ]]
|
||||
```
|
||||
|
||||
Component usage example:
|
||||
|
||||
```html
|
||||
[[ extends "base" ]]
|
||||
|
||||
[[ block content ]]
|
||||
<h2>Featured</h2>
|
||||
<@ Movie id={ featured.id } title={ featured.title } featured />
|
||||
|
||||
<ul>
|
||||
<( foreach m in movies )>
|
||||
<li>
|
||||
<@ Movie id={ m.id } title={ m.title } rating={ m.rating } />
|
||||
</li>
|
||||
<( endforeach )>
|
||||
</ul>
|
||||
[[ endblock ]]
|
||||
```
|
||||
|
||||
## 16. Open questions
|
||||
|
||||
- Final grammar (EBNF) for expressions/tags.
|
||||
- Built‑in filters/helpers set.
|
||||
- Parser error recovery strategy.
|
||||
- Safe value interface semantics.
|
||||
- Component children/content projection: do we allow `<Card>...children...</Card>`?
|
||||
- Prop type checking and defaulting strategy.
|
||||
## Project Dependencies
|
||||
- **PHP**: ^8.2
|
||||
- **PHPUnit**: ^10.0 (Development)
|
||||
|
|
|
|||
100
THREAT_MODEL.md
100
THREAT_MODEL.md
|
|
@ -1,100 +0,0 @@
|
|||
# Eyrie Templates — Threat Model (Draft)
|
||||
|
||||
Purpose: identify assets, threats, and controls relevant to a server‑side PHP templating engine and define baseline mitigations.
|
||||
|
||||
## 1. Assets
|
||||
|
||||
- Template sources (files and compiled cache outputs)
|
||||
- Rendered HTML output delivered to clients
|
||||
- Template context data (user data, secrets if accidentally passed)
|
||||
- Component definitions/renderers and their configuration
|
||||
- Helper/filter registry and configuration
|
||||
- Loader configuration (roots, namespaces)
|
||||
- Cache directory and compiled artifacts
|
||||
- Error logs and diagnostics
|
||||
|
||||
## 2. Actors
|
||||
|
||||
- Application developers (trusted, may misconfigure)
|
||||
- End users (untrusted input surfaces)
|
||||
- Attackers supplying malicious input
|
||||
- System administrators (manage deployment and FS perms)
|
||||
|
||||
## 3. Trust boundaries
|
||||
|
||||
- Between untrusted user input and template rendering
|
||||
- Between template engine and filesystem (loaders, cache)
|
||||
- Between engine and helper/filter callables (application code)
|
||||
- Between dev and prod environments (verbosity, paths, timings)
|
||||
|
||||
## 4. Entry points and attack surfaces
|
||||
|
||||
- Template variables/expressions from request‑derived data (XSS)
|
||||
- Helper/filter parameters (command/code injection via helpers)
|
||||
- Component props/attributes (injection vectors; type confusion)
|
||||
- Template name resolution (path traversal, namespace bypass)
|
||||
- Include/extends directives (recursive includes, deep inheritance)
|
||||
- Component trees (deep recursion/nesting)
|
||||
- Large templates or pathological inputs (parser/render DoS)
|
||||
- Cache poisoning or disclosure (incorrect perms or keying)
|
||||
- Error pages and stack traces (info leakage)
|
||||
|
||||
## 5. Threats and mitigations
|
||||
|
||||
- Reflected/stored XSS in output
|
||||
- Default auto‑escaping with context modes (`html`, `attr`, `url`, `js`)
|
||||
- `safe` must be explicit and narrowly scoped
|
||||
- Security lint/checks to flag `safe` usage
|
||||
- Path traversal via loader
|
||||
- Normalize and resolve paths; reject `..` and absolute paths unless mapped
|
||||
- Restrict to configured roots; support namespaces with fixed roots
|
||||
- Code execution via helpers/filters
|
||||
- Only whitelisted callables registered by application
|
||||
- No evaluation of template strings as PHP; no reflection access
|
||||
- Optionally sandbox helpers with contracts and safe value types
|
||||
- Code execution or SSRF via component renderers
|
||||
- Components implement a constrained interface; no filesystem/network access by default
|
||||
- Validate and sanitize props before use; avoid passing raw props to sinks
|
||||
- Enforce a strict registry: only explicitly added components are callable
|
||||
- DoS via deep inheritance/recursion or huge loops
|
||||
- Limits: include/extends depth; loop iteration caps; template/token size limits
|
||||
- Watchdog timeouts for parse/render
|
||||
- Component nesting/recursion depth caps and per‑render time budgets
|
||||
- Cache tampering/leakage
|
||||
- Cache dir perms 0700; atomic writes; validate cache keys (include engine version and options)
|
||||
- Avoid executing cache files; treat as data
|
||||
- Information disclosure via errors
|
||||
- Configurable verbosity; hide paths/snippets in production
|
||||
- Structured error types without sensitive context values
|
||||
- SSRF/RFI via helpers/filters
|
||||
- No network/file IO in templates; helpers must not fetch remote resources by default
|
||||
- Apply same restrictions to components
|
||||
|
||||
## 6. Assumptions
|
||||
|
||||
- Templates are developer‑authored and trusted; user‑generated templates are out of scope.
|
||||
- Application controls helper registrations; helpers conform to safe contracts.
|
||||
- Deployment applies standard OS hardening (FS perms, no public cache dirs).
|
||||
|
||||
## 7. Security requirements (binding)
|
||||
|
||||
- Auto‑escaping enabled by default and cannot be globally disabled in production builds
|
||||
- Loader prevents traversal; cannot escape configured roots
|
||||
- Depth/size/time limits are configurable with safe defaults
|
||||
- Distinct dev/prod modes, with safe prod defaults
|
||||
- No eval of template source; no direct PHP execution from templates
|
||||
- Components are pure render units; cannot mutate outer context; registered explicitly
|
||||
|
||||
## 8. Validation and testing
|
||||
|
||||
- XSS test corpus across HTML, attribute, JS, and URL contexts
|
||||
- Fuzz tests for parser stability and timeouts
|
||||
- Unit tests for loader normalization and traversal blocking
|
||||
- Integration tests for cache directory perms and keying
|
||||
- Component test suite for prop escaping, type validation, and recursion limits
|
||||
|
||||
## 9. Open items
|
||||
|
||||
- Define safe value wrapper interface
|
||||
- Decide on strict‑variables default (on/off)
|
||||
- Finalize default limits (include depth, loop max, template size)
|
||||
|
|
@ -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
31
composer.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
72
src/Config.php
Normal 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';
|
||||
}
|
||||
}
|
||||
464
src/Engine.php
464
src/Engine.php
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
16
src/Exceptions/FilterNotFoundException.php
Normal file
16
src/Exceptions/FilterNotFoundException.php
Normal 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
|
||||
{
|
||||
}
|
||||
16
src/Exceptions/PropertyNotFoundException.php
Normal file
16
src/Exceptions/PropertyNotFoundException.php
Normal 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
|
||||
{
|
||||
}
|
||||
16
src/Exceptions/RecursionLimitException.php
Normal file
16
src/Exceptions/RecursionLimitException.php
Normal 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
|
||||
{
|
||||
}
|
||||
18
src/Exceptions/ScapeException.php
Normal file
18
src/Exceptions/ScapeException.php
Normal 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
|
||||
{
|
||||
}
|
||||
16
src/Exceptions/SyntaxException.php
Normal file
16
src/Exceptions/SyntaxException.php
Normal 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
|
||||
{
|
||||
}
|
||||
16
src/Exceptions/TemplateNotFoundException.php
Normal file
16
src/Exceptions/TemplateNotFoundException.php
Normal 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
|
||||
{
|
||||
}
|
||||
36
src/Filters/CurrencyFilter.php
Normal file
36
src/Filters/CurrencyFilter.php
Normal 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);
|
||||
}
|
||||
}
|
||||
50
src/Filters/DateFilter.php
Normal file
50
src/Filters/DateFilter.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
33
src/Filters/DefaultFilter.php
Normal file
33
src/Filters/DefaultFilter.php
Normal 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;
|
||||
}
|
||||
}
|
||||
33
src/Filters/FirstFilter.php
Normal file
33
src/Filters/FirstFilter.php
Normal 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;
|
||||
}
|
||||
}
|
||||
33
src/Filters/FloatFilter.php
Normal file
33
src/Filters/FloatFilter.php
Normal 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, '.', ',');
|
||||
}
|
||||
}
|
||||
34
src/Filters/JoinFilter.php
Normal file
34
src/Filters/JoinFilter.php
Normal 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;
|
||||
}
|
||||
}
|
||||
27
src/Filters/JsonFilter.php
Normal file
27
src/Filters/JsonFilter.php
Normal 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);
|
||||
}
|
||||
}
|
||||
32
src/Filters/KeysFilter.php
Normal file
32
src/Filters/KeysFilter.php
Normal 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 [];
|
||||
}
|
||||
}
|
||||
35
src/Filters/LastFilter.php
Normal file
35
src/Filters/LastFilter.php
Normal 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;
|
||||
}
|
||||
}
|
||||
15
src/Filters/LowerFilter.php
Normal file
15
src/Filters/LowerFilter.php
Normal 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);
|
||||
}
|
||||
}
|
||||
35
src/Filters/TruncateFilter.php
Normal file
35
src/Filters/TruncateFilter.php
Normal 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;
|
||||
}
|
||||
}
|
||||
15
src/Filters/UcfirstFilter.php
Normal file
15
src/Filters/UcfirstFilter.php
Normal 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);
|
||||
}
|
||||
}
|
||||
15
src/Filters/UpperFilter.php
Normal file
15
src/Filters/UpperFilter.php
Normal 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);
|
||||
}
|
||||
}
|
||||
27
src/Filters/UrlEncodeFilter.php
Normal file
27
src/Filters/UrlEncodeFilter.php
Normal 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);
|
||||
}
|
||||
}
|
||||
27
src/Filters/WordCountFilter.php
Normal file
27
src/Filters/WordCountFilter.php
Normal 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);
|
||||
}
|
||||
}
|
||||
25
src/Interfaces/FilterInterface.php
Normal file
25
src/Interfaces/FilterInterface.php
Normal 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;
|
||||
}
|
||||
34
src/Interfaces/HostProviderInterface.php
Normal file
34
src/Interfaces/HostProviderInterface.php
Normal 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;
|
||||
}
|
||||
395
src/Interpreter/Interpreter.php
Normal file
395
src/Interpreter/Interpreter.php
Normal 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;
|
||||
}
|
||||
}
|
||||
300
src/Interpreter/ValueResolver.php
Normal file
300
src/Interpreter/ValueResolver.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 . " ]]";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 . " />";
|
||||
}
|
||||
}
|
||||
|
|
@ -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 . " >>";
|
||||
}
|
||||
}
|
||||
|
|
@ -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 . "\" ]]";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
28
src/Parser/Node/FilterLoadNode.php
Normal file
28
src/Parser/Node/FilterLoadNode.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 . " )>";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 . " )>";
|
||||
}
|
||||
}
|
||||
|
|
@ -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 . "\" ]]";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 . " )>";
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
20
src/Parser/Node/ParentNode.php
Normal file
20
src/Parser/Node/ParentNode.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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 ]]";
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
32
src/Parser/Node/VariableNode.php
Normal file
32
src/Parser/Node/VariableNode.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
102
tests/CacheTest.php
Normal 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
60
tests/ConfigTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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('<script>', $engine->render('escape', ['content' => '<script>']));
|
||||
}
|
||||
|
||||
public function testRenderIf(): void
|
||||
{
|
||||
$template = '
|
||||
<( if show )>
|
||||
Visible
|
||||
<( endif )>
|
||||
';
|
||||
file_put_contents($this->tempDir . '/if.eyrie.php', $template);
|
||||
$loader = new FileLoader([$this->tempDir]);
|
||||
$engine = new Engine($loader, ['cache' => $this->cacheDir]);
|
||||
|
||||
$this->assertStringContainsString('Visible', $engine->render('if', ['show' => true]));
|
||||
$this->assertStringNotContainsString('Visible', $engine->render('if', ['show' => false]));
|
||||
}
|
||||
|
||||
public function testRenderIfElse(): void
|
||||
{
|
||||
$template = '
|
||||
<( if show )>
|
||||
Yes
|
||||
<( else )>
|
||||
No
|
||||
<( endif )>
|
||||
';
|
||||
file_put_contents($this->tempDir . '/ifelse.eyrie.php', $template);
|
||||
$loader = new FileLoader([$this->tempDir]);
|
||||
$engine = new Engine($loader, ['cache' => $this->cacheDir]);
|
||||
|
||||
$this->assertStringContainsString('Yes', $engine->render('ifelse', ['show' => true]));
|
||||
$this->assertStringContainsString('No', $engine->render('ifelse', ['show' => false]));
|
||||
}
|
||||
|
||||
public function testRenderDotNotation(): void
|
||||
{
|
||||
file_put_contents($this->tempDir . '/dot.eyrie.php', '<< user.name >>');
|
||||
$loader = new FileLoader([$this->tempDir]);
|
||||
$engine = new Engine($loader, ['cache' => $this->cacheDir, 'debug' => true]);
|
||||
|
||||
$this->assertEquals('Junie', $engine->render('dot', ['user' => ['name' => 'Junie']]));
|
||||
}
|
||||
|
||||
public function testRenderForeach(): void
|
||||
{
|
||||
$template = '
|
||||
<( foreach item in items )>
|
||||
<< item >>
|
||||
<( endforeach )>
|
||||
';
|
||||
file_put_contents($this->tempDir . '/foreach.eyrie.php', $template);
|
||||
$loader = new FileLoader([$this->tempDir]);
|
||||
$engine = new Engine($loader, ['cache' => $this->cacheDir]);
|
||||
|
||||
$result = $engine->render('foreach', ['items' => ['A', 'B', 'C']]);
|
||||
$this->assertStringContainsString('A', $result);
|
||||
$this->assertStringContainsString('B', $result);
|
||||
$this->assertStringContainsString('C', $result);
|
||||
}
|
||||
|
||||
public function testRenderLoop(): void
|
||||
{
|
||||
$template = '
|
||||
<( loop from 1 to 3 )>
|
||||
<< loop.index >>:<< loop.last >>
|
||||
<( endloop )>
|
||||
';
|
||||
file_put_contents($this->tempDir . '/loop.eyrie.php', $template);
|
||||
$loader = new FileLoader([$this->tempDir]);
|
||||
$engine = new Engine($loader, ['cache' => $this->cacheDir]);
|
||||
|
||||
$result = $engine->render('loop');
|
||||
$this->assertStringContainsString('0:false', $result);
|
||||
$this->assertStringContainsString('1:false', $result);
|
||||
$this->assertStringContainsString('2:true', $result);
|
||||
}
|
||||
|
||||
public function testRenderInheritance(): void
|
||||
{
|
||||
file_put_contents($this->tempDir . '/layout.eyrie.php', '<html><body>[[ block content ]]Default[[ endblock ]]</body></html>');
|
||||
file_put_contents($this->tempDir . '/page.eyrie.php', '[[ extends "layout" ]] [[ block content ]]Page Content[[ endblock ]]');
|
||||
$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('© 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
144
tests/FilterTest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
48
tests/HostProviderTest.php
Normal file
48
tests/HostProviderTest.php
Normal 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 'welcome' to fr', $output);
|
||||
$this->assertStringContainsString('Var arg: Translated 'hello' to en', $output);
|
||||
|
||||
unlink($templatesDir . '/tests/host_test.scape.php');
|
||||
}
|
||||
}
|
||||
144
tests/InterpreterTest.php
Normal file
144
tests/InterpreterTest.php
Normal 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("<script>", $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
105
tests/LexerTest.php
Normal 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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
186
tests/ParserTest.php
Normal 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
10
tests/fixtures/filters/currency.php
vendored
Normal 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
10
tests/fixtures/layouts/base.scape.php
vendored
Normal 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' ]}© 2026{[ endblock ]}</footer>
|
||||
</body>
|
||||
</html>
|
||||
1
tests/fixtures/partials/hello.scape.php
vendored
Normal file
1
tests/fixtures/partials/hello.scape.php
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
Hello {{ name }}!
|
||||
1
tests/fixtures/partials/recursive.scape.php
vendored
Normal file
1
tests/fixtures/partials/recursive.scape.php
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
{[ include 'recursive' ]}
|
||||
2
tests/fixtures/partials/user_info.scape.php
vendored
Normal file
2
tests/fixtures/partials/user_info.scape.php
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
Name: {{ name }}
|
||||
ID: {{ id }}
|
||||
10
tests/fixtures/tests/child.scape.php
vendored
Normal file
10
tests/fixtures/tests/child.scape.php
vendored
Normal 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 ]}
|
||||
6
tests/fixtures/tests/include.scape.php
vendored
Normal file
6
tests/fixtures/tests/include.scape.php
vendored
Normal 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
4
tests/fixtures/tests/simple.scape.php
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
Hello {{ name }}!
|
||||
{( foreach item in items )}
|
||||
- {{ item }}
|
||||
{( endforeach )}
|
||||
Loading…
Reference in a new issue