diff --git a/.gitignore b/.gitignore index 27f0183..623aa48 100644 --- a/.gitignore +++ b/.gitignore @@ -1,87 +1,14 @@ -# ---> JetBrains -# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider -# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 - -# User-specific stuff -.idea/**/workspace.xml -.idea/**/tasks.xml -.idea/**/usage.statistics.xml -.idea/**/dictionaries -.idea/**/shelf - -# AWS User-specific -.idea/**/aws.xml - -# Generated files -.idea/**/contentModel.xml - -# Sensitive or high-churn files -.idea/**/dataSources/ -.idea/**/dataSources.ids -.idea/**/dataSources.local.xml -.idea/**/sqlDataSources.xml -.idea/**/dynamic.xml -.idea/**/uiDesigner.xml -.idea/**/dbnavigator.xml - -# Gradle -.idea/**/gradle.xml -.idea/**/libraries - -# Gradle and Maven with auto-import -# When using Gradle or Maven with auto-import, you should exclude module files, -# since they will be recreated, and may cause churn. Uncomment if using -# auto-import. -# .idea/artifacts -# .idea/compiler.xml -# .idea/jarRepositories.xml -# .idea/modules.xml -# .idea/*.iml -# .idea/modules -# *.iml -# *.ipr - -# CMake +.idea/ cmake-build-*/ - -# Mongo Explorer plugin -.idea/**/mongoSettings.xml - -# File-based project format *.iws - -# IntelliJ out/ - -# mpeltonen/sbt-idea plugin .idea_modules/ - -# JIRA plugin atlassian-ide-plugin.xml - -# Cursive Clojure plugin -.idea/replstate.xml - -# SonarLint plugin -.idea/sonarlint/ - -# Crashlytics plugin (for Android Studio and IntelliJ) com_crashlytics_export_strings.xml crashlytics.properties crashlytics-build.properties fabric.properties - -# Editor-based Rest Client -.idea/httpRequests - -# Android studio 3.1+ serialized cache file -.idea/caches/build_file_checksums.ser - -# ---> Composer composer.phar /vendor/ - -# Commit your application's lock file https://getcomposer.org/doc/01-basic-usage.md#commit-your-composer-lock-file-to-version-control -# You may choose to ignore a library lock file http://getcomposer.org/doc/02-libraries.md#lock-file -# composer.lock - +.junie/ +.phpunit.cache/ \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..7b47913 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,52 @@ +# Code of Conduct + +We are committed to providing a friendly, safe, and welcoming environment for all. This Code of Conduct is adapted from the Contributor Covenant. + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our community a harassment‑free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio‑economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to a positive environment include: +- Using welcoming and inclusive language +- Being respectful of differing viewpoints and experiences +- Gracefully accepting constructive criticism +- Focusing on what is best for the community +- Showing empathy towards other community members + +Examples of unacceptable behavior include: +- The use of sexualized language or imagery and unwelcome sexual attention or advances +- Trolling, insulting/derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others’ private information without explicit permission +- Other conduct which could reasonably be considered inappropriate in a professional setting + +## Responsibilities and Enforcement + +Project maintainers are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. + +Maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to temporarily or permanently ban any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies within all project spaces and also applies when an individual is officially representing the project in public spaces. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the project team at conduct@your-domain.example (replace with your contact). All complaints will be reviewed and investigated promptly and fairly. + +All maintainers are obligated to respect the privacy and security of the reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: + +1. Correction — A private, written warning with clarity around the nature of the violation and an explanation of why the behavior was inappropriate. +2. Warning — A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. +3. Temporary Ban — A temporary ban from any sort of interaction or public communication with the community for a specified period of time. +4. Permanent Ban — A permanent ban from any sort of public interaction within the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 2.1. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..ae8307f --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,42 @@ +Contributing to Eyrie Templates + +Thanks for your interest in improving Eyrie Templates! This guide explains how to propose changes, report issues, and build the project. + +Code of Conduct +By participating, you agree to abide by our Code of Conduct (see CODE_OF_CONDUCT.md). + +Getting started +- Fork the repository and create a topic branch off main. +- Use clear commit messages and keep changes focused. +- For larger proposals, open an issue first to discuss the approach. + +Development setup +- PHP: 8.1+ (proposed minimum; confirm in SPEC.md). +- Install dependencies via Composer once added to the project. +- Run linters/formatters if configured. + +Pull requests +- Describe the problem and solution clearly; include screenshots for UX-facing changes. +- Add tests where applicable (parsing, rendering, loaders, escaping, security, components/props). +- Update documentation (README.md, SPEC.md) as needed. +- Ensure CI passes before requesting review. + +Commit message style (suggested) +- Use the imperative mood: "Add X", "Fix Y". +- Reference issues: Fixes #123 or Refs #456. + +Issue reports +- Include steps to reproduce, expected vs actual behavior, and environment details. +- For parser/runtime errors, include a minimal template snippet and context data. + +Security +- Do not file security issues publicly. See SECURITY.md for private reporting instructions. + +Style and design principles +- Security by default: auto-escaping on, minimal use of safe. +- Predictable behavior: no implicit state; explicit configuration. +- Maintain a small, coherent core; prefer helpers/filters/components over new syntax. +- Components are pure render units: no filesystem/network access and no mutation of outer context. + +License +By contributing, you agree that your contributions will be licensed under the repository’s LICENSE. diff --git a/MILESTONES.md b/MILESTONES.md new file mode 100644 index 0000000..ae09fba --- /dev/null +++ b/MILESTONES.md @@ -0,0 +1,59 @@ +# Milestones + +## Table of Contents +- [x] [Foundation](#foundation) +- [x] [Core Rendering](#core-rendering) +- [x] [Control Structures](#control-structures) +- [x] [Template Inheritance](#template-inheritance) +- [x] [Components and Partials](#components-and-partials) +- [x] [Advanced Features](#advanced-features) +- [x] [Performance and Optimization](#performance-and-optimization) +- [x] [Documentation](#documentation) +- [x] [Production Release v0.1.0](#production-release-v010) + +## Foundation +- [x] Project initialization (Composer, PHPUnit, Directory structure) +- [x] Template Loader implementation +- [x] Basic Configuration system + +## Core Rendering +- [x] Lexer for Eyrie syntax +- [x] Parser for expressions and output tags +- [x] Auto-escaping implementation +- [x] Basic variable and expression output (`<< >>`) + +## Control Structures +- [x] Function calls and control blocks (`<( )>`) +- [x] If/Elseif/Else logic +- [x] Foreach loop implementation +- [x] Range loop implementation + +## Template Inheritance +- [x] Extends mechanism (`[[ extends ]]`) +- [x] Block definition and overrides (`[[ block ]]`) +- [x] Super call implementation (`[[ super ]]`) + +## Components and Partials +- [x] Partial inclusion (`[[ include ]]`) +- [x] Component rendering (`<@ />`) +- [x] Component props handling + +## Advanced Features +- [x] Filters implementation (`|`) +- [x] Custom Helpers support +- [x] Custom Tags support + +## Performance and Optimization +- [x] Compiled template caching +- [x] Performance benchmarking + +## Documentation +- [x] User-friendly README (Installation, Quick Start, Syntax Guide) +- [x] Technical Specifications (SPECS.md) +- [x] Security Policy and Threat Model + +## Production Release v0.1.0 +- [x] Final specification audit +- [x] Security baseline verified (Threat Model) +- [x] Zero-warning test suite +- [x] Comprehensive user documentation diff --git a/README.md b/README.md index 6665993..126dc62 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,181 @@ -# Eyrie-Templates +# Eyrie Templates -The templating engine for the Phred Framework \ No newline at end of file +Eyrie is a fast, safe, and ergonomic server-side templating engine for PHP 8.2+. It is designed for the Phred Framework but can be used as a standalone library. + +## Features + +- **High-Performance:** Templates are compiled to native PHP code and cached. +- **Security-First:** Automatic output escaping by default to prevent XSS. +- **Minimal Syntax:** Clean and predictable syntax optimized for HTML. +- **DRY Composition:** Robust template inheritance and reusable components. +- **Expressive Control:** Support for conditionals, loops, and range-based iteration. + +## Installation + +Install Eyrie via Composer: + +```bash +composer require getphred/eyrie +``` + +## Quick Start + +```php +use Eyrie\Engine; +use Eyrie\Loader\FileLoader; + +// 1. Configure the loader +$loader = new FileLoader(['./templates']); + +// 2. Initialize the engine +$engine = new Engine($loader, [ + 'cache' => './cache', + 'debug' => true, +]); + +// 3. Render a template +echo $engine->render('welcome', ['name' => 'Phred']); +``` + +## Template Syntax + +### Output +Use `<< >>` to output variables or expressions. All output is automatically escaped. + +```html +

Hello, << name >>!

+

2 + 2 = << 2 + 2 >>

+``` + +### Filters +Modify output using the pipe `|` operator. + +```html +

<< title | upper >>

+

<< bio | raw >>

+``` + +### Control Structures +Control blocks use the `<( )>` syntax. + +#### Conditionals +```html +<( if user.isAdmin )> +

Welcome, Admin!

+<( elseif user.isMember )> +

Welcome, Member!

+<( else )> +

Welcome, Guest!

+<( endif )> +``` + +#### Loops +```html + +``` + +#### Range Loops +The `loop` tag provides a convenient way to iterate over a range. It also provides a `loop` variable with metadata. + +```html +<( loop from 1 to 5 )> +

Iteration << loop.index >> of << loop.length >>

+ <( 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 + + + + [[ block title ]]My Site[[ endblock ]] + + +
+ [[ block content ]][[ endblock ]] +
+ + +``` + +#### Page (`home.eyrie.php`) +```html +[[ extends "layouts.base" ]] + +[[ block title ]]Home Page - [[ super ]][[ endblock ]] + +[[ block content ]] +

Welcome Home

+[[ endblock ]] +``` + +### Components +Components are reusable pieces of UI that use a custom tag-like syntax. + +```html +<@ Alert type="success" message="Operation successful!" /> +``` + +### Template Resolution +Eyrie uses dot-notation to resolve template names relative to the configured loader paths. + +```php +// Resolves to: ./templates/emails/welcome.eyrie.php +$engine->render('emails.welcome'); +``` + +### Partials +Include other template files directly. + +```html +[[ include "partials.header" ]] +``` + +## Configuration + +### Loader +The `FileLoader` accepts an array of paths and an optional file extension (default is `.eyrie.php`). + +```php +$loader = new FileLoader(['./templates', './shared'], '.html'); +``` + +### Engine Options +- `cache`: Path to the directory where compiled templates will be stored. +- `debug`: If `true`, templates are recompiled on every request (default `false`). + +## Advanced Usage + +### Custom Helpers +You can register custom PHP functions to be used inside your templates. + +```php +$engine->addHelper('greet', function($name) { + return "Hello, $name!"; +}); +``` + +Template: +```html +<< greet(name) >> +``` + +### Global Variables +Add variables that are available to all templates. + +```php +$engine->addGlobal('app_name', 'My Eyrie App'); +``` + +## License + +MIT diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..5b30b53 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,49 @@ +# Security Policy (Draft) + +## Supported versions + +- Until first stable release, only the latest minor/patch is supported. +- Post‑1.0: last two minor versions receive security fixes. + +## Reporting a vulnerability + +- Please email security reports to: security@your-domain.example (replace with your contact) +- Provide a minimal reproduction, affected version, environment details, and impact if possible. +- We aim to acknowledge within 3 business days and provide a timeline after triage. + +## Disclosure process + +1. Private triage and fix development. +2. Coordinated disclosure with reporter; optional CVE request if applicable. +3. Security release notes summarizing impact, severity, and upgrade guidance. + +## Secure development guidelines + +- Auto‑escape by default; minimize usage of `safe` and review all instances. +- No dynamic eval; no template‑driven file/network access. +- Validate and normalize template names; prohibit `..` traversal and absolute paths unless namespaced. +- Keep clear separation: helpers/filters are whitelisted and reviewed. +- Enforce depth/iteration/time and size limits with safe defaults. +- Prefer exceptions with sanitized messages over warnings or silent failures. +- Components: + - Register components explicitly; unregistered components must not be invokable. + - Treat component props as untrusted input; validate types and ranges before use. + - Components must not perform filesystem/network access by default and must not mutate outer template context. + - Cap component recursion/nesting depth to avoid DoS. + +## Secrets and sensitive data + +- Do not pass secrets (tokens, passwords) through template contexts unless absolutely necessary; prefer redacted representations. +- Ensure logs and error messages do not include raw context values in production. + +## Dependencies and updates + +- Target PHP 8.1+ (finalize at release); use supported versions only. +- Pin constraints to secure versions; audit dependencies regularly (e.g., `composer audit`). +- Security fixes are backported per Supported versions policy. + +## Hardening recommendations for deployers + +- Place cache directory outside web root with perms `0700`. +- Run PHP under least‑privilege account; restrict template directories to read‑only for the runtime. +- Disable display of errors in production; enable structured logging. diff --git a/SPECS.md b/SPECS.md new file mode 100644 index 0000000..479379d --- /dev/null +++ b/SPECS.md @@ -0,0 +1,264 @@ +# Eyrie Templates — Product Specification (Draft) + +This specification captures the initial scope for Eyrie Templates, the templating engine used by the Phred Framework. + +## 1. Goals + +- Fast, safe, ergonomic server‑side templating for PHP apps in the Phred ecosystem. +- Separation of concerns: business logic in PHP, presentation logic in templates. +- Security by default via auto‑escaping with contextual modes. +- Predictable, minimal syntax optimized for HTML. +- DRY composition with inheritance and partials. + +## 2. Non‑Goals + +- No arbitrary code execution in templates; no general programming. +- No dynamic variable creation inside templates (only loop counters). +- No while loops or unbounded iteration constructs. +- No filesystem or network access from templates. + +## 3. Installation + +- Composer package: `composer require getphred/eyrie` + +### 3.1 Engine configuration + +- Directory roots can be configured for Layouts, Pages, Partials, and Components. +- Template names are referenced using dot-notation relative to their configured roots and do not include file extensions. +- Default template file extension: `.eyrie.php`. + +## 4. Core concepts + +- Template: Text file (typically HTML) with Eyrie syntax. +- Context: Associative array passed at render time. +- Page: A top‑level template representing a view. Pages may extend a Layout and fill named Blocks. +- Layout: A template that defines the overarching structure (shell) and exposes named Blocks with optional defaults. +- Block: A named content region. Pages/components can override block content; `[[ super ]]` can include parent content. + - Component: A reusable, tag‑like unit rendered with a custom element syntax (e.g., `<@ Movie id="1" />`). Components encapsulate their own rendering and accept props. +- Props: Data passed to components (and optionally pages) akin to attributes; values flow from the rendering context and are subject to escaping. +- Helper: Whitelisted callable exposed to templates. +- Filter: Unary transformation applied via `|` pipe. +- Tag: Control structure/directive (loops, conditionals, includes, etc.). +- Loader: Resolves template names to sources. +- Cache: Compiled/intermediate representation for speed. + +## 5. Syntax overview + +- Output: `<< expression >>` prints value with auto‑escaping. +- Calls/control: control blocks start with `<(` and end with `)>`; examples use `<( if ... )>` / `<( endif )>`. +- Blocks/extends: `[[ ... ]]` for inheritance, blocks, includes. +- Components: `<@ ComponentName prop1="value" prop2={ expr } />` self‑closing. Support for children/content projection is TBD. + +### 5.1 Expressions + +- Property/array access: `user.name`, `order.items[0]`. +- Literals: strings, numbers, booleans, null. +- Operators: arithmetic, comparison, logical, ternary. +- Filters: `value | lower | escape('attr') | ...`. + +### 5.2 Output examples + +- Basic: `<< user.name >>` +- With filters: `<< title | upper >>` +- Raw (discouraged): `<< user.bio | raw >>` + +### 5.3 Control structures (inside `<( ... )>`) + +- If/elseif/else/end: + - `<( if cond )>` ... `<( elseif other )>` ... `<( else )>` ... `<( endif )>` +- Foreach: + - `<( foreach item in items )>` ... `<( endforeach )>` + - Loop vars (read‑only): `item` +- Loop (range/repeat): + - `<( loop from 0 to 10 )>` ... `<( endloop )>` + - Loop vars (read‑only): `loop.index` (int), `loop.first` (bool), `loop.last` (bool), `loop.length` (int) + +### 5.4 Inheritance and blocks (inside `[[ ... ]]`) + +- Extends: `[[ extends "layouts.base" ]]` (dot-notated paths, final part is parent file) + - Extends syntax does not include the layout file extension. + - Extends must be the first line of a template file. +- Declare/override: + - `[[ block content ]] ... [[ endblock ]]` + - `[[ super ]]` inside override to include parent content +- Block context (optional): `[[ block sidebar with { user: user } ]]` + +### 5.5 Partials +- Basic Include: `[[ include "partials.footer" ]]` +- Include with optional context: `[[ include "partials.footer" with { x: 1 } ]]` (dot-notated paths, final part is partial file) + - Include syntax does not include the partial file extension. + +### 5.6 Components + +- Tag‑based components are referenced by PascalCase names and invoked with the `<@` prefix: `<@ Movie id="1" />`. +- Props: + - String literal: `<@ Movie title="Jaws" />` + - Expression: `<@ Movie rating={ movie.rating } />` + - Boolean shorthand: `<@ Movie featured />` → `featured=true` +- Registration: components are registered in PHP (see API). Unregistered component names are a `SyntaxError` (configurable to `RuntimeError`). +- Auto‑escaping: Component outputs are escaped by context unless a component deliberately returns a `SafeHtml`‑like value. +- Context: Components receive props plus a limited view of the parent context (configurable); they do not mutate outer context. + - Layout/block interaction: Components can be used within Layouts and Pages but do not extend layouts themselves, and they do not define or fill block areas. + +### 5.7 Built‑in tags + +- `if/elseif/else/endif`, `foreach/endforeach`, `loop/endloop`, `include`, `extends`, `block/endblock`, `super` + +### 5.8 Helpers and filters + +- Helpers are registered by name in PHP and callable in `<( ... )>` or `<< >>`, e.g. `<< route('home') >>`. +- Filters chain with pipes, e.g. `<< text | truncate(120) | escape('attr') >>`. + +## 6. Auto‑escaping + +- Enabled by default for all `<< >>` outputs. +- Modes: `html` (default), `attr`, `url`, `js`. +- `escape(mode)` switches mode; `safe` marks trusted values to bypass escaping. +- Escaping happens after filters unless `safe` present. +- Component rendering occurs under the current escaping mode of the insertion site. + +## 7. Template loaders + +- FilesystemLoader: rooted directories, normalized paths, no `..` traversal. +- Names are dot‑notated within configured roots and omit the file extension (default `.eyrie.php`). +- Optional namespaces (if supported): `@emails.welcome` → resolved under the `emails` root. +- Future: StringLoader, ArrayLoader. + +## 8. Caching + +- Optional compiled cache keyed by template name + engine version + config hash. +- Stores: filesystem (default), PSR‑16 (future). +- Defaults: dir perms 0700; atomic writes; checksum integrity. + +## 9. Public PHP API (draft) + +```php +interface Loader { + public function getSource(string $name): string; + public function getCacheKey(string $name): string; // stable per source + public function exists(string $name): bool; +} + +final class Environment { + public function __construct(Loader $loader, array $options = []) {} + public function addHelper(string $name, callable $helper): void {} + public function addFilter(string $name, callable $filter): void {} + /** Register a component renderer by tag name, e.g., 'Movie' */ + public function addComponent(string $name, ComponentRenderer $component): void {} + public function render(string $name, array $context = []): string {} + public function compile(string $name): CompiledTemplate {} +} + +/** Minimal component contract (draft) */ +interface ComponentRenderer { + /** + * @param array $props Props/attributes supplied in the template + * @param array $context A read‑only view of the current render context + * @return mixed A string (escaped later) or a SafeHtml‑like value + */ + public function render(array $props, array $context = []): mixed; +} + +final class CompiledTemplate { + public function render(array $context = []): string {} +} +``` + +Notes: +- `strict_variables` option throws on missing variables. +- Helpers/filters return values still subject to escaping unless wrapped in a `SafeHtml`‑like type. + +## 10. Errors and diagnostics + +- Exceptions: `TemplateError`, `SyntaxError`, `RuntimeError`, `LoaderError`. +- Messages include template name, line/col, and a snippet when possible. +- Configurable verbosity for dev vs prod. +- Useful diagnostics for components: unknown component name, invalid/unknown props, and type errors include the component tag snippet. + +## 11. Performance targets (initial) + +- Cold render p95 < 10ms for a ~5KB template on typical server hardware. +- Warm render p95 < 2ms with cache. +- Watchdog timeouts to limit parse/render and mitigate DoS. + - Component recursion and nesting limits (e.g., max depth) to prevent pathological trees. + +## 12. Security requirements (summary) + +- Auto‑escape by default; `safe` is explicit opt‑in. +- No arbitrary PHP execution; only whitelisted helpers/filters. +- Loader prevents traversal; only configured roots. +- Sandboxed evaluation; no eval/reflection. +- Depth/iteration limits; template size/token limits. +- Restrictive cache dir permissions; validated paths. +- Components are pure render units: no filesystem/network access; no mutation of outer context; bounded recursion/depth. + +## 13. Logging and telemetry + +- Hook for timings, cache metrics, loader misses. +- PSR‑3 logger support. + +## 14. Compatibility + +- Minimum PHP: propose 8.1+ (finalize). +- UTF‑8 for sources and output. + +## 15. Examples + +Base `base.eyrie.php`: + +```html + + + + + << title | escape('html') >> + + +
[[ block header ]]Default Header[[ endblock ]]
+
[[ block content ]][[ endblock ]]
+ + +``` + +Child `home.eyrie.php`: + +```html +[[ extends "base" ]] + +[[ block content ]] +

Hello, << user.name >>!

+ +[[ endblock ]] +``` + +Component usage example: + +```html +[[ extends "base" ]] + +[[ block content ]] +

Featured

+ <@ Movie id={ featured.id } title={ featured.title } featured /> + + +[[ 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 `...children...`? +- Prop type checking and defaulting strategy. diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md new file mode 100644 index 0000000..811620c --- /dev/null +++ b/THREAT_MODEL.md @@ -0,0 +1,100 @@ +# Eyrie Templates — Threat Model (Draft) + +Purpose: identify assets, threats, and controls relevant to a server‑side PHP templating engine and define baseline mitigations. + +## 1. Assets + +- Template sources (files and compiled cache outputs) +- Rendered HTML output delivered to clients +- Template context data (user data, secrets if accidentally passed) +- Component definitions/renderers and their configuration +- Helper/filter registry and configuration +- Loader configuration (roots, namespaces) +- Cache directory and compiled artifacts +- Error logs and diagnostics + +## 2. Actors + +- Application developers (trusted, may misconfigure) +- End users (untrusted input surfaces) +- Attackers supplying malicious input +- System administrators (manage deployment and FS perms) + +## 3. Trust boundaries + +- Between untrusted user input and template rendering +- Between template engine and filesystem (loaders, cache) +- Between engine and helper/filter callables (application code) +- Between dev and prod environments (verbosity, paths, timings) + +## 4. Entry points and attack surfaces + +- Template variables/expressions from request‑derived data (XSS) +- Helper/filter parameters (command/code injection via helpers) +- Component props/attributes (injection vectors; type confusion) +- Template name resolution (path traversal, namespace bypass) +- Include/extends directives (recursive includes, deep inheritance) +- Component trees (deep recursion/nesting) +- Large templates or pathological inputs (parser/render DoS) +- Cache poisoning or disclosure (incorrect perms or keying) +- Error pages and stack traces (info leakage) + +## 5. Threats and mitigations + +- Reflected/stored XSS in output + - Default auto‑escaping with context modes (`html`, `attr`, `url`, `js`) + - `safe` must be explicit and narrowly scoped + - Security lint/checks to flag `safe` usage +- Path traversal via loader + - Normalize and resolve paths; reject `..` and absolute paths unless mapped + - Restrict to configured roots; support namespaces with fixed roots +- Code execution via helpers/filters + - Only whitelisted callables registered by application + - No evaluation of template strings as PHP; no reflection access + - Optionally sandbox helpers with contracts and safe value types +- Code execution or SSRF via component renderers + - Components implement a constrained interface; no filesystem/network access by default + - Validate and sanitize props before use; avoid passing raw props to sinks + - Enforce a strict registry: only explicitly added components are callable +- DoS via deep inheritance/recursion or huge loops + - Limits: include/extends depth; loop iteration caps; template/token size limits + - Watchdog timeouts for parse/render + - Component nesting/recursion depth caps and per‑render time budgets +- Cache tampering/leakage + - Cache dir perms 0700; atomic writes; validate cache keys (include engine version and options) + - Avoid executing cache files; treat as data +- Information disclosure via errors + - Configurable verbosity; hide paths/snippets in production + - Structured error types without sensitive context values +- SSRF/RFI via helpers/filters + - No network/file IO in templates; helpers must not fetch remote resources by default + - Apply same restrictions to components + +## 6. Assumptions + +- Templates are developer‑authored and trusted; user‑generated templates are out of scope. +- Application controls helper registrations; helpers conform to safe contracts. +- Deployment applies standard OS hardening (FS perms, no public cache dirs). + +## 7. Security requirements (binding) + +- Auto‑escaping enabled by default and cannot be globally disabled in production builds +- Loader prevents traversal; cannot escape configured roots +- Depth/size/time limits are configurable with safe defaults +- Distinct dev/prod modes, with safe prod defaults +- No eval of template source; no direct PHP execution from templates +- Components are pure render units; cannot mutate outer context; registered explicitly + +## 8. Validation and testing + +- XSS test corpus across HTML, attribute, JS, and URL contexts +- Fuzz tests for parser stability and timeouts +- Unit tests for loader normalization and traversal blocking +- Integration tests for cache directory perms and keying +- Component test suite for prop escaping, type validation, and recursion limits + +## 9. Open items + +- Define safe value wrapper interface +- Decide on strict‑variables default (on/off) +- Finalize default limits (include depth, loop max, template size) diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..c394df3 --- /dev/null +++ b/composer.json @@ -0,0 +1,31 @@ +{ + "name": "getphred/eyrie", + "description": "The templating engine for the Phred Framework", + "license": "MIT", + "type": "library", + "authors": [ + { + "name": "Junie", + "email": "junie@example.com" + } + ], + "require": { + "php": "^8.2" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "autoload": { + "psr-4": { + "Eyrie\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Eyrie\\Tests\\": "tests/" + } + }, + "config": { + "sort-packages": true + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..d74b6a1 --- /dev/null +++ b/composer.lock @@ -0,0 +1,1690 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "a04d8f4360b838804edefd62253383ac", + "packages": [], + "packages-dev": [ + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.7.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + }, + "time": "2025-12-06T11:56:16+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "10.1.16", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "7e308268858ed6baedc8704a304727d20bc07c77" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/7e308268858ed6baedc8704a304727d20bc07c77", + "reference": "7e308268858ed6baedc8704a304727d20bc07c77", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^4.19.1 || ^5.1.0", + "php": ">=8.1", + "phpunit/php-file-iterator": "^4.1.0", + "phpunit/php-text-template": "^3.0.1", + "sebastian/code-unit-reverse-lookup": "^3.0.0", + "sebastian/complexity": "^3.2.0", + "sebastian/environment": "^6.1.0", + "sebastian/lines-of-code": "^2.0.2", + "sebastian/version": "^4.0.1", + "theseer/tokenizer": "^1.2.3" + }, + "require-dev": { + "phpunit/phpunit": "^10.1" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "10.1.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/10.1.16" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-08-22T04:31:57+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "4.1.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "a95037b6d9e608ba092da1b23931e537cadc3c3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/a95037b6d9e608ba092da1b23931e537cadc3c3c", + "reference": "a95037b6d9e608ba092da1b23931e537cadc3c3c", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/4.1.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-08-31T06:24:48+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "4.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7", + "reference": "f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^10.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/4.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:56:09+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "0c7b06ff49e3d5072f057eb1fa59258bf287a748" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/0c7b06ff49e3d5072f057eb1fa59258bf287a748", + "reference": "0c7b06ff49e3d5072f057eb1fa59258bf287a748", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/3.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-08-31T14:07:24+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "e2a2d67966e740530f4a3343fe2e030ffdc1161d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/e2a2d67966e740530f4a3343fe2e030ffdc1161d", + "reference": "e2a2d67966e740530f4a3343fe2e030ffdc1161d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "source": "https://github.com/sebastianbergmann/php-timer/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:57:52+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "10.5.60", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "f2e26f52f80ef77832e359205f216eeac00e320c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/f2e26f52f80ef77832e359205f216eeac00e320c", + "reference": "f2e26f52f80ef77832e359205f216eeac00e320c", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xml": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.1", + "phpunit/php-code-coverage": "^10.1.16", + "phpunit/php-file-iterator": "^4.1.0", + "phpunit/php-invoker": "^4.0.0", + "phpunit/php-text-template": "^3.0.1", + "phpunit/php-timer": "^6.0.0", + "sebastian/cli-parser": "^2.0.1", + "sebastian/code-unit": "^2.0.0", + "sebastian/comparator": "^5.0.4", + "sebastian/diff": "^5.1.1", + "sebastian/environment": "^6.1.0", + "sebastian/exporter": "^5.1.4", + "sebastian/global-state": "^6.0.2", + "sebastian/object-enumerator": "^5.0.0", + "sebastian/recursion-context": "^5.0.1", + "sebastian/type": "^4.0.0", + "sebastian/version": "^4.0.1" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "10.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "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" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsors.html", + "type": "custom" + }, + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", + "type": "tidelift" + } + ], + "time": "2025-12-06T07:50:42+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "c34583b87e7b7a8055bf6c450c2c77ce32a24084" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/c34583b87e7b7a8055bf6c450c2c77ce32a24084", + "reference": "c34583b87e7b7a8055bf6c450c2c77ce32a24084", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/2.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T07:12:49+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "a81fee9eef0b7a76af11d121767abc44c104e503" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/a81fee9eef0b7a76af11d121767abc44c104e503", + "reference": "a81fee9eef0b7a76af11d121767abc44c104e503", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "source": "https://github.com/sebastianbergmann/code-unit/tree/2.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:58:43+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "5e3a687f7d8ae33fb362c5c0743794bbb2420a1d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/5e3a687f7d8ae33fb362c5c0743794bbb2420a1d", + "reference": "5e3a687f7d8ae33fb362c5c0743794bbb2420a1d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/3.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:59:15+00:00" + }, + { + "name": "sebastian/comparator", + "version": "5.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "e8e53097718d2b53cfb2aa859b06a41abf58c62e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/e8e53097718d2b53cfb2aa859b06a41abf58c62e", + "reference": "e8e53097718d2b53cfb2aa859b06a41abf58c62e", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.1", + "sebastian/diff": "^5.0", + "sebastian/exporter": "^5.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "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" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2025-09-07T05:25:07+00:00" + }, + { + "name": "sebastian/complexity", + "version": "3.2.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "68ff824baeae169ec9f2137158ee529584553799" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/68ff824baeae169ec9f2137158ee529584553799", + "reference": "68ff824baeae169ec9f2137158ee529584553799", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.18 || ^5.0", + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/3.2.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-12-21T08:37:17+00:00" + }, + { + "name": "sebastian/diff", + "version": "5.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "c41e007b4b62af48218231d6c2275e4c9b975b2e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/c41e007b4b62af48218231d6c2275e4c9b975b2e", + "reference": "c41e007b4b62af48218231d6c2275e4c9b975b2e", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0", + "symfony/process": "^6.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/5.1.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T07:15:17+00:00" + }, + { + "name": "sebastian/environment", + "version": "6.1.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "8074dbcd93529b357029f5cc5058fd3e43666984" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/8074dbcd93529b357029f5cc5058fd3e43666984", + "reference": "8074dbcd93529b357029f5cc5058fd3e43666984", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/6.1.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-23T08:47:14+00:00" + }, + { + "name": "sebastian/exporter", + "version": "5.1.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "0735b90f4da94969541dac1da743446e276defa6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/0735b90f4da94969541dac1da743446e276defa6", + "reference": "0735b90f4da94969541dac1da743446e276defa6", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.1", + "sebastian/recursion-context": "^5.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/5.1.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2025-09-24T06:09:11+00:00" + }, + { + "name": "sebastian/global-state", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "987bafff24ecc4c9ac418cab1145b96dd6e9cbd9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/987bafff24ecc4c9ac418cab1145b96dd6e9cbd9", + "reference": "987bafff24ecc4c9ac418cab1145b96dd6e9cbd9", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "sebastian/object-reflector": "^3.0", + "sebastian/recursion-context": "^5.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/6.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T07:19:19+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "856e7f6a75a84e339195d48c556f23be2ebf75d0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/856e7f6a75a84e339195d48c556f23be2ebf75d0", + "reference": "856e7f6a75a84e339195d48c556f23be2ebf75d0", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.18 || ^5.0", + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/2.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-12-21T08:38:20+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "202d0e344a580d7f7d04b3fafce6933e59dae906" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/202d0e344a580d7f7d04b3fafce6933e59dae906", + "reference": "202d0e344a580d7f7d04b3fafce6933e59dae906", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "sebastian/object-reflector": "^3.0", + "sebastian/recursion-context": "^5.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T07:08:32+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "24ed13d98130f0e7122df55d06c5c4942a577957" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/24ed13d98130f0e7122df55d06c5c4942a577957", + "reference": "24ed13d98130f0e7122df55d06c5c4942a577957", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/3.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T07:06:18+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "5.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "47e34210757a2f37a97dcd207d032e1b01e64c7a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/47e34210757a2f37a97dcd207d032e1b01e64c7a", + "reference": "47e34210757a2f37a97dcd207d032e1b01e64c7a", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/5.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-10T07:50:56+00:00" + }, + { + "name": "sebastian/type", + "version": "4.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "462699a16464c3944eefc02ebdd77882bd3925bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/462699a16464c3944eefc02ebdd77882bd3925bf", + "reference": "462699a16464c3944eefc02ebdd77882bd3925bf", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "source": "https://github.com/sebastianbergmann/type/tree/4.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T07:10:45+00:00" + }, + { + "name": "sebastian/version", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c51fa83a5d8f43f1402e3f32a005e6262244ef17" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c51fa83a5d8f43f1402e3f32a005e6262244ef17", + "reference": "c51fa83a5d8f43f1402e3f32a005e6262244ef17", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "source": "https://github.com/sebastianbergmann/version/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-07T11:34:05+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-11-17T20:03:58+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": "^8.2" + }, + "platform-dev": {}, + "plugin-api-version": "2.6.0" +} diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..7edac94 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,17 @@ + + + + + tests + + + + + src + + + diff --git a/src/Compiler/Compiler.php b/src/Compiler/Compiler.php new file mode 100644 index 0000000..537533c --- /dev/null +++ b/src/Compiler/Compiler.php @@ -0,0 +1,276 @@ +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; + } +} diff --git a/src/Engine.php b/src/Engine.php new file mode 100644 index 0000000..d789982 --- /dev/null +++ b/src/Engine.php @@ -0,0 +1,276 @@ +loader = $loader; + $this->compiler = new Compiler(); + $this->cachePath = $options['cache'] ?? sys_get_temp_dir() . '/eyrie_cache'; + $this->debug = $options['debug'] ?? false; + + if (!is_dir($this->cachePath)) { + mkdir($this->cachePath, 0777, true); + } + } + + public function render(string $name, array $context = []): string + { + $this->blocks = []; // Reset blocks for each top-level render + $this->capturedBlocks = []; + $this->renderedBlocks = []; + $this->parentBlock = ''; + $this->currentLayout = null; + + $current = $name; + $chain = []; + + // Pass 1: Collect blocks + while (true) { + $cacheKey = $this->loader->getCacheKey($current); + $cachedFile = $this->cachePath . '/' . md5($cacheKey) . '.php'; + if ($this->debug || !file_exists($cachedFile) || !$this->loader->isFresh($current, filemtime($cachedFile))) { + $php = $this->compile($current); + file_put_contents($cachedFile, $php); + } + + if (in_array($cachedFile, $chain)) { + throw new \RuntimeException("Circular inheritance detected: " . implode(' -> ', $chain) . " -> $cachedFile"); + } + $chain[] = $cachedFile; + $this->capturedBlocks = []; + $this->currentLayout = null; + + // Collect blocks into $this->blocks + $fullContext = array_merge($this->globals, $context); + $this->evaluate($cachedFile, $fullContext); + + foreach ($this->capturedBlocks as $blockName => $callback) { + if (!isset($this->blocks[$blockName])) { + $this->blocks[$blockName] = $callback; + } + } + + if ($this->currentLayout !== null) { + $current = $this->currentLayout; + continue; + } + break; + } + + // Pass 2: Final render of the top-most layout + $topLayoutFile = end($chain); + $this->capturedBlocks = []; + $this->renderedBlocks = []; + $this->currentLayout = null; + + return $this->evaluate($topLayoutFile, $context); + } + + public function doRender(string $name, array $context = []): string + { + // For nested renders (include, component), we want a fresh block state + // but perhaps they should inherit the parent blocks? + // Eyrie specs say components encapsulate their rendering. + $oldBlocks = $this->blocks; + $this->blocks = []; + $res = $this->render($name, $context); + $this->blocks = $oldBlocks; + return $res; + } + + private function compile(string $name): string + { + $source = $this->loader->load($name); + $lexer = new Lexer($source); + $parser = new Parser($lexer->tokenize()); + $ast = $parser->parse(); + return $this->compiler->compile($ast); + } + + private function evaluate(string $file, array $context): string + { + ob_start(); + try { + // Flatten context for extraction. + $vars = array_merge($this->globals, $context); + // Compiled expressions use ($context['var'] ?? null) + $vars['context'] = $vars; + $vars['engine'] = $this; // Provide $engine for closures that might need it + + // Templates are included in the scope of this closure, + // which is bound to the Engine instance. + $result = (function ($__tpl_file, $context) { + extract($context, EXTR_SKIP); + return include $__tpl_file; + })->call($this, $file, $vars); + } catch (\Throwable $e) { + ob_end_clean(); + throw $e; + } + + $output = ob_get_clean(); + + if (is_array($result) && isset($result['layout'])) { + $this->currentLayout = $result['layout']; + } + + return $output; + } + + public function getGlobals(): array + { + return $this->globals; + } + + public function escape(mixed $value): string + { + if ($value instanceof SafeString) { + return (string)$value; + } + if (is_bool($value)) { + return $value ? 'true' : 'false'; + } + return htmlspecialchars((string)$value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + } + + public function access(mixed $object, string $property): mixed + { + if (is_array($object)) { + return $object[$property] ?? null; + } + + if (is_object($object)) { + if (isset($object->{$property})) { + return $object->{$property}; + } + $method = 'get' . ucfirst($property); + if (method_exists($object, $method)) { + return $object->$method(); + } + } + + return null; + } + + public function renderBlock(string $name, callable $fallback, array $context): string + { + $this->renderedBlocks[$name] = true; + + ob_start(); + $fallback(); + $fallbackContent = ob_get_clean(); + + if (isset($this->blocks[$name])) { + $callback = $this->blocks[$name]; + + $oldParentBlock = $this->parentBlock; + $this->parentBlock = $fallbackContent; + + ob_start(); + $callback($context); + $result = ob_get_clean(); + + $this->parentBlock = $oldParentBlock; + + return $result; + } + + return $fallbackContent; + } + + public function registerBlock(string $name, array $blockCtx, callable $callback): void + { + $this->capturedBlocks[$name] = function(array $context) use ($callback, $blockCtx) { + $vars = array_merge($context, $blockCtx); + $callback($vars); + }; + } + + private function evaluateBlock(string $php, array $context): string + { + return ""; + } + + public function renderComponent(string $name, array $props): string + { + // For now, components are just templates with their own scope + return $this->doRender($name, $props); + } + + public function applyFilter(string $name, mixed $value, ...$args): mixed + { + if (isset($this->filters[$name])) { + return $this->filters[$name]($value, ...$args); + } + + // Built-in filters + switch ($name) { + case 'upper': + return strtoupper((string)$value); + case 'lower': + return strtolower((string)$value); + case 'raw': + return new SafeString((string)$value); + } + + return $value; + } + + public function callHelper(string $name, ...$args): mixed + { + if (!isset($this->helpers[$name])) { + throw new \RuntimeException(sprintf('Helper "%s" not found.', $name)); + } + + return ($this->helpers[$name])(...$args); + } + + public function addHelper(string $name, callable $helper): void + { + $this->helpers[$name] = $helper; + } + + public function addFilter(string $name, callable $filter): void + { + $this->filters[$name] = $filter; + } + + public function addGlobal(string $name, mixed $value): void + { + $this->globals[$name] = $value; + } + + public function getLoader(): LoaderInterface + { + return $this->loader; + } + + public function isDebug(): bool + { + return $this->debug; + } +} diff --git a/src/Loader/FileLoader.php b/src/Loader/FileLoader.php new file mode 100644 index 0000000..9a9ff5e --- /dev/null +++ b/src/Loader/FileLoader.php @@ -0,0 +1,69 @@ +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; + } +} diff --git a/src/Loader/LoaderInterface.php b/src/Loader/LoaderInterface.php new file mode 100644 index 0000000..56e88b1 --- /dev/null +++ b/src/Loader/LoaderInterface.php @@ -0,0 +1,42 @@ + TokenType::OUTPUT_START, + '>>' => TokenType::OUTPUT_END, + '<(' => TokenType::CONTROL_START, + ')>' => TokenType::CONTROL_END, + '[[' => TokenType::BLOCK_START, + ']]' => TokenType::BLOCK_END, + '<@' => TokenType::COMPONENT_START, + '/>' => TokenType::COMPONENT_END, + ]; + + private string $source; + private int $cursor = 0; + private int $line = 1; + private ?TokenType $state = null; + + public function __construct(string $source) + { + $this->source = $source; + } + + /** + * @return Token[] + */ + public function tokenize(): array + { + $tokens = []; + $length = strlen($this->source); + + while ($this->cursor < $length) { + if ($this->state === null) { + $tokens[] = $this->lexText(); + } else { + $tokens = array_merge($tokens, $this->lexExpression()); + } + } + + $tokens[] = new Token(TokenType::EOF, '', $this->line); + return array_filter($tokens); + } + + private function lexText(): ?Token + { + $start = $this->cursor; + $length = strlen($this->source); + $text = ''; + + while ($this->cursor < $length) { + foreach (self::DELIMITERS as $delim => $type) { + if (substr($this->source, $this->cursor, strlen($delim)) === $delim) { + if ($this->cursor > $start) { + return new Token(TokenType::TEXT, $text, $this->line); + } + $this->state = $type; + $this->cursor += strlen($delim); + return new Token($type, $delim, $this->line); + } + } + + $char = $this->source[$this->cursor]; + if ($char === "\n") { + $this->line++; + } + $text .= $char; + $this->cursor++; + } + + return $text !== '' ? new Token(TokenType::TEXT, $text, $this->line) : null; + } + + private function lexExpression(): array + { + $tokens = []; + $length = strlen($this->source); + + while ($this->cursor < $length) { + $this->skipWhitespace(); + + // Check for end delimiters + foreach (self::DELIMITERS as $delim => $type) { + if ($this->isEndDelimiter($type) && substr($this->source, $this->cursor, strlen($delim)) === $delim) { + $tokens[] = new Token($type, $delim, $this->line); + $this->cursor += strlen($delim); + $this->state = null; + return $tokens; + } + } + + $char = $this->source[$this->cursor]; + + if (ctype_alpha($char) || $char === '_') { + $tokens[] = $this->lexIdentifier(); + } elseif (ctype_digit($char)) { + $tokens[] = $this->lexNumber(); + } elseif ($char === '"' || $char === "'") { + $tokens[] = $this->lexString($char); + } elseif ($char === '|') { + $tokens[] = new Token(TokenType::OPERATOR, '|', $this->line); + $this->cursor++; + } else { + // Operators or other symbols + $tokens[] = new Token(TokenType::OPERATOR, $char, $this->line); + $this->cursor++; + } + } + + return $tokens; + } + + private function isEndDelimiter(TokenType $type): bool + { + return in_array($type, [ + TokenType::OUTPUT_END, + TokenType::CONTROL_END, + TokenType::BLOCK_END, + TokenType::COMPONENT_END + ]); + } + + private function skipWhitespace(): void + { + while ($this->cursor < strlen($this->source) && ctype_space($this->source[$this->cursor])) { + if ($this->source[$this->cursor] === "\n") { + $this->line++; + } + $this->cursor++; + } + } + + private function lexIdentifier(): Token + { + $start = $this->cursor; + while ($this->cursor < strlen($this->source) && (ctype_alnum($this->source[$this->cursor]) || $this->source[$this->cursor] === '_')) { + $this->cursor++; + } + return new Token(TokenType::IDENTIFIER, substr($this->source, $start, $this->cursor - $start), $this->line); + } + + private function lexNumber(): Token + { + $start = $this->cursor; + while ($this->cursor < strlen($this->source) && ctype_digit($this->source[$this->cursor])) { + $this->cursor++; + } + return new Token(TokenType::NUMBER, substr($this->source, $start, $this->cursor - $start), $this->line); + } + + private function lexString(string $quote): Token + { + $start = $this->cursor; + $this->cursor++; // Skip opening quote + while ($this->cursor < strlen($this->source) && $this->source[$this->cursor] !== $quote) { + $this->cursor++; + } + $this->cursor++; // Skip closing quote + $value = substr($this->source, $start, $this->cursor - $start); + return new Token(TokenType::STRING, $value, $this->line); + } +} diff --git a/src/Parser/Node/BlockNode.php b/src/Parser/Node/BlockNode.php new file mode 100644 index 0000000..0fa3457 --- /dev/null +++ b/src/Parser/Node/BlockNode.php @@ -0,0 +1,22 @@ +name . " ]]"; + } +} diff --git a/src/Parser/Node/ComponentNode.php b/src/Parser/Node/ComponentNode.php new file mode 100644 index 0000000..457ea3f --- /dev/null +++ b/src/Parser/Node/ComponentNode.php @@ -0,0 +1,21 @@ +name . " />"; + } +} diff --git a/src/Parser/Node/ExpressionNode.php b/src/Parser/Node/ExpressionNode.php new file mode 100644 index 0000000..20a4ec2 --- /dev/null +++ b/src/Parser/Node/ExpressionNode.php @@ -0,0 +1,21 @@ +expression . " >>"; + } +} diff --git a/src/Parser/Node/ExtendsNode.php b/src/Parser/Node/ExtendsNode.php new file mode 100644 index 0000000..30cedd1 --- /dev/null +++ b/src/Parser/Node/ExtendsNode.php @@ -0,0 +1,20 @@ +layout . "\" ]]"; + } +} diff --git a/src/Parser/Node/ForeachNode.php b/src/Parser/Node/ForeachNode.php new file mode 100644 index 0000000..0bbe79f --- /dev/null +++ b/src/Parser/Node/ForeachNode.php @@ -0,0 +1,22 @@ +item . " in " . $this->items . " )>"; + } +} diff --git a/src/Parser/Node/IfNode.php b/src/Parser/Node/IfNode.php new file mode 100644 index 0000000..058b29a --- /dev/null +++ b/src/Parser/Node/IfNode.php @@ -0,0 +1,23 @@ +condition . " )>"; + } +} diff --git a/src/Parser/Node/IncludeNode.php b/src/Parser/Node/IncludeNode.php new file mode 100644 index 0000000..d3ed460 --- /dev/null +++ b/src/Parser/Node/IncludeNode.php @@ -0,0 +1,21 @@ +template . "\" ]]"; + } +} diff --git a/src/Parser/Node/LoopNode.php b/src/Parser/Node/LoopNode.php new file mode 100644 index 0000000..4499fd2 --- /dev/null +++ b/src/Parser/Node/LoopNode.php @@ -0,0 +1,22 @@ +from . " to " . $this->to . " )>"; + } +} diff --git a/src/Parser/Node/Node.php b/src/Parser/Node/Node.php new file mode 100644 index 0000000..f4fb538 --- /dev/null +++ b/src/Parser/Node/Node.php @@ -0,0 +1,14 @@ +children)); + } +} diff --git a/src/Parser/Node/SuperNode.php b/src/Parser/Node/SuperNode.php new file mode 100644 index 0000000..3408624 --- /dev/null +++ b/src/Parser/Node/SuperNode.php @@ -0,0 +1,18 @@ +content; + } +} diff --git a/src/Parser/Parser.php b/src/Parser/Parser.php new file mode 100644 index 0000000..d273de1 --- /dev/null +++ b/src/Parser/Parser.php @@ -0,0 +1,424 @@ +tokens = $tokens; + } + + public function parse(): RootNode + { + $root = new RootNode(); + + while (!$this->isEOF()) { + $node = $this->parseNode(); + if ($node) { + $root->children[] = $node; + } + } + + return $root; + } + + private function parseNode(): ?Node + { + $token = $this->peek(); + + if ($token->type === TokenType::TEXT) { + $this->consume(); + return new TextNode($token->value, $token->line); + } + + if ($token->type === TokenType::OUTPUT_START) { + return $this->parseOutput(); + } + + if ($token->type === TokenType::CONTROL_START) { + return $this->parseControl(); + } + + if ($token->type === TokenType::BLOCK_START) { + return $this->parseBlockTag(); + } + + if ($token->type === TokenType::COMPONENT_START) { + return $this->parseComponent(); + } + + // Placeholder for other tags + $this->consume(); + return null; + } + + private function parseOutput(): ExpressionNode + { + $startToken = $this->consume(TokenType::OUTPUT_START); + $expression = ''; + $filters = []; + + while (!$this->isEOF() && $this->peek()->type !== TokenType::OUTPUT_END) { + if ($this->peek()->value === '|') { + $this->consume(); // | + $filterName = $this->consume(TokenType::IDENTIFIER)->value; + $args = []; + if ($this->peek()->value === '(') { + $this->consume(); // ( + while ($this->peek()->value !== ')') { + $args[] = $this->consume()->value; + if ($this->peek()->value === ',') $this->consume(); + } + $this->consume(); // ) + } + $filters[] = ['name' => $filterName, 'args' => $args]; + } else { + $expression .= $this->consume()->value; + } + } + + $this->consume(TokenType::OUTPUT_END); + + return new ExpressionNode(trim($expression), $startToken->line, $filters); + } + + private function parseControl(): ?Node + { + $this->consume(TokenType::CONTROL_START); + $token = $this->peek(); + + if ($token->value === 'if') { + return $this->parseIf(); + } + + if ($token->value === 'foreach') { + return $this->parseForeach(); + } + + if ($token->value === 'loop') { + return $this->parseLoop(); + } + + // Handle other control structures + while (!$this->isEOF() && $this->peek()->type !== TokenType::CONTROL_END) { + $this->consume(); + } + $this->consume(TokenType::CONTROL_END); + + return null; + } + + private function parseIf(): IfNode + { + $startToken = $this->consume(); // consume 'if' + $condition = ''; + while ($this->peek()->type !== TokenType::CONTROL_END) { + $condition .= $this->consume()->value . ' '; + } + $this->consume(TokenType::CONTROL_END); + + $body = []; + $elseifs = []; + $else = null; + + while (!$this->isEOF()) { + $token = $this->peek(); + + if ($token->type === TokenType::CONTROL_START) { + $next = $this->tokens[$this->cursor + 1] ?? null; + if ($next && $next->value === 'endif') { + $this->consume(TokenType::CONTROL_START); + $this->consume(); // endif + $this->consume(TokenType::CONTROL_END); + break; + } + if ($next && $next->value === 'elseif') { + $this->consume(TokenType::CONTROL_START); + $this->consume(); // elseif + $elseifCond = ''; + while ($this->peek()->type !== TokenType::CONTROL_END) { + $elseifCond .= $this->consume()->value . ' '; + } + $this->consume(TokenType::CONTROL_END); + $elseifBody = []; + while (!$this->isEOF()) { + $t = $this->peek(); + if ($t->type === TokenType::CONTROL_START) { + $n = $this->tokens[$this->cursor + 1] ?? null; + if ($n && in_array($n->value, ['elseif', 'else', 'endif'])) break; + } + $elseifBody[] = $this->parseNode(); + } + $elseifs[] = ['condition' => trim($elseifCond), 'body' => array_filter($elseifBody)]; + continue; + } + if ($next && $next->value === 'else') { + $this->consume(TokenType::CONTROL_START); + $this->consume(); // else + $this->consume(TokenType::CONTROL_END); + $elseBody = []; + while (!$this->isEOF()) { + $t = $this->peek(); + if ($t->type === TokenType::CONTROL_START) { + $n = $this->tokens[$this->cursor + 1] ?? null; + if ($n && $n->value === 'endif') break; + } + $elseBody[] = $this->parseNode(); + } + $else = array_filter($elseBody); + continue; + } + } + + $body[] = $this->parseNode(); + } + + return new IfNode(trim($condition), array_filter($body), $elseifs, $else, $startToken->line); + } + + private function parseForeach(): ForeachNode + { + $startToken = $this->consume(); // consume 'foreach' + $item = $this->consume(TokenType::IDENTIFIER)->value; + $this->consume(TokenType::IDENTIFIER); // consume 'in' + + $items = ''; + while ($this->peek()->type !== TokenType::CONTROL_END) { + $items .= $this->consume()->value . ' '; + } + $this->consume(TokenType::CONTROL_END); + + $body = []; + while (!$this->isEOF()) { + $token = $this->peek(); + if ($token->type === TokenType::CONTROL_START) { + $next = $this->tokens[$this->cursor + 1] ?? null; + if ($next && $next->value === 'endforeach') { + $this->consume(TokenType::CONTROL_START); + $this->consume(); // endforeach + $this->consume(TokenType::CONTROL_END); + break; + } + } + $body[] = $this->parseNode(); + } + + return new ForeachNode(trim($items), $item, array_filter($body), $startToken->line); + } + + private function parseLoop(): LoopNode + { + $startToken = $this->consume(); // consume 'loop' + $this->consume(TokenType::IDENTIFIER); // consume 'from' + + $from = ''; + while ($this->peek()->value !== 'to') { + $from .= $this->consume()->value . ' '; + } + $this->consume(TokenType::IDENTIFIER); // consume 'to' + + $to = ''; + while ($this->peek()->type !== TokenType::CONTROL_END) { + $to .= $this->consume()->value . ' '; + } + $this->consume(TokenType::CONTROL_END); + + $body = []; + while (!$this->isEOF()) { + $token = $this->peek(); + if ($token->type === TokenType::CONTROL_START) { + $next = $this->tokens[$this->cursor + 1] ?? null; + if ($next && $next->value === 'endloop') { + $this->consume(TokenType::CONTROL_START); + $this->consume(); // endloop + $this->consume(TokenType::CONTROL_END); + break; + } + } + $body[] = $this->parseNode(); + } + + return new LoopNode(trim($from), trim($to), array_filter($body), $startToken->line); + } + + private function parseBlockTag(): ?Node + { + $this->consume(TokenType::BLOCK_START); + $token = $this->peek(); + + if ($token->value === 'extends') { + return $this->parseExtends(); + } + + if ($token->value === 'block') { + return $this->parseBlock(); + } + + if ($token->value === 'super') { + return $this->parseSuper(); + } + + if ($token->value === 'include') { + return $this->parseInclude(); + } + + // Handle other block tags (e.g. include, super) + while (!$this->isEOF() && $this->peek()->type !== TokenType::BLOCK_END) { + $this->consume(); + } + $this->consume(TokenType::BLOCK_END); + + return null; + } + + private function parseExtends(): ExtendsNode + { + $startToken = $this->consume(); // extends + $layout = $this->consume(TokenType::STRING)->value; + $layout = substr($layout, 1, -1); // Strip quotes + $this->consume(TokenType::BLOCK_END); + + return new ExtendsNode($layout, $startToken->line); + } + + private function parseBlock(): BlockNode + { + $startToken = $this->consume(); // block + $name = $this->consume(TokenType::IDENTIFIER)->value; + $context = []; + + if ($this->peek()->value === 'with') { + $this->consume(); // with + if ($this->peek()->value === '{') { + $this->consume(); // { + while (!$this->isEOF() && $this->peek()->value !== '}') { + $propName = $this->consume(TokenType::IDENTIFIER)->value; + $this->consume(TokenType::OPERATOR); // : + + // Consume the value correctly + $valueToken = $this->consume(); + $expr = $valueToken->value; + + $context[$propName] = ['expr' => $expr]; + if ($this->peek()->value === ',') { + $this->consume(); + } + } + if ($this->peek()->value === '}') { + $this->consume(); // } + } + } + } + + $this->consume(TokenType::BLOCK_END); + + $body = []; + while (!$this->isEOF()) { + $token = $this->peek(); + if ($token->type === TokenType::BLOCK_START) { + $next = $this->tokens[$this->cursor + 1] ?? null; + if ($next && $next->value === 'endblock') { + $this->consume(TokenType::BLOCK_START); + $this->consume(); // endblock + $this->consume(TokenType::BLOCK_END); + break; + } + } + $body[] = $this->parseNode(); + } + + return new BlockNode($name, array_filter($body), $context, $startToken->line); + } + + private function parseSuper(): SuperNode + { + $startToken = $this->consume(); // super + $this->consume(TokenType::BLOCK_END); + + return new SuperNode($startToken->line); + } + + private function parseInclude(): IncludeNode + { + $startToken = $this->consume(); // include + $template = $this->consume(TokenType::STRING)->value; + $template = substr($template, 1, -1); // Strip quotes + $this->consume(TokenType::BLOCK_END); + + return new IncludeNode($template, [], $startToken->line); + } + + private function parseComponent(): ComponentNode + { + $startToken = $this->consume(TokenType::COMPONENT_START); + $name = $this->consume(TokenType::IDENTIFIER)->value; + $props = []; + + while ($this->peek()->type !== TokenType::COMPONENT_END) { + $propName = $this->consume(TokenType::IDENTIFIER)->value; + $this->consume(TokenType::OPERATOR); // = + + $token = $this->peek(); + if ($token->type === TokenType::STRING) { + $val = $this->consume()->value; + $props[$propName] = substr($val, 1, -1); // Strip quotes + } elseif ($token->value === '{') { + $this->consume(); // { + $expr = ''; + while ($this->peek()->value !== '}') { + $exprToken = $this->consume(); + $expr .= $exprToken->value; + } + $this->consume(); // } + $props[$propName] = ['expr' => trim($expr)]; + } else { + $props[$propName] = $this->consume()->value; + } + } + + $this->consume(TokenType::COMPONENT_END); + + return new ComponentNode($name, $props, $startToken->line); + } + + private function peek(): Token + { + return $this->tokens[$this->cursor]; + } + + private function consume(?TokenType $type = null): Token + { + $token = $this->peek(); + + if ($type !== null && $token->type !== $type) { + throw new \RuntimeException(sprintf('Expected token %s, got %s at line %d', $type->value, $token->type->value, $token->line)); + } + + $this->cursor++; + return $token; + } + + private function isEOF(): bool + { + return $this->peek()->type === TokenType::EOF; + } +} diff --git a/src/Parser/Token.php b/src/Parser/Token.php new file mode 100644 index 0000000..0964d5e --- /dev/null +++ b/src/Parser/Token.php @@ -0,0 +1,15 @@ +> + 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'; +} diff --git a/src/SafeString.php b/src/SafeString.php new file mode 100644 index 0000000..ed9a512 --- /dev/null +++ b/src/SafeString.php @@ -0,0 +1,17 @@ +value; + } +} diff --git a/tests/EngineTest.php b/tests/EngineTest.php new file mode 100644 index 0000000..b216fb5 --- /dev/null +++ b/tests/EngineTest.php @@ -0,0 +1,212 @@ +tempDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'eyrie_tests_' . uniqid(); + mkdir($this->tempDir); + $this->cacheDir = $this->tempDir . DIRECTORY_SEPARATOR . 'cache'; + } + + protected function tearDown(): void + { + $this->removeDirectory($this->tempDir); + } + + public function testRenderSimpleText(): void + { + file_put_contents($this->tempDir . '/hello.eyrie.php', 'Hello World'); + $loader = new FileLoader([$this->tempDir]); + $engine = new Engine($loader, ['cache' => $this->cacheDir]); + + $this->assertEquals('Hello World', $engine->render('hello')); + } + + public function testRenderVariable(): void + { + file_put_contents($this->tempDir . '/greet.eyrie.php', 'Hello << name >>!'); + $loader = new FileLoader([$this->tempDir]); + $engine = new Engine($loader, ['cache' => $this->cacheDir]); + + $this->assertEquals('Hello Junie!', $engine->render('greet', ['name' => 'Junie'])); + } + + public function testAutoEscaping(): void + { + file_put_contents($this->tempDir . '/escape.eyrie.php', '<< content >>'); + $loader = new FileLoader([$this->tempDir]); + $engine = new Engine($loader, ['cache' => $this->cacheDir]); + + $this->assertEquals('<script>', $engine->render('escape', ['content' => '