diff --git a/.gitattributes b/.gitattributes index 433a2de9a..c361dca0e 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,9 +1,11 @@ .gitattributes export-ignore .github/ export-ignore .gitignore export-ignore +AGENTS.md export-ignore ncs.* export-ignore phpstan*.neon export-ignore src/**/*.latte export-ignore +docs/ export-ignore tests/ export-ignore *.php* diff=php diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..40665db71 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,61 @@ +# To My Agents! + +It is my fervent wish that this file guide every AI coding agent working with code in this repository. + +## Documentation + +Any distilled, agent-facing documentation for this package - how it works +internally and the rationale behind key design decisions - lives in `docs/`. +Consult it before non-trivial changes; it is the source of truth from which the +public manual is distilled. + +The presenter lifecycle, link generation, persistent state, routing, and template +lookup carry subtle non-local invariants - read `docs/internals` before touching +them. + +## Project Overview + +Nette Application is a full-stack, component-based MVC framework library for PHP - +the core application layer of the Nette Framework: presenters, components, routing, +templating integration, and the request/response cycle. It is a library package, +not a runnable application, and integrates with the rest of the framework through +`src/Bridges` (DI, Latte, Tracy). + +- **PHP Version**: 8.3 - 8.5 +- **Package**: `nette/application` + +## Essential Commands + +```bash +# Run all tests +composer tester # or: vendor/bin/tester tests -s + +# Run one test directory / file +vendor/bin/tester tests/UI -s +php tests/Application/Presenter.twoDomains.phpt + +# Static analysis (PHPStan level 8) +composer phpstan +``` + +## Conventions + +- Every file starts with `declare(strict_types=1);`; tabs for indentation; Nette + Coding Standard (PSR-12 based) with the opening brace on its own line. +- Tests are Nette Tester `.phpt` files mirroring `src/` under `tests/`; use + `test()` / `testException()` and `Assert::*`. Temp files go to `tests/tmp/{pid}/`, + and `tests/types/` holds PHPStan type-inference fixtures. + +## Working in this repo + +- **The presenter lifecycle is exact and load-bearing.** The precise phase order + (action, canonicalization, signal dispatch, `beforeRender`, `render`, the + `onRender` hook, `afterRender`), `AbortException`-based termination, and the + 4xx-not-500 invalid-link contract are documented in `docs/internals/lifecycle.md`. + Don't reconstruct them from memory. +- **Links and persistent state span the whole component tree.** Link-generation + modes and whole-tree global-state aggregation are the trickiest code here; see + `docs/internals/link-generation.md` and `persistent-state.md`. +- User-facing how-to (routing DSL, signals, snippets, component factories, flash + messages, DI/NEON configuration) is manual material and lives in the public web + docs, not here. diff --git a/composer.json b/composer.json index b3b33623c..c9b5f1def 100644 --- a/composer.json +++ b/composer.json @@ -16,7 +16,7 @@ ], "require": { "php": "8.3 - 8.5", - "nette/component-model": "^3.2 || ^4.0", + "nette/component-model": "^4.0", "nette/http": "^3.4", "nette/routing": "^3.1.1", "nette/utils": "^4.1" @@ -29,7 +29,7 @@ "nette/tester": "^2.6", "nette/caching": "^3.2", "nette/di": "^3.2", - "nette/forms": "^3.2", + "nette/forms": "^4.0", "nette/robot-loader": "^4.0", "nette/security": "^3.2", "latte/latte": "^3.1.4", @@ -43,9 +43,9 @@ "conflict": { "nette/caching": "<3.2", "nette/di": "<3.2", - "nette/forms": "<3.2", + "nette/forms": "<4.0", "nette/schema": "<1.3", - "latte/latte": "<3.1.4 || >=3.2", + "latte/latte": "<3.1.5 || >=3.2", "tracy/tracy": "<2.12" }, "autoload": { @@ -61,7 +61,7 @@ }, "extra": { "branch-alias": { - "dev-master": "3.3-dev" + "dev-master": "4.0-dev" } }, "config": { diff --git a/docs/decisions/2025-02-27 linkbase-tag.md b/docs/decisions/2025-02-27 linkbase-tag.md new file mode 100644 index 000000000..8ebba910f --- /dev/null +++ b/docs/decisions/2025-02-27 linkbase-tag.md @@ -0,0 +1,81 @@ +# {linkBase} tag - compile-time base for relative links in a template + +Status: accepted · 2025-02-27 · branch `v3.2` (released in 3.2.7) + +## Context + +A flexible directory structure is one of the most valuable features of Nette +Application, but it creates a problem with shared layouts: relative link targets +(`Dashboard:`, `Products:Overview:`) are resolved against the *current* +presenter. When the same layout is used by both `Admin:Dashboard` and +`Admin:Products:Detail`, the target `Dashboard:` resolves correctly to +`Admin:Dashboard:default` in one case and to the non-existent +`Admin:Products:Dashboard:default` in the other. + +The previous workaround was to write every target in layouts absolutely +(`:Admin:Dashboard:`). It works, but the module prefix is duplicated in every +link, so moving or renaming a module means editing templates en masse. + +## Considered options + +- **Status quo (absolute links everywhere)** - no new concept, but prefix + duplication and fragility when refactoring modules. +- **Runtime solution** (setting the base on the presenter/component, or a + template variable threaded into `LinkGenerator`) - collides with the shared + template cache: the same compiled layout is used by presenters from different + modules, so the base would have to become part of the cache key, or a runtime + cost would be paid on every link. It would also make the template no longer + self-describing - a link's target would depend on state set somewhere in PHP. +- **Compile-time tag in the template** (chosen) - the base is declared directly + in the template it concerns, and expansion happens at compile time. + +## Decision + +A new tag `{linkBase module}` plus a compile-time pass `applyLinkBasePass` +registered in `UIExtension::getPasses()`: + +- The tag must appear in the template head (`Tag::isInHead()`, otherwise + `CompileException`) and applies to the **whole template file** - not to + included files, which are compiled separately and may carry their own + `{linkBase}`. It emits no code itself (`print()` returns `''`); all the work + is done by the pass. Only the first tag found is used + (`NodeHelpers::findFirst`). +- The pass traverses the AST and rewrites **all** `LinkNode`s - i.e. `{link}`, + `{plink}` and `n:href` uniformly. +- When both the target and the base are static strings, the destination is + rewritten directly at compile time (`LinkGenerator::applyBase()` called from + the pass) - **zero runtime overhead**. When either is an expression, the + destination is wrapped in a runtime call `LinkGenerator::applyBase(%node, + %node)` via `AuxiliaryNode`. +- Semantics of `LinkGenerator::applyBase($link, $base)`: the prefix `:$base:` is + added only when the target **contains a colon and does not start with one**. + Thus the base applies exclusively to relative targets that name a presenter + (`Users:list` → `:Admin:Users:list`); untouched are absolute targets + (`:Foo:`), targets within the current presenter (`show`, `this`), signals + (`click!`) and aliases - none of which have a colon in the middle. + +## Consequences + +- (+) Layouts and templates shared by presenters at different module depths link + consistently, regardless of who renders them. +- (+) Renaming or moving a module is a single change in `{linkBase}` instead of + editing every link. +- (+) For static links, expansion happens at compile time; nothing is paid at + runtime. +- (−) Another tag and concept to learn; the per-template-file scope may surprise + (includes do not inherit the base). +- (−) The base cannot be set per presenter/module from PHP - that remains open. + +## Code + +- `src/Bridges/ApplicationLatte/Nodes/LinkBaseNode.php` - the tag and the + `applyLinkBasePass` pass +- `src/Application/LinkGenerator.php` - `applyBase()` (`@internal`) +- tag and pass registration in `src/Bridges/ApplicationLatte/UIExtension.php` + (`getTags()`, `getPasses()`) +- test: `tests/Bridges.Latte/{linkBase}.phpt` (static and dynamic targets) +- commit `e3beab847`, released in `v3.2.7` + +The `|absoluteUrl` filter shipped in the same release for forcing an absolute URL +on an arbitrary value (image path, database value), complementing this tag for +cases outside presenter link targets. diff --git a/docs/internals/access-control.md b/docs/internals/access-control.md new file mode 100644 index 000000000..47235eb6f --- /dev/null +++ b/docs/internals/access-control.md @@ -0,0 +1,58 @@ +# Access control & CSRF + +`AccessPolicy` (internal) evaluates `#[Requires]` attributes. The attribute is +repeatable, class- or method-level, and matched with `IS_INSTANCEOF`, so subclasses +(like the deprecated `#[CrossOrigin]`) count as `Requires` instances. + +## Enforcement runs at three sites + +No single file shows this; the policy is applied wherever user code is about to run: + +1. **Class-level** — `Presenter::run()`, in the checkRequirements phase, on the + presenter's own reflection (before `startup`). +2. **Method-level** — `Component::tryCall()`, i.e. on every `action*`, `render*` + and `handle*` invocation, right before argument conversion. +3. **Factory methods** — `Component::createComponent()` checks the policy on the + `createComponent()` method, so a component can be guarded at creation. + +At the first two sites the (older, unrelated) `checkRequirements()` user hook is +also called with the same reflection — the two mechanisms coexist. The factory +site runs only the attribute policy. + +## `#[Requires]` semantics + +- `methods:` — allowed HTTP methods; `['*']` is a wildcard. **Trap:** a + *class-level* `methods:` empties `$presenter->allowedMethods` to bypass the + deprecated legacy `Presenter::checkHttpMethod()`, so the attribute fully replaces + it. Violation → 405 with an `Allow` header. +- `actions:` — method-level use is allowed **only in presenters** + (`LogicException` otherwise). Violation → 4xx via `error()`. +- `forward: true` — the element is reachable only via forward; it also makes the + element **non-linkable**: `AccessPolicy::isLinkable()` is what + `LinkGenerator::validateLinkTarget` consults for every mode except `forward` + (see link-generation.md). Violation at runtime → 4xx. +- `ajax: true` — non-AJAX request → 403. +- `sameOrigin: true` — see below. + +## The implicit signal CSRF rule + +**Every `handle*` method implicitly gets `Requires(sameOrigin: true)`** — added by +`AccessPolicy::applyInternalRules` — unless the method opts out via +`#[Requires(sameOrigin: false)]`, the deprecated `#[CrossOrigin]`, or a legacy +`@crossOrigin` annotation. Same-origin is verified from the **`Sec-Fetch-Site` +header** (`$httpRequest->isFrom(FetchSite::SameOrigin)`), not from a SameSite +cookie. + +A violation does **not** produce an error: it calls `Presenter::detectedCsrf()`, +whose default is `redirect('this')` — **the signal is silently dropped** and the +page reloads without it. `detectedCsrf()` is public and overridable. + +## `UI\Form` has its own, parallel check + +`Form::signalReceived('submit')` does **not** go through `AccessPolicy` for CSRF: +it re-checks `Sec-Fetch-Site` itself, gated by the `$crossOrigin` flag from +nette/forms (`allowCrossOrigin()`), and calls the same `detectedCsrf()`. Two more +suppressions live here: a request with the `RESTORED` flag (set by +`Presenter::restoreRequest` for re-sent POSTs) skips `fireEvents()` entirely, and +`receiveHttpData()` returns null for forwarded requests — a form never submits +across a forward. diff --git a/docs/internals/lifecycle.md b/docs/internals/lifecycle.md new file mode 100644 index 000000000..33f487ec9 --- /dev/null +++ b/docs/internals/lifecycle.md @@ -0,0 +1,75 @@ +# Presenter lifecycle + +`Presenter::run(Request): Response` is a fixed sequence; the exact ordering is the +non-local knowledge (some steps sit where a reader would not expect). + +## Phase order (`run`) + +Before the `try`: `initGlobalParameters()` parses the action, the signal, and the +signal receiver, and calls `loadState()`. Then, inside the `try`: + +1. **checkRequirements** — access policy, `checkRequirements`, HTTP method check. +2. **startup** — `onStartup`, then `startup()`, then a guard that the child called + `parent::startup()` (else `InvalidStateException`). +3. **`action()`** — via `tryCall`. A `SwitchException` here re-enters with a + changed action **and disables `autoCanonicalize`**. +4. **component autoload** — every id in `globalParams` is `getComponent(…, throw: + false)`d. +5. **canonicalize** — only if `autoCanonicalize` (see below). +6. **HEAD** — terminates early on a HEAD request. +7. **`handle()`** — `processSignal()`. **This runs after the action and + before rendering**, which is the ordering that surprises people. +8. **beforeRender** → **`onRender`** (a hook that is easy to forget) → **`render()`** + (again `SwitchException` → `setView` + retry) → **afterRender**. +9. **sendTemplate** — completes and sends the template response. + +So the true order is **startup → action → (autoload + canonicalize) → signal → +beforeRender → onRender → render → afterRender → sendTemplate**. + +A `SwitchException` escaping anywhere other than the two retry sites above is +turned by `run()` into a `LogicException` ("Switch is only allowed inside +action*() or render*() method"). + +## Termination is by exception; there is no explicit "frozen" flag + +Every terminating method funnels through `sendResponse()` → `terminate()`, which +**throws `AbortException`**: `sendTemplate`, `sendPayload`/`sendJson`, +`redirectUrl`, `forward`. `run()`'s **empty** `catch (AbortException)` then lets +control fall through to the post-lifecycle steps. So there is no separate "state is +frozen" mechanism — the response method simply throws, and nothing after it in the +lifecycle runs. (`error()` is different: it throws `BadRequestException`, which is +**not** caught in `run()` and bubbles up to `Application`.) + +**Post-lifecycle, always runs** (even after redirect/JSON, because the abort lands +here): `saveGlobalState()`, then the AJAX payload block (`payload->state = +getGlobalState()`, snippet mode for an invalidated `TextResponse`), flash-session +30-second expiration, a `VoidResponse` fallback, `onShutdown`, `shutdown()`, and +`return $this->response`. + +## The template file is resolved lazily, at `sendTemplate` + +`completeTemplate()` copies the presenter's `#[TemplateVariable]` properties into +the template (see templates.md) and, **only if the template has no file set**, +calls `findTemplateFile()` **then** — so template lookup happens at send time, not +when the template object is created. Changing the view is possible right +up to `render`; after `sendTemplate` throws `AbortException` nothing more can +affect it. + +## Canonicalization uses the `redirectX` mode + +`canonicalize()` (auto-called at step 5 unless `autoCanonicalize` was turned off by +an action switch) returns immediately for AJAX or non-GET/HEAD. Otherwise it builds +the canonical URL with `link(..., 'redirectX')` (see link-generation.md — an +absolute URL with no flash key, used purely for comparison), swallows any +`InvalidLinkException`, and if the current URL differs, redirects — **302 when the +`VARYING` request flag is set, else 301**. That flag is set by the router (external +`nette/routing`); this package only reads it. + +## Signals + +`initGlobalParameters` reads the signal from `_do` (POST) / `do` (the `SignalKey`), +splitting `{receiver}-{signal}` at the **last** `-` (so a receiver id may itself +contain dashes). `processSignal()` resolves the receiver (`''` = the presenter +itself), raising `BadSignalException` if it is missing or not a `SignalReceiver`, +then calls `signalReceived()` → `handle()`. A signal always targets the +current presenter/action; the URL carries it as `?do=-`. diff --git a/docs/internals/link-generation.md b/docs/internals/link-generation.md new file mode 100644 index 000000000..1c41ea794 --- /dev/null +++ b/docs/internals/link-generation.md @@ -0,0 +1,77 @@ +# Link generation + +`LinkGenerator::createRequest` is the densest, most trap-laden code in +the package. It builds a `Request` from a destination string, in five phases: +(1) signal/component resolution, (2) alias + presenter/action split, (3) signal +argument processing, (4) action argument processing, (5) missing-param check and +assembly. + +## The mode strings — and `redirectX` is real + +The `$mode` string is **not** just `link`/`redirect`/`forward`. There are **five**: + +| mode | URL from `link()`? | flash `_fid`? | notes | +|---|---|---|---| +| `link` | relative (unless `absoluteUrls` or `//` prefix) | no | the normal case | +| `redirect` | absolute | yes* | `Component::redirect` | +| `forward` | — (returns null) | yes* | Request via `$lastRequest`; no `isLinkable` enforcement | +| `test` | — (returns null) | no | `isLinkCurrent`; skips the missing-param check | +| `redirectX` | absolute | no | **canonicalization only** | + +`redirectX` is **not a phantom** — it is live in `Presenter::canonicalize()`. It +behaves like a redirect that yields an **absolute URL with no flash key**, so the +result can be compared byte-for-byte against the current request URL. The +`@param` docblock under-documents it (it lists only `forward|redirect|link`); trust +the code. `*`Flash is added only when the mode is `redirect`/`forward` **and** the +presenter has an active flash session — never for `redirectX` or `link`. + +## The `'#'` argument is reserved for the fragment + +`link()` (not `createRequest`) strips a `'#'` key out of `$args` and appends it as +the URL fragment — rawurlencoded, overriding any `#fragment` in the destination +string (an empty-string value falls back to it); a non-scalar value throws +`InvalidLinkException`. So `'#'` never reaches the `Request`, and no real parameter +may be named `#`. A `?query` inside the destination is deprecated and **replaces** +`$args` entirely (`parseDestination` returns it as `args`). + +## `$lastRequest` is an out-of-band return channel + +`createRequest` returns a URL, but also stores the built `Request` in the public +`@internal $lastRequest` (reset at the start, set at the end). This is how callers +retrieve the request without it being the return value — `getLastCreatedRequest()`, +the `'current'` flag that `isLinkCurrent` reads (even though `test` mode returns +`null`), and `Application::createErrorRequest` after a forward. + +## The 4xx-not-500 contract + +A malformed parameter must degrade to 4xx, not crash to 500, but only for +*untrusted* values. The mechanism is a **subtype**: +`InvalidRequestParameterException extends InvalidLinkException`. + +- `ParameterConverter::toParameters` throws the **subtype only for supplemental + values** — those pulled from the *current request's own params* when the + destination is `this` (`$refPresenter->getParameters()`). Explicit caller-supplied + args throw the plain parent `InvalidLinkException`. +- **`redirect`/`redirectPermanent`/`forward` catch only the subtype** → `error()` + (4xx); **`isLinkCurrent` catches the subtype** → `false`. +- **`link`/`canonicalize` catch the parent** `InvalidLinkException` → the subtype + passes through as before → `#error` / silently abandoned canonical redirect. + +The net effect: a hostile `?param=` re-fed through `redirect('this')` in +`startup()` yields 4xx, while a programmer's typo in an explicit link argument still +surfaces as an exception. The subtype is BC-safe because it passes existing +`catch (InvalidLinkException)` blocks. + +`$invalidLinkMode` (bit flags `Silent`/`Warning`/`Exception`/`Textual`) decides what +`handleInvalidLink` does with an escaped `InvalidLinkException` in the `link` path +(`#`, `E_USER_WARNING`, rethrow, or `#error: `). + +## Two `link()`s + +`Presenter` has **no** `link()` of its own — it inherits `Component::link()`, which +delegates to the presenter's `LinkGenerator` in `link` mode and **wraps exceptions +via `handleInvalidLink` (respecting `$invalidLinkMode`)**. The standalone +`LinkGenerator::link()` (constructed with its own router + reference URL) does +**not** swallow exceptions — it always throws — and is what you use to generate +links outside a running presenter. Its default `$mode = null` also means the URL +is always **absolute** (relativization applies only to an explicit `'link'` mode). diff --git a/docs/internals/persistent-state.md b/docs/internals/persistent-state.md new file mode 100644 index 000000000..a3687d12b --- /dev/null +++ b/docs/internals/persistent-state.md @@ -0,0 +1,58 @@ +# Persistent parameters & global state + +Persistent parameters survive across requests by riding in every generated URL. +The mechanism spans the whole component tree and has a strict validation contract. + +## Declaration and validation + +`#[Persistent]` on a **public, non-static** property is discovered by +`ComponentReflection::getParameters()`, which records its type and — for a +presenter — a `since` (the declaring class, used to scope which links may carry it). + +- **`loadState()`** converts each incoming param via + `ParameterConverter::convertType`; a **failure calls `error()` → 404** (a bad URL + value is client error, not a crash). Conversion is **lossless** for scalars (it + compares the round-tripped string), and **`callable` is deliberately rejected** + ("for security reasons"). Null params are ignored (the default stands). +- **`saveState()`/`saveStatePartial()`** re-convert on the way out; a failure here + throws `InvalidLinkException` (link generation, not a request), and a value equal + to its default is dropped (`null`) so it does not bloat the URL. + +## `#[Parameter]` rides the input path only + +`ComponentReflection::getParameters()` returns both `#[Persistent]` and +`#[Parameter]` properties, so `loadState()` fills and type-validates **both** the +same way (a bad value → `error()` → 404). The asymmetry is on the way out: only +persistent entries carry a `since` key, and `getPersistentParams()` filters on its +presence — a `#[Parameter]` property is never written back by `saveState` and never +reaches a URL. + +## Global state is aggregated across the whole tree + +`getGlobalState()` builds the full persistent-state map that links carry: + +1. unconsumed URL params in `globalParams` (prefixed by `id . NameSeparator`); +2. the presenter's own `saveStatePartial`; +3. a walk of the **entire `getComponentTree()`** — each `StatePersistent` component + contributes `saveState`, prefixed by its `getUniqueId() . NameSeparator`, while a + `sinces` table records where each key was declared; +4. when a `forClass` is given, keys whose `since` class is not among the target's + classes/traits are filtered out — so a link only carries state the destination + can actually receive. + +**`saveGlobalState()` empties `globalParams` (consuming them) and caches the +result.** It runs at the end of `run()` (always, since the terminating +`AbortException` lands after it) and again inside `Component::redirect()` before the +link is built. Global state reaches the URL because `createRequest` does `$args += +$refPresenter->getGlobalState(...)`. When a component attaches, `popGlobalParameters` +hands it its slice of the URL params so its own `loadState` runs. + +## The `_fid` flash key + +Flash messages survive a redirect via a session section keyed by a short random id +stored in the `FlashKey` (`_fid`) parameter. `hasFlashSession()` is true only when +`_fid` is a non-empty param **and** the session section `Nette.Application.Flash/` +exists. Consequently `_fid` is appended to a link **only in `redirect`/`forward` +mode and only when a flash session is already live** — ordinary links, and +`redirectX`, never carry it. `getFlashSession()` lazily generates the id (storing it +into the presenter's params) the first time a flash is written. diff --git a/docs/internals/readme.md b/docs/internals/readme.md new file mode 100644 index 000000000..0bc0b31a9 --- /dev/null +++ b/docs/internals/readme.md @@ -0,0 +1,21 @@ +# Application internals + +How `nette/application` (the MVC layer) works underneath, for agents editing it. +The emergent models don't share much context, so they're split by seam: + +- **[lifecycle.md](lifecycle.md)** — `Presenter::run()`, the exact phase order, + `AbortException`, signal dispatch, and canonicalization. +- **[link-generation.md](link-generation.md)** — `LinkGenerator::createRequest`, + the mode strings (including the real `redirectX`), destination parsing, + `$lastRequest`, and the 4xx-not-500 contract. The densest, trickiest code. +- **[persistent-state.md](persistent-state.md)** — persistent parameters, + `loadState`/`saveState`, whole-tree global-state aggregation, and the `_fid` + flash key. +- **[access-control.md](access-control.md)** — `AccessPolicy`, `#[Requires]`, the + three enforcement sites, and the implicit same-origin (CSRF) rule for signals. +- **[snippets.md](snippets.md)** — AJAX partial rendering: invalidation flags, the + snippet-mode render, and the payload contract. +- **[routing.md](routing.md)** — the Application `Route`/`RouteList` subclasses, + the `Nette:Micro` firewall exception, and `PresenterFactory`. +- **[templates.md](templates.md)** — template/layout file lookup and typed + templates. diff --git a/docs/internals/routing.md b/docs/internals/routing.md new file mode 100644 index 000000000..117e22321 --- /dev/null +++ b/docs/internals/routing.md @@ -0,0 +1,60 @@ +# Routing (Application layer) + +`nette/application` subclasses the `nette/routing` engine to add presenter/action/ +module conventions. The heavy lifting (mask parsing, the generation index / +`warmupCache`, nested-list `getConstantParameters` aggregation) lives in the base +package — see `nette/routing`'s internals. What is **here**: + +## `Application\Routers\Route` (over the base `Route`) + +- **`UIMeta` defaults** — injects `module`/`presenter`/`action` metadata with + kebab-case `FilterIn`/`FilterOut` (merged into `defaultMeta` before the parent + constructor). This is where a URL segment like `product-edit` becomes the + `ProductEdit` presenter. +- **kebab-case inflectors** — `action2path`/`path2action` (camelCase ↔ dashes) and + `presenter2path`/`path2presenter` (`Foo:Bar` ↔ `foo.bar`, `:` ↔ `.`). +- **Closure metadata → `Nette:Micro`** — a `\Closure` target is rewritten to + `['presenter' => 'Nette:Micro', 'callback' => $closure]`. This is the closure-route + mechanism, and the reason `Nette:Micro` must stay reachable (below). +- **`` handling** — after a base match, a `module` mask parameter is + prepended to the presenter (`module:Presenter`) and unset; `constructUrl` splits it + back out; `getConstantParameters` re-joins `module:presenter`. + +## `RouteList::completeParameters` and module scoping + +The Application `RouteList` adds module scoping on match: `completeParameters` +**prepends the list's `module` to the matched presenter — unless it starts with +`Nette:`**. `constructUrl` is the mirror: with a `module` set, the target presenter +must start with that prefix (else `null` — not this list's route), and the prefix +is stripped before delegating down. `withModule($m)` creates a nested list whose +`module` is `$m.':'` (a whole group under a module), which is distinct from the +per-route `` mask parameter above. `warmupCache` is *invoked* from `RoutingExtension::afterCompile` +(only when `routing: cache`), but is defined in the base package. + +## The `Nette:Micro` firewall exception + +`Application::createInitialRequest` **rejects any incoming request to a `Nette:*` +presenter except `Nette:Micro`**: `str_starts_with($presenter, 'Nette:') && +$presenter !== 'Nette:Micro'` → `BadRequestException`. Framework-internal presenters +must not be directly reachable from the network, but `Nette:Micro` is the legitimate +target that closure routes produce (mapped to `NetteModule\MicroPresenter`), so it +is the single allowed exception. `completeParameters`' `Nette:`-prefix skip exists +for the same reason — module scoping must not be applied to it. + +## Request loop and error routing + +`processRequest` has a `maxLoop` (default 20) guard, rejects direct requests **to** +the error presenters (only a forward may reach them), and turns an +`InvalidPresenterException` into a `BadRequestException` only on the first request. +`createErrorRequest` routes a `BadRequestException` to `error4xxPresenter ?? +errorPresenter` and anything else to `errorPresenter` (5xx); `sendHttpCode` maps a +`BadRequestException` to its HTTP code (else 404) and everything else to 500. + +## `PresenterFactory` + +`formatPresenterClass` maps a presenter name to a class through the mapping masks +(the hard default is `App\Presentation\*\**Presenter`; `setMapping` overrides), by +substituting `**`→`Part\Part` and `*`→`Part` per `:`-separated segment. Names are +charset-validated, the class is checked for `IPresenter`/non-abstract, and resolved +classes are cached. Aliases are resolved in the **link** layer (the `@alias` +destination), not in routing. diff --git a/docs/internals/snippets.md b/docs/internals/snippets.md new file mode 100644 index 000000000..b15385af6 --- /dev/null +++ b/docs/internals/snippets.md @@ -0,0 +1,46 @@ +# Snippets & AJAX partial rendering + +The snippet mechanism is emergent across three files: `Control` (invalidation +flags), the post-lifecycle block in `Presenter::run()` (the re-send), and +`SnippetRuntime` in the Latte bridge (the capture). None of them shows the whole +picture. + +## The pipeline + +1. During the lifecycle, `redrawControl()` marks a control (key `"\0"`) or a named + snippet as invalid. +2. After the terminating `AbortException` lands in `run()`, the AJAX block sets + `payload->state`, and — when the response is a `TextResponse` and something is + invalid — flips `snippetMode = true` and sends the `TextResponse` **right + there**. Since `sendTemplate` only stores the response and aborts, this is the + template's **only** render, and it happens in snippet mode. Then `sendPayload()` + replaces `$this->response` with a `JsonResponse` of the payload — that is what + the presenter returns to `Application`. +3. Every compiled template starts with a prolog injected by + `UIExtension::snippetRenderingPass`: it calls + `SnippetRuntime::renderSnippets(...)` and **returns early when it rendered** — + in snippet mode the main template body never runs. +4. `renderSnippets` renders only the invalid snippet blocks (into + `payload->snippets[]`, captured via output buffering in `enter`/`leave`), + then `renderChildren` walks the component tree and recursively renders every + invalid child `Control` with its `snippetMode` on. + +## Invariants & traps + +- **Invalidation propagates upward:** `isControlInvalid()` with no argument walks + the whole `Renderable` subtree — an invalid child makes the parent report + invalid. A named query falls back to the whole-control `"\0"` flag. +- **Rendering a snippet clears its flag:** `SnippetRuntime::enter()` calls + `redrawControl($name, redraw: false)`. Re-rendering the same snippet twice in one + request therefore captures it once. +- **Dynamic snippets are valid only inside a static `{snippet}`/`{snippetArea}`** + (`E_USER_WARNING` otherwise). A `snippetArea` only relays: its own wrapper output + is discarded, only inner snippets are captured. +- **The payload contract** (what nette/naja-style clients consume): `snippets`, + `state` (always set on AJAX — the global persistent state, see + persistent-state.md), and `redirect` — on AJAX, `redirectUrl()` does not send an + HTTP redirect but puts the URL into `payload->redirect` and sends the payload. +- Snippet element ids are `snippet--` (`getSnippetId`). +- `{snippet}`/`{snippetArea}` compile to `enter`/`leave` calls + (`Nodes/SnippetNode.php`); the driver instance comes from the `snippetDriver` + provider and exists only when the template has a control. diff --git a/docs/internals/templates.md b/docs/internals/templates.md new file mode 100644 index 000000000..0f2bfd2a5 --- /dev/null +++ b/docs/internals/templates.md @@ -0,0 +1,56 @@ +# Template & layout lookup + +Templates are found by convention; the candidate paths and their order are the only +non-obvious part. + +## Action template candidates (`formatTemplateFiles`) + +Starting from the presenter's own directory: + +- **If there is no `templates/` subdirectory**, the lookup climbs one level; if + there is still none, the single candidate is `"/.latte"` (the + "template next to the presenter" layout). +- **If a `templates/` subdirectory exists**, two candidates are tried, in order: + 1. `"/templates//.latte"` + 2. `"/templates/..latte"` + +`findTemplateFile()` returns the first that `is_file`, else calls `error()` → **404 +"Missing template"**. Remember (see lifecycle.md) this resolution happens **lazily +at `sendTemplate`**, via `completeTemplate`, only when the template has no explicit +file. + +## Layout candidates (`formatLayoutTemplateFiles`) + +- A `$layout` containing a slash (or backslash) is taken as a direct path. +- Otherwise the base name is `` (default `layout`). With a `templates/` + directory the candidates are `templates//@.latte` and + `templates/.@.latte` (once, at the presenter's level only), + then the shared `templates/@.latte` repeated **up the module levels** + (`substr_count(name, ':')` levels of `dirname`). Without a `templates/` + directory, `@.latte` next to the presenter, then walking up the same way. +- `setLayout(false)` disables the layout entirely (returns `null`); an explicitly + named layout that is not found throws `FileNotFoundException`, while the default + layout being absent is simply `null` (optional). + +## Typed templates + +`formatTemplateClass` derives the template class from the presenter/control name: +for a presenter it tries `Template` then `Template` (stripping +the `Presenter` suffix); for a control, `Template` (stripping `Control`). The +class is validated (`is_a(..., Template::class)`) or a `trigger_error` degrades to +the default. `TemplateFactory::createTemplate` instantiates it around a fresh Latte +engine and injects the default variables (`user`, `baseUrl`, `basePath`, `flashes`, +`control`, `presenter`). + +## `#[TemplateVariable]` + +At `sendTemplate`, `completeTemplate()` copies every `#[TemplateVariable]` property +of the presenter into the template via `$template->$name ??= $this->$name` — an +explicitly assigned template variable wins. The property must be **public** +(`LogicException` otherwise); uninitialized properties are skipped (unless they +have a get hook, PHP 8.4+). Presenter-only — plain controls don't run +`completeTemplate`. + +The `{templateType}` Latte tag is **not** in this package — it is implemented in +Latte core; here the typing is only the name-derived class plus the +`@property-read Template $template` docblock. diff --git a/phpstan.neon b/phpstan.neon index 89a7fdc1d..43dbd8da5 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -95,11 +95,6 @@ parameters: count: 1 path: src/Application/UI/Presenter.php - # Nette Forms compatibility - methods differ between versions - - - identifier: method.notFound - path: src/Application/UI/Form.php - # Control::$template->flashes is set dynamically for flash messages - identifier: property.notFound @@ -211,3 +206,10 @@ parameters: - offsetAccess.notFound - closure.unusedUse - identical.alwaysFalse + + # Tracy routing agent panel: compiled Latte template with recursive block array (self-referencing $_blocks) + - + path: src/Bridges/ApplicationTracy/dist/panel.agent.phtml + identifiers: + - offsetAccess.notFound + - closure.unusedUse diff --git a/readme.md b/readme.md index 41ddf658c..b4b61840e 100644 --- a/readme.md +++ b/readme.md @@ -2,7 +2,7 @@ Nette Application MVC ===================== [![Downloads this Month](https://img.shields.io/packagist/dm/nette/application.svg)](https://packagist.org/packages/nette/application) -[![Tests](https://github.com/nette/application/actions/workflows/tests.yml/badge.svg?branch=v3.3)](https://github.com/nette/application/actions) +[![Tests](https://github.com/nette/application/actions/workflows/tests.yml/badge.svg?branch=master)](https://github.com/nette/application/actions) [![Latest Stable Version](https://poser.pugx.org/nette/application/v/stable)](https://github.com/nette/application/releases) [![License](https://img.shields.io/badge/license-New%20BSD-blue.svg)](https://github.com/nette/application/blob/master/license.md) diff --git a/src/Application/LinkGenerator.php b/src/Application/LinkGenerator.php index dcdd5cc17..34fc86089 100644 --- a/src/Application/LinkGenerator.php +++ b/src/Application/LinkGenerator.php @@ -210,8 +210,9 @@ public function createRequest( $globalState = $refPresenter->getGlobalState($path === 'this' ? null : $presenterClass); if ($current && $args) { $tmp = $globalState + $refPresenter->getParameters(); + $serialize = fn($val) => http_build_query([$val instanceof \BackedEnum ? $val->value : $val]); foreach ($args as $key => $val) { - if (http_build_query([$val]) !== (isset($tmp[$key]) ? http_build_query([$tmp[$key]]) : '')) { + if ($serialize($val) !== (isset($tmp[$key]) ? $serialize($tmp[$key]) : '')) { $current = false; break; } @@ -246,6 +247,12 @@ public function createRequest( $args[UI\Presenter::FlashKey] = is_string($flashKey) && $flashKey !== '' ? $flashKey : null; } + foreach ($args as $key => $val) { + if ($val instanceof \BackedEnum) { // URL carries the case value + $args[$key] = $val->value; + } + } + return $this->lastRequest = new Request($presenter, Request::FORWARD, $args, flags: ['current' => $current]); } @@ -326,9 +333,7 @@ private function validateLinkTarget( : " from '{$presenter->getName()}:{$presenter->getAction()}'"; if ($mode !== 'forward' && !(new UI\AccessPolicy($element))->isLinkable()) { throw new UI\InvalidLinkException("Link to forbidden $message."); - } elseif ($presenter?->invalidLinkMode - && (UI\ComponentReflection::parseAnnotation($element, 'deprecated') || $element->getAttributes(Attributes\Deprecated::class)) - ) { + } elseif ($presenter?->invalidLinkMode && $element->getAttributes(Attributes\Deprecated::class)) { trigger_error("Link to deprecated $message.", E_USER_DEPRECATED); } } diff --git a/src/Application/Routers/RouteList.php b/src/Application/Routers/RouteList.php index ad683046f..55ce0b1e7 100644 --- a/src/Application/Routers/RouteList.php +++ b/src/Application/Routers/RouteList.php @@ -69,10 +69,10 @@ public function addRoute( #[Language('TEXT')] string $mask, array|string|\Closure $metadata = [], - int|bool $oneWay = 0, + bool $oneWay = false, ): static { - $this->add(new Route($mask, $metadata), (int) $oneWay); + $this->add(new Route($mask, $metadata), $oneWay); return $this; } diff --git a/src/Application/UI/AccessPolicy.php b/src/Application/UI/AccessPolicy.php index ecfd7f995..39ded71dc 100644 --- a/src/Application/UI/AccessPolicy.php +++ b/src/Application/UI/AccessPolicy.php @@ -70,7 +70,6 @@ private function applyInternalRules(array $attrs, Component $component): array if ( $this->element instanceof \ReflectionMethod && str_starts_with($this->element->getName(), $component::formatSignalMethod('')) - && !ComponentReflection::parseAnnotation($this->element, 'crossOrigin') && !Nette\Utils\Arrays::some($attrs, fn($attr) => $attr->sameOrigin === false) ) { $attrs[] = new Attributes\Requires(sameOrigin: true); diff --git a/src/Application/UI/Component.php b/src/Application/UI/Component.php index a9850fe1d..643a17742 100644 --- a/src/Application/UI/Component.php +++ b/src/Application/UI/Component.php @@ -71,18 +71,28 @@ public function getUniqueId(): string } + public function addComponent( + Nette\ComponentModel\IComponent $component, + ?string $name, + ?string $insertBefore = null, + ): static + { + if (!$component instanceof SignalReceiver && !$component instanceof StatePersistent) { + throw new Nette\InvalidStateException("Component '$name' of type " . get_debug_type($component) . ' is not intended to be used in the Presenter.'); + } + + return parent::addComponent($component, $name, $insertBefore = null); + } + + protected function createComponent(string $name): ?Nette\ComponentModel\IComponent { if (method_exists($this, $method = 'createComponent' . $name)) { - (new AccessPolicy(new \ReflectionMethod($this, $method)))->checkAccess($this); - } - $res = parent::createComponent($name); - if ($res && !$res instanceof SignalReceiver && !$res instanceof StatePersistent) { - $type = $res::class; - trigger_error("It seems that component '$name' of type $type is not intended to be used in the Presenter."); + $rm = new \ReflectionMethod($this, $method); + (new AccessPolicy($rm))->checkAccess($this); + $this->checkRequirements($rm); } - - return $res; + return parent::createComponent($name); } @@ -164,9 +174,9 @@ public function loadState(array $params): void )); } - $this->$name = $params[$name]; + $this->$name = &$params[$name]; } else { - $params[$name] = $this->$name ?? null; + $params[$name] = &$this->$name; } } @@ -217,7 +227,7 @@ public function saveStatePartial(array &$params, ComponentReflection $reflection )); } - if ($params[$name] === $meta['def'] || ($meta['def'] === null && $params[$name] === '')) { + if ($params[$name] === $meta['def']) { $params[$name] = null; // value transmit is unnecessary } } @@ -243,7 +253,7 @@ final public function getParameter(string $name): mixed */ final public function getParameters(): array { - return $this->params; + return array_map(fn($item) => $item, $this->params); } @@ -300,7 +310,7 @@ public function link(string $destination, $args = []): string return $this->getPresenter()->getLinkGenerator()->link($destination, $args, $this, 'link'); } catch (InvalidLinkException $e) { - return $this->getPresenter()->handleInvalidLink($e); + return $this->getPresenter()->processInvalidLink($e); } } @@ -346,10 +356,9 @@ public function isLinkCurrent(?string $destination = null, $args = []): bool * Redirect to another presenter, action or signal. * @param string $destination in format "[//] [[[module:]presenter:]action | signal! | this] [#fragment]" * @param array|mixed $args - * @return never * @throws Nette\Application\AbortException */ - public function redirect(string $destination, $args = []): void + public function redirect(string $destination, $args = []): never { $args = func_num_args() < 3 && is_array($args) ? $args @@ -370,10 +379,9 @@ public function redirect(string $destination, $args = []): void * Permanently redirects to presenter, action or signal. * @param string $destination in format "[//] [[[module:]presenter:]action | signal! | this] [#fragment]" * @param array|mixed $args - * @return never * @throws Nette\Application\AbortException */ - public function redirectPermanent(string $destination, $args = []): void + public function redirectPermanent(string $destination, $args = []): never { $args = func_num_args() < 3 && is_array($args) ? $args @@ -394,7 +402,7 @@ public function redirectPermanent(string $destination, $args = []): void * @throws Nette\Application\BadRequestException * @return never */ - public function error(string $message = '', int $httpCode = Nette\Http\IResponse::S404_NotFound): void + public function error(string $message = '', int $httpCode = Nette\Http\IResponse::S404_NotFound): never { throw new Nette\Application\BadRequestException($message, $httpCode); } diff --git a/src/Application/UI/ComponentReflection.php b/src/Application/UI/ComponentReflection.php index 3ba879fac..8de13c270 100644 --- a/src/Application/UI/ComponentReflection.php +++ b/src/Application/UI/ComponentReflection.php @@ -48,10 +48,7 @@ public function getParameters(): array foreach ($this->getProperties(\ReflectionProperty::IS_PUBLIC) as $prop) { if ($prop->isStatic()) { continue; - } elseif ( - self::parseAnnotation($prop, 'persistent') - || $prop->getAttributes(Attributes\Persistent::class) - ) { + } elseif ($prop->getAttributes(Attributes\Persistent::class)) { $params[$prop->getName()] = [ 'def' => $prop->hasDefaultValue() ? $prop->getDefaultValue() : null, 'type' => ParameterConverter::getType($prop), @@ -92,7 +89,7 @@ public function getPersistentParams(): array /** * Returns array of persistent components. They are tagged with class-level attribute - * #[Persistent] or annotation @persistent or returned by Presenter::getPersistentComponents(). + * #[Persistent] or returned by Presenter::getPersistentComponents(). * @return array */ public function getPersistentComponents(): array @@ -104,9 +101,7 @@ public function getPersistentComponents(): array } $attrs = $this->getAttributes(Attributes\Persistent::class); - $names = $attrs - ? $attrs[0]->getArguments() - : (array) self::parseAnnotation($this, 'persistent'); + $names = $attrs ? $attrs[0]->getArguments() : []; $names = array_merge($names, $class::getPersistentComponents()); $components = array_fill_keys($names, ['since' => $class]); @@ -174,77 +169,6 @@ public function getSignalMethod(string $signal): ?\ReflectionMethod } - /** - * Returns an annotation value. - * @deprecated - * @return ?list - */ - public static function parseAnnotation(\Reflector $ref, string $name): ?array - { - if (!preg_match_all('#[\s*]@' . preg_quote($name, '#') . '(?:\(\s*([^)]*)\s*\)|\s|$)#', (string) $ref->getDocComment(), $m)) { - return null; - } - - $tokens = ['true' => true, 'false' => false, 'null' => null]; - $res = []; - foreach ($m[1] as $s) { - foreach (preg_split('#\s*,\s*#', $s, -1, PREG_SPLIT_NO_EMPTY) ?: ['true'] as $item) { - $res[] = array_key_exists($tmp = strtolower($item), $tokens) - ? $tokens[$tmp] - : $item; - } - } - - $alt = match ($name) { - 'persistent' => '#[Nette\Application\Attributes\Persistent]', - 'deprecated' => '#[Nette\Application\Attributes\Deprecated]', - 'crossOrigin' => '#[Nette\Application\Attributes\Requires(sameOrigin: false)]', - default => 'alternative' - }; - trigger_error("Annotation @$name is deprecated, use $alt (used in " . Reflection::toString($ref) . ')', E_USER_DEPRECATED); - return $res; - } - - - #[\Deprecated] - public function hasAnnotation(string $name): bool - { - return (bool) self::parseAnnotation($this, $name); - } - - - #[\Deprecated] - public function getAnnotation(string $name): mixed - { - $res = self::parseAnnotation($this, $name); - return $res ? end($res) : null; - } - - - public function getMethod($name): MethodReflection - { - return new MethodReflection($this->getName(), $name); - } - - - /** - * @return list - */ - public function getMethods($filter = -1): array - { - foreach ($res = parent::getMethods($filter) as $key => $val) { - $res[$key] = new MethodReflection($this->getName(), $val->getName()); - } - - return $res; - } - - - /** - * @deprecated - * @param array $args - * @return list - */ #[\Deprecated] public static function combineArgs(\ReflectionFunctionAbstract $method, array $args): array { diff --git a/src/Application/UI/Form.php b/src/Application/UI/Form.php index b0309f43a..0f3ac70c0 100644 --- a/src/Application/UI/Form.php +++ b/src/Application/UI/Form.php @@ -17,6 +17,7 @@ class Form extends Nette\Forms\Form implements SignalReceiver { /** @var array Occurs when form is attached to presenter */ public array $onAnchor = []; + private ?Nette\Http\FetchSite $allowedOrigin = Nette\Http\FetchSite::SameOrigin; public function __construct(?Nette\ComponentModel\IContainer $parent = null, ?string $name = null) @@ -26,6 +27,15 @@ public function __construct(?Nette\ComponentModel\IContainer $parent = null, ?st } + /** + * The form is anchored via a presenter; the source is set when the form is attached to it. + */ + protected function createDefaultSource(): ?Nette\Forms\SubmissionSource + { + return null; + } + + protected function validateParent(Nette\ComponentModel\IContainer $parent): void { parent::validateParent($parent); @@ -39,13 +49,8 @@ protected function validateParent(Nette\ComponentModel\IContainer $parent): void $this->setAction(new Link($presenter, 'this')); } - $controls = $this->getControls(); - if (iterator_count($controls) && $this->isSubmitted()) { - foreach ($controls as $control) { - if (!$control->isDisabled()) { - $control->loadHttpData(); - } - } + if (!$this->isAnchored()) { // the write-once source survives re-anchoring, it resolves the current presenter itself + $this->setSubmissionSource(new FormSubmissionSource); // lets already attached controls load their values } Nette\Utils\Arrays::invoke($this->onAnchor, $this); @@ -81,11 +86,11 @@ public function hasPresenter(): bool /** - * Tells if the form is anchored. + * Disables the same-origin (Sec-Fetch) CSRF check, allowing cross-origin form submissions. */ - public function isAnchored(): bool + public function allowCrossOrigin(): void { - return (bool) $this->getPresenter(throw: false); + $this->allowedOrigin = null; } @@ -96,38 +101,6 @@ public function disableSameSiteProtection(): void } - /** - * Internal: returns submitted HTTP data or null when form was not submitted. - */ - protected function receiveHttpData(): ?array - { - $presenter = $this->getPresenter(); - if (!$presenter->isSignalReceiver($this, 'submit')) { - return null; - } - - $request = $presenter->getRequest(); - if ($request->isMethod('forward') || $request->isMethod('post') !== $this->isMethod('post')) { - return null; - } - - return $this->isMethod('post') - ? Nette\Utils\Arrays::mergeTree($request->getPost(), $request->getFiles()) - : $request->getParameters(); - } - - - protected function beforeRender(): void - { - parent::beforeRender(); - $key = ($this->isMethod('post') ? '_' : '') . Presenter::SignalKey; - if (!isset($this[$key]) && $this->getAction() !== '') { - $do = $this->lookupPath(Presenter::class) . self::NameSeparator . 'submit'; - $this[$key] = (new Nette\Forms\Controls\HiddenField($do))->setOmitted(); - } - } - - /********************* interface SignalReceiver ****************d*g**/ @@ -141,7 +114,7 @@ public function signalReceived(string $signal): void $class = static::class; throw new BadSignalException("Missing handler for signal '$signal' in $class."); - } elseif (!$this->crossOrigin && !$presenter->getHttpRequest()->isFrom(Nette\Http\FetchSite::SameOrigin)) { + } elseif ($this->allowedOrigin && !$presenter->getHttpRequest()->isFrom($this->allowedOrigin)) { $presenter->detectedCsrf(); } elseif (!$presenter->getRequest()->hasFlag(Nette\Application\Request::RESTORED)) { diff --git a/src/Application/UI/FormSubmissionSource.php b/src/Application/UI/FormSubmissionSource.php new file mode 100644 index 000000000..8a527717d --- /dev/null +++ b/src/Application/UI/FormSubmissionSource.php @@ -0,0 +1,51 @@ +lookup(Presenter::class); + if (!$presenter->isSignalReceiver($form, 'submit')) { + return null; + } + + $request = $presenter->getRequest(); + if ($request->isMethod('forward') || $request->isMethod('post') !== $form->isMethod('post')) { + return null; + } + + return $form->isMethod('post') + ? Nette\Utils\Arrays::mergeTree($request->getPost(), $request->getFiles()) + : $request->getParameters(); + } + + + /** + * Installs the hidden field carrying the 'submit' signal. + */ + public function prepare(Nette\Forms\Form $form): void + { + $key = ($form->isMethod('post') ? '_' : '') . Presenter::SignalKey; + if (!isset($form[$key]) && $form->getAction() !== '') { + $do = $form->lookupPath(Presenter::class) . Nette\ComponentModel\IComponent::NameSeparator . 'submit'; + $form[$key] = (new Nette\Forms\Controls\HiddenField($do))->setOmitted(); + } + } +} diff --git a/src/Application/UI/MethodReflection.php b/src/Application/UI/MethodReflection.php deleted file mode 100644 index 3bd60cdae..000000000 --- a/src/Application/UI/MethodReflection.php +++ /dev/null @@ -1,31 +0,0 @@ -isDefaultValueAvailable() - ? $param->getDefaultValue() - : null; - if ($args[$name] === $def || ($def === null && $args[$name] === '')) { + $def = $param->isDefaultValueAvailable() ? $param->getDefaultValue() : null; + if ($args[$name] === $def) { $args[$name] = null; // value transmit is unnecessary } } @@ -153,7 +151,8 @@ public static function convertType(mixed &$val, string $types): bool $type === 'scalar' => !is_array($val), // special type due to historical reasons $type === 'mixed' => true, $type === 'callable' => false, // intentionally disabled for security reasons - default => $val instanceof $type, + default => $val instanceof $type + || (is_subclass_of($type, \BackedEnum::class) && self::castEnum($val, $type)), }) { return true; } @@ -189,6 +188,29 @@ private static function castScalar(mixed &$val, string $type): bool } + /** + * Lossless conversion of scalar to backed enum case. + * @param class-string<\BackedEnum> $type + */ + private static function castEnum(mixed &$val, string $type): bool + { + if (!is_scalar($val)) { + return false; + } + + $tmp = $val; + if ( + !self::castScalar($tmp, (string) (new \ReflectionEnum($type))->getBackingType()) + || !($case = $type::tryFrom($tmp)) + ) { + return false; + } + + $val = $case; + return true; + } + + public static function getType(\ReflectionParameter|\ReflectionProperty $item): string { if ($type = $item->getType()) { diff --git a/src/Application/UI/Presenter.php b/src/Application/UI/Presenter.php index c298d4433..625b1e71d 100644 --- a/src/Application/UI/Presenter.php +++ b/src/Application/UI/Presenter.php @@ -123,11 +123,6 @@ abstract class Presenter extends Control implements Application\IPresenter private readonly LinkGenerator $linkGenerator; - public function __construct() - { - } - - final public function getRequest(): Application\Request { return $this->request ?? throw new Nette\InvalidStateException('Request is not set, presenter is not running.'); @@ -491,9 +486,8 @@ public function setLayout(string|bool $layout): static /** * @throws Nette\Application\AbortException - * @return never */ - public function sendTemplate(?Template $template = null): void + public function sendTemplate(?Template $template = null): never { $template ??= $this->getTemplate(); $this->completeTemplate($template); @@ -683,9 +677,8 @@ public function isAjax(): bool /** * Sends AJAX payload to the output. * @throws Nette\Application\AbortException - * @return never */ - public function sendPayload(): void + public function sendPayload(): never { $this->sendResponse(new Responses\JsonResponse($this->getPayload())); } @@ -694,9 +687,8 @@ public function sendPayload(): void /** * Sends JSON data to the output. * @throws Nette\Application\AbortException - * @return never */ - public function sendJson(mixed $data): void + public function sendJson(mixed $data): never { $this->sendResponse(new Responses\JsonResponse($data)); } @@ -708,9 +700,8 @@ public function sendJson(mixed $data): void /** * Sends response and terminates presenter. * @throws Nette\Application\AbortException - * @return never */ - public function sendResponse(Application\Response $response): void + public function sendResponse(Application\Response $response): never { $this->response = $response; $this->terminate(); @@ -720,9 +711,8 @@ public function sendResponse(Application\Response $response): void /** * Correctly terminates presenter. * @throws Nette\Application\AbortException - * @return never */ - public function terminate(): void + public function terminate(): never { throw new Application\AbortException; } @@ -732,9 +722,8 @@ public function terminate(): void * Forward to another presenter or action. * @param array|mixed $args * @throws Nette\Application\AbortException - * @return never */ - public function forward(string|Nette\Application\Request $destination, $args = []): void + public function forward(string|Nette\Application\Request $destination, $args = []): never { if ($destination instanceof Application\Request) { $this->sendResponse(new Responses\ForwardResponse($destination)); @@ -756,9 +745,8 @@ public function forward(string|Nette\Application\Request $destination, $args = [ /** * Redirect to another URL and ends presenter execution. * @throws Nette\Application\AbortException - * @return never */ - public function redirectUrl(string $url, ?int $httpCode = null): void + public function redirectUrl(string $url, ?int $httpCode = null): never { if ($this->isAjax()) { $this->getPayload()->redirect = $url; @@ -884,7 +872,7 @@ protected function requestToUrl(Application\Request $request, ?bool $relative = * Invalid link handler. Descendant can override this method to change default behaviour. * @throws InvalidLinkException */ - protected function handleInvalidLink(InvalidLinkException $e): string + protected function processInvalidLink(InvalidLinkException $e): string { if ($this->invalidLinkMode & self::InvalidLinkException) { throw $e; diff --git a/src/Bridges/ApplicationDI/ApplicationExtension.php b/src/Bridges/ApplicationDI/ApplicationExtension.php index 714b7f20e..d3a13527e 100644 --- a/src/Bridges/ApplicationDI/ApplicationExtension.php +++ b/src/Bridges/ApplicationDI/ApplicationExtension.php @@ -293,7 +293,7 @@ private function checkPresenter(string $class): void $re = $class::formatActionMethod('') . '.|' . $class::formatRenderMethod('') . '.|' . $class::formatSignalMethod('') . '.'; foreach ($rc->getMethods() as $rm) { - if (preg_match("#^(?!handleInvalidLink)($re)#", $rm->getName()) && (!$rm->isPublic() || $rm->isStatic())) { + if (preg_match("#^$re#", $rm->getName()) && (!$rm->isPublic() || $rm->isStatic())) { throw new Nette\InvalidStateException(sprintf('Method %s: this method must be public non-static.', Reflection::toString($rm))); } elseif (preg_match('#^createComponent.#', $rm->getName()) && ($rm->isPrivate() || $rm->isStatic())) { throw new Nette\InvalidStateException(sprintf('Method %s: this method must be non-private non-static.', Reflection::toString($rm))); diff --git a/src/Bridges/ApplicationDI/LatteExtension.php b/src/Bridges/ApplicationDI/LatteExtension.php index 3799bd581..f2400e0a5 100644 --- a/src/Bridges/ApplicationDI/LatteExtension.php +++ b/src/Bridges/ApplicationDI/LatteExtension.php @@ -21,7 +21,6 @@ * * @property object{ * debugger: bool|'all'|null, - * macros: array, * extensions: array, * templateClass: string|null, * strictTypes: bool, diff --git a/src/Bridges/ApplicationLatte/Nodes/ControlNode.php b/src/Bridges/ApplicationLatte/Nodes/ControlNode.php index d2ec5e227..e79b999d0 100644 --- a/src/Bridges/ApplicationLatte/Nodes/ControlNode.php +++ b/src/Bridges/ApplicationLatte/Nodes/ControlNode.php @@ -21,7 +21,7 @@ /** - * {control name[:method] [,] [params]} + * {control name[:method] [, params]} * Renders a component by calling its render() method. */ class ControlNode extends StatementNode @@ -43,7 +43,7 @@ public static function create(Tag $tag): static $node->method = $tag->parser->parseExpression(); } - $stream->tryConsume(','); + $tag->parser->consumeCommaBeforeArguments(strict: false); $start = $stream->getIndex(); $node->args = $tag->parser->parseArguments(); $start -= $stream->getIndex(); diff --git a/src/Bridges/ApplicationLatte/Nodes/TemplatePrintNode.php b/src/Bridges/ApplicationLatte/Nodes/TemplatePrintNode.php deleted file mode 100644 index 6958684e1..000000000 --- a/src/Bridges/ApplicationLatte/Nodes/TemplatePrintNode.php +++ /dev/null @@ -1,59 +0,0 @@ -getParameters(), ' . PhpHelpers::dump($this->template ?? Template::class) . '); exit;'; - } - - - /** @param array $params */ - public static function printClass(array $params, string $parentClass): void - { - $bp = new Latte\Essential\Blueprint; - $control = $params['control'] ?? $params['presenter'] ?? null; - $name = 'Template'; - if ($control instanceof UI\Control) { - $name = preg_replace('#(Control|Presenter)$#', '', $control::class) . 'Template'; - unset($params[$control instanceof UI\Presenter ? 'control' : 'presenter']); - } - $class = $bp->generateTemplateClass($params, $name, $parentClass); - $code = (string) $class->getNamespace(); - - $bp->printBegin(); - $bp->printCode($code); - - if ($control instanceof UI\Control) { - $controlFile = (new \ReflectionClass($control))->getFileName() - ?: throw new \LogicException('Cannot determine file of class ' . $control::class . '.'); - $file = dirname($controlFile) . '/' . $class->getName() . '.php'; - if (file_exists($file)) { - echo "unsaved, file {$bp->clickableFile($file)} already exists"; - } else { - echo "saved to file {$bp->clickableFile($file)}"; - file_put_contents($file, "printEnd(); - exit; - } -} diff --git a/src/Bridges/ApplicationLatte/TemplateFactory.php b/src/Bridges/ApplicationLatte/TemplateFactory.php index e51ba4db5..9fc483394 100644 --- a/src/Bridges/ApplicationLatte/TemplateFactory.php +++ b/src/Bridges/ApplicationLatte/TemplateFactory.php @@ -42,7 +42,7 @@ public function __construct( * @param ?class-string $class * @return ($class is null ? Template : T) */ - public function createTemplate(?UI\Control $control = null, ?string $class = null): UI\Template + public function createTemplate(?UI\Control $control = null, ?string $class = null): Template { $class ??= $this->templateClass; if (!is_a($class, Template::class, allow_string: true)) { diff --git a/src/Bridges/ApplicationLatte/UIExtension.php b/src/Bridges/ApplicationLatte/UIExtension.php index f8d19776e..cde6b117c 100644 --- a/src/Bridges/ApplicationLatte/UIExtension.php +++ b/src/Bridges/ApplicationLatte/UIExtension.php @@ -78,7 +78,6 @@ public function getTags(): array 'plink' => Nodes\LinkNode::create(...), 'link' => Nodes\LinkNode::create(...), 'linkBase' => Nodes\LinkBaseNode::create(...), - 'templatePrint' => Nodes\TemplatePrintNode::create(...), 'snippet' => Nodes\SnippetNode::create(...), 'snippetArea' => Nodes\SnippetAreaNode::create(...), 'layout' => $this->createExtendsNode(...), diff --git a/src/Bridges/ApplicationTracy/RoutingPanel.php b/src/Bridges/ApplicationTracy/RoutingPanel.php index 6ada0036d..aa27aece8 100644 --- a/src/Bridges/ApplicationTracy/RoutingPanel.php +++ b/src/Bridges/ApplicationTracy/RoutingPanel.php @@ -68,6 +68,19 @@ public function getPanel(): string } + public function getAgentInfo(): string + { + return Nette\Utils\Helpers::capture(function () { + $matched = $this->matched; + $routes = $this->routes; + $source = $this->matched ? $this->findSource() : null; + $url = $this->httpRequest->getUrl(); + $method = $this->httpRequest->getMethod(); + require __DIR__ . '/dist/panel.agent.phtml'; + }); + } + + /** @return array{path: string, domain: ?string, module: string, routes: array} */ private function analyse(Routing\RouteList $router, ?Nette\Http\IRequest $httpRequest): array { @@ -88,7 +101,7 @@ private function analyse(Routing\RouteList $router, ?Nette\Http\IRequest $httpRe continue; } - $matched = $flags[$i] & $router::ONE_WAY ? 'oneway' : 'no'; + $matched = empty($flags[$i]['oneWay']) ? 'no' : 'oneway'; $params = $e = null; try { if ( diff --git a/src/Bridges/ApplicationTracy/dist/panel.agent.phtml b/src/Bridges/ApplicationTracy/dist/panel.agent.phtml new file mode 100644 index 000000000..edd9d73b5 --- /dev/null +++ b/src/Bridges/ApplicationTracy/dist/panel.agent.phtml @@ -0,0 +1,131 @@ + $matched */ +/** @var array{path:string,domain:?string,module:string,routes:array} $routes */ +/** @var \ReflectionClass|\ReflectionMethod|string|null $source */ +/** @var Nette\Http\UrlScript $url */ +/** @var string $method */ +use Nette\Application\UI\Presenter; +echo "\n"; +$_blocks['route'] = function ($route,$path) use (&$_blocks) { + $mark = ['yes' => 'MATCHED', 'may' => 'MAY MATCH', 'no' => 'no match', 'oneway' => 'one-way', 'error' => 'ERROR'][$route->matched] ?? $route->matched /* pos 9:2 */; + $mask = $route->mask !== null ? $path . $route->mask : $route->class /* pos 10:2 */; + $defaults = [] /* pos 11:2 */; + foreach ($route->defaults as $k => $v) /* pos 12:2 */ { + $defaults[] = $k . '=' . (is_string($v) ? $v : (is_scalar($v) ? var_export($v, true) : get_debug_type($v))) /* pos 12:40 */; + } + + echo '- ['; + echo Tracy\Helpers::escapeMd($mark) /* pos 13:6 */; + echo '] `'; + echo $mask /* pos 13:16 */; + echo '`'; + if ($defaults) /* pos 13:33 */ { + echo ' ('; + echo Tracy\Helpers::escapeMd(implode(', ', $defaults)) /* pos 13:49 */; + echo ')'; + } + if ($route->error) /* pos 13:82 */ { + echo ' — '; + echo Tracy\Helpers::escapeMd($route->error->getMessage()) /* pos 13:105 */; + } + echo "\n"; +}; +echo "\n"; +$_blocks['routeList'] = function ($list,$path) use (&$_blocks) { + if ($list['domain']) /* pos 17:2 */ { + echo '- domain: `'; + echo Tracy\Helpers::escapeMd($list['domain']) /* pos 17:31 */; + echo '` +'; + } + if ($list['module']) /* pos 19:2 */ { + echo '- module: `'; + echo Tracy\Helpers::escapeMd($list['module']) /* pos 19:31 */; + echo '` +'; + } + $path .= $list['path'] /* pos 21:2 */; + foreach ($list['routes'] as $router) /* pos 22:2 */ { + if (is_array($router)) /* pos 23:3 */ { + $_blocks['routeList']($router,$path); + } else /* pos 25:3 */ { + $_blocks['route']($router,$path); + } + + } + +}; +echo ' +## Routing + +`'; +echo Tracy\Helpers::escapeMd($method) /* pos 33:2 */; +echo ' '; +echo Tracy\Helpers::escapeMd($url->getAbsoluteUrl()) /* pos 33:12 */; +echo '` + +'; +if ($matched === null) /* pos 35:1 */ { + echo '**Matched:** no route +'; +} else /* pos 37:1 */ { + $target = ($matched[Presenter::PresenterKey] ?? '?') . ':' . ($matched[Presenter::ActionKey] ?? Presenter::DefaultAction) /* pos 38:2 */; + echo '**Matched:** '; + echo Tracy\Helpers::escapeMd($target) /* pos 39:15 */; + if (isset($matched[Presenter::SignalKey])) /* pos 39:24 */ { + echo Tracy\Helpers::escapeMd($matched[Presenter::SignalKey]) /* pos 39:66 */; + echo '!'; + } + echo "\n"; + if (is_string($source)) /* pos 40:2 */ { + echo '**Handler:** '; + echo Tracy\Helpers::escapeMd($source) /* pos 41:16 */; + echo ' (class not found) +'; + } elseif ($source instanceof \ReflectionMethod) /* pos 42:2 */ { + echo '**Handler:** '; + echo Tracy\Helpers::escapeMd($source->getDeclaringClass()->getName()) /* pos 43:16 */; + echo '::'; + echo Tracy\Helpers::escapeMd($source->getName()) /* pos 43:59 */; + echo '() in '; + echo Tracy\Helpers::escapeMd($source->getFileName()) /* pos 43:85 */; + echo ':'; + echo Tracy\Helpers::escapeMd($source->getStartLine()) /* pos 43:110 */; + echo "\n"; + } elseif ($source instanceof \ReflectionClass) /* pos 44:2 */ { + echo '**Handler:** '; + echo Tracy\Helpers::escapeMd($source->getName()) /* pos 45:16 */; + echo ' in '; + echo Tracy\Helpers::escapeMd($source->getFileName()) /* pos 45:40 */; + echo ':'; + echo Tracy\Helpers::escapeMd($source->getStartLine()) /* pos 45:65 */; + echo "\n"; + } + + + $skip = [Presenter::PresenterKey => 1, Presenter::ActionKey => 1, Presenter::SignalKey => 1] /* pos 47:2 */; + $extras = array_diff_key($matched, $skip) /* pos 48:2 */; + if ($extras) /* pos 49:2 */ { + echo '**Parameters:** +'; + foreach ($extras as $key => $value) /* pos 51:3 */ { + echo '- '; + echo Tracy\Helpers::escapeMd($key) /* pos 52:6 */; + echo ' = '; + echo Tracy\Helpers::escapeMd(is_string($value) ? $value : (is_scalar($value) ? var_export($value, true) : get_debug_type($value))) /* pos 52:15 */; + echo "\n"; + + } + + } +} +echo ' +### Routes +'; +if (empty($routes['routes'])) /* pos 58:1 */ { + echo '(none defined) +'; +} else /* pos 60:1 */ { + $_blocks['routeList']($routes,''); +} diff --git a/src/Bridges/ApplicationTracy/dist/panel.phtml b/src/Bridges/ApplicationTracy/dist/panel.phtml index 724c4db46..33a58af8a 100644 --- a/src/Bridges/ApplicationTracy/dist/panel.phtml +++ b/src/Bridges/ApplicationTracy/dist/panel.phtml @@ -224,9 +224,10 @@ if (empty($routes['routes'])) /* pos 111:2 */ { echo ' = '; if (is_string($value)) /* pos 155:28 */ { echo Tracy\Helpers::escapeHtml($value) /* pos 155:50 */; - echo '
'; + } else /* pos 155:58 */ { echo Dumper::toHtml($value, [Dumper::COLLAPSE => true, Dumper::LIVE => true]) /* pos 155:62 */; } + echo '
'; echo "\n"; } @@ -256,9 +257,10 @@ if (empty($routes['routes'])) /* pos 111:2 */ { echo ' = '; if (is_string($value)) /* pos 170:29 */ { echo Tracy\Helpers::escapeHtml($value) /* pos 170:51 */; - echo '
'; + } else /* pos 170:59 */ { echo Dumper::toHtml($value, [Dumper::COLLAPSE => true, Dumper::LIVE => true]) /* pos 170:63 */; } + echo '
'; echo "\n"; } diff --git a/src/Bridges/ApplicationTracy/panel.agent.latte b/src/Bridges/ApplicationTracy/panel.agent.latte new file mode 100644 index 000000000..8efd97b14 --- /dev/null +++ b/src/Bridges/ApplicationTracy/panel.agent.latte @@ -0,0 +1,62 @@ +{varType ?array $matched} +{varType array{path: string, domain: ?string, module: string, routes: array} $routes} +{varType \ReflectionClass|\ReflectionMethod|string|null $source} +{varType Nette\Http\UrlScript $url} +{varType string $method} +{use Nette\Application\UI\Presenter} + +{define route $route, $path} + {do $mark = [yes => 'MATCHED', may => 'MAY MATCH', no => 'no match', oneway => 'one-way', error => 'ERROR'][$route->matched] ?? $route->matched} + {do $mask = $route->mask !== null ? $path . $route->mask : $route->class} + {do $defaults = []} + {foreach $route->defaults as $k => $v}{do $defaults[] = $k . '=' . (is_string($v) ? $v : (is_scalar($v) ? var_export($v, true) : get_debug_type($v)))}{/foreach} + - [{$mark}] `{$mask|noescape}`{if $defaults} ({=implode(', ', $defaults)}){/if}{if $route->error} — {$route->error->getMessage()}{/if} +{/define} + +{define routeList $list, $path} + {if $list[domain]}- domain: `{$list[domain]}` + {/if} + {if $list[module]}- module: `{$list[module]}` + {/if} + {do $path .= $list[path]} + {foreach $list[routes] as $router} + {if is_array($router)} + {include routeList $router, $path} + {else} + {include route $router, $path} + {/if} + {/foreach} +{/define} + +## Routing + +`{$method} {$url->getAbsoluteUrl()}` + +{if $matched === null} + **Matched:** no route +{else} + {do $target = ($matched[Presenter::PresenterKey] ?? '?') . ':' . ($matched[Presenter::ActionKey] ?? Presenter::DefaultAction)} + **Matched:** {$target}{if isset($matched[Presenter::SignalKey])}{$matched[Presenter::SignalKey]}!{/if} + {if is_string($source)} + **Handler:** {$source} (class not found) + {elseif $source instanceof \ReflectionMethod} + **Handler:** {$source->getDeclaringClass()->getName()}::{$source->getName()}() in {$source->getFileName()}:{$source->getStartLine()} + {elseif $source instanceof \ReflectionClass} + **Handler:** {$source->getName()} in {$source->getFileName()}:{$source->getStartLine()} + {/if} + {do $skip = [Presenter::PresenterKey => 1, Presenter::ActionKey => 1, Presenter::SignalKey => 1]} + {do $extras = array_diff_key($matched, $skip)} + {if $extras} + **Parameters:** + {foreach $extras as $key => $value} + - {$key} = {is_string($value) ? $value : (is_scalar($value) ? var_export($value, true) : get_debug_type($value))} + {/foreach} + {/if} +{/if} + +### Routes +{if empty($routes[routes])} + (none defined) +{else} + {include routeList $routes, ''} +{/if} diff --git a/src/Bridges/ApplicationTracy/panel.latte b/src/Bridges/ApplicationTracy/panel.latte index 972bfd3f8..f749feb12 100644 --- a/src/Bridges/ApplicationTracy/panel.latte +++ b/src/Bridges/ApplicationTracy/panel.latte @@ -152,7 +152,7 @@
{foreach $route->defaults as $key => $value} - {$key} = {if is_string($value)}{$value}
{Dumper::toHtml($value, [Dumper::COLLAPSE => true, Dumper::LIVE => true])}{/if} + {$key} = {if is_string($value)}{$value}{else}{Dumper::toHtml($value, [Dumper::COLLAPSE => true, Dumper::LIVE => true])}{/if}
{/foreach}
@@ -167,7 +167,7 @@ {do $params = array_diff_key($params, [Presenter::PresenterKey => 1, Presenter::ActionKey => 1])} {/if} {foreach $params as $key => $value} - {$key} = {if is_string($value)}{$value}
{Dumper::toHtml($value, [Dumper::COLLAPSE => true, Dumper::LIVE => true])}{/if} + {$key} = {if is_string($value)}{$value}{else}{Dumper::toHtml($value, [Dumper::COLLAPSE => true, Dumper::LIVE => true])}{/if}
{/foreach} {elseif $route->error} diff --git a/tests/Bridges.Latte/{control}.2.phpt b/tests/Bridges.Latte/{control}.2.phpt index e9bda2a50..e7e85f31c 100644 --- a/tests/Bridges.Latte/{control}.2.phpt +++ b/tests/Bridges.Latte/{control}.2.phpt @@ -51,15 +51,15 @@ $latte->renderToString(' {control $name:$name} -{control form var1} +{control form, var1} -{control form var1, 1, 2} +{control form, var1, 1, 2} -{control form wrap => 5, 1, 2} +{control form, wrap => 5, 1, 2} -{control form [var1 => 5], 1, 2} +{control form, [var1 => 5], 1, 2} -{control form 5, 1, var1: 2} +{control form, 5, 1, var1: 2} ', $params); Assert::same([ diff --git a/tests/Bridges.Latte/{control}.commaWarning.phpt b/tests/Bridges.Latte/{control}.commaWarning.phpt new file mode 100644 index 000000000..1a5dd3281 --- /dev/null +++ b/tests/Bridges.Latte/{control}.commaWarning.phpt @@ -0,0 +1,53 @@ +setLoader(new Latte\Loaders\StringLoader); +$latte->addExtension(new Nette\Bridges\ApplicationLatte\UIExtension(null)); + + +test('no warning when only the component name is given', function () use ($latte) { + Assert::noError(fn() => $latte->compile('{control form}')); +}); + + +test('no warning when comma separates name and arguments', function () use ($latte) { + Assert::noError(fn() => $latte->compile('{control form, param => 1}')); +}); + + +test('deprecation warning when comma is missing before arguments', function () use ($latte) { + Assert::error( + fn() => $latte->compile('{control form param => 1}'), + E_USER_DEPRECATED, + 'Missing comma before tag arguments on line 1 at column 15.', + ); +}); + + +test('deprecation warning also fires after the :method syntax', function () use ($latte) { + Assert::error( + fn() => $latte->compile('{control form:type param}'), + E_USER_DEPRECATED, + 'Missing comma before tag arguments on line 1 at column 20.', + ); +}); + + +test('a malformed tag emits the deprecation together with the exception', function () use ($latte) { + Assert::error(function () use ($latte) { + Assert::exception( + fn() => $latte->compile('{control form: param => 1}'), + Latte\CompileException::class, + "Unexpected '=>' (on line 1 at column 22)", + ); + }, E_USER_DEPRECATED, 'Missing comma before tag arguments on line 1 at column 22.'); +}); diff --git a/tests/Bridges.Latte/{control}.phpt b/tests/Bridges.Latte/{control}.phpt index a3c59f235..dccddaaba 100644 --- a/tests/Bridges.Latte/{control}.phpt +++ b/tests/Bridges.Latte/{control}.phpt @@ -60,7 +60,7 @@ Assert::match( $ʟ_tmp->renderType('param') /* pos 1:1 */; %A% XX, - $latte->compile('{control form:type param}'), + $latte->compile('{control form:type, param}'), ); Assert::match( @@ -69,7 +69,7 @@ Assert::match( $ʟ_tmp->render(...$params) /* pos 1:1 */; %A% XX, - $latte->compile('{control form (expand) $params}'), + $latte->compile('{control form, (expand) $params}'), ); Assert::match( @@ -78,7 +78,7 @@ Assert::match( $ʟ_tmp->renderType(['param' => 123]) /* pos 1:1 */; %A% XX, - $latte->compile('{control form:type param => 123}'), + $latte->compile('{control form:type, param => 123}'), ); Assert::match( diff --git a/tests/Bridges.Tracy/RoutingPanel.getAgentInfo().phpt b/tests/Bridges.Tracy/RoutingPanel.getAgentInfo().phpt new file mode 100644 index 000000000..a6d75db5a --- /dev/null +++ b/tests/Bridges.Tracy/RoutingPanel.getAgentInfo().phpt @@ -0,0 +1,105 @@ +addRoute('sign-in', 'Sign:in'); +$router->withPath('admin/') + ->addRoute('', 'Admin:Home:default') + ->addRoute('/', ['module' => 'Admin']); +$router->addRoute('/'); + + +test('matched route', function () use ($router) { + $httpRequest = new Http\Request(new UrlScript('http://example.com/admin', '/')); + $panel = new RoutingPanel($router, $httpRequest, new PresenterFactory(function () {})); + $panel->getTab(); + + Assert::match( + <<<'XX' + %A% + ## Routing + + `GET http://example.com/admin` + + **Matched:** Admin:Home:default + %A% + + ### Routes + - [no match] `sign-in` (presenter=Sign, action=in) + - [MATCHED] `admin/` (presenter=Admin:Home, action=default) + - [no match] `admin//` (module=Admin, action=default) + - [MAY MATCH] `/` (action=default) + XX, + $panel->getAgentInfo(), + ); +}); + + +test('no matching route', function () { + $router = new RouteList; + $router->addRoute('sign-in', 'Sign:in'); + + $httpRequest = new Http\Request(new UrlScript('http://example.com/nowhere', '/')); + $panel = new RoutingPanel($router, $httpRequest, new PresenterFactory(function () {})); + $panel->getTab(); + + Assert::match( + <<<'XX' + %A% + ## Routing + + `GET http://example.com/nowhere` + + **Matched:** no route + + ### Routes + - [no match] `sign-in` (presenter=Sign, action=in) + XX, + $panel->getAgentInfo(), + ); +}); + + +test('no routes defined', function () { + $router = new RouteList; + + $httpRequest = new Http\Request(new UrlScript('http://example.com/', '/')); + $panel = new RoutingPanel($router, $httpRequest, new PresenterFactory(function () {})); + $panel->getTab(); + + Assert::match( + <<<'XX' + %A% + ## Routing + + `GET http://example.com/` + + **Matched:** no route + + ### Routes + (none defined) + XX, + $panel->getAgentInfo(), + ); +}); + + +test('matched with extra parameters', function () use ($router) { + $httpRequest = new Http\Request(new UrlScript('http://example.com/article/detail?id=42', '/')); + $panel = new RoutingPanel($router, $httpRequest, new PresenterFactory(function () {})); + $panel->getTab(); + + $output = $panel->getAgentInfo(); + Assert::contains('**Matched:** Article:detail', $output); + Assert::contains('**Parameters:**', $output); + Assert::contains('- id = 42', $output); +}); diff --git a/tests/Routers/RouteList.addRoute.phpt b/tests/Routers/RouteList.addRoute.phpt index 1e7dab56c..df90b9592 100644 --- a/tests/Routers/RouteList.addRoute.phpt +++ b/tests/Routers/RouteList.addRoute.phpt @@ -10,7 +10,7 @@ require __DIR__ . '/Route.php'; $list = new RouteList; $list->addRoute('foo', ['presenter' => 'foo'], RouteList::ONE_WAY); -$list->addRoute('bar', ['presenter' => 'bar'], RouteList::ONE_WAY); +$list->addRoute('bar', ['presenter' => 'bar'], oneWay: true); $list->addRoute('hello', ['presenter' => 'hello']); diff --git a/tests/UI/Component.redirect().phpt b/tests/UI/Component.redirect().phpt index 1e62b5c91..0ff9b0127 100644 --- a/tests/UI/Component.redirect().phpt +++ b/tests/UI/Component.redirect().phpt @@ -22,7 +22,7 @@ class TestPresenter extends Application\UI\Presenter } - public function sendResponse(Application\Response $response): void + public function sendResponse(Application\Response $response): never { parent::sendResponse($this->response = $response); } diff --git a/tests/UI/ComponentReflection.parseAnnotation.phpt b/tests/UI/ComponentReflection.parseAnnotation.phpt deleted file mode 100644 index d9151d743..000000000 --- a/tests/UI/ComponentReflection.parseAnnotation.phpt +++ /dev/null @@ -1,42 +0,0 @@ -sort); + + // non-default value is carried in URL as the case value + Assert::same('/index.php?sort=desc&action=default&presenter=Test', $this->link('this')); + + // enum instance and its value are accepted in args + Assert::same('/index.php?sort=desc&action=default&presenter=Test', $this->link('this', ['sort' => Sort::Desc])); + Assert::same('/index.php?sort=desc&action=default&presenter=Test', $this->link('this', ['sort' => 'desc'])); + + // default value is omitted + Assert::same('/index.php?action=default&presenter=Test', $this->link('this', ['sort' => Sort::Asc])); + $this->sort = Sort::Asc; + Assert::same('/index.php?action=default&presenter=Test', $this->link('this')); + + // enum in action method argument + Assert::same('/index.php?dir=desc&action=other&presenter=Test', $this->link('other', ['dir' => Sort::Desc])); + Assert::same('/index.php?dir=desc&action=other&presenter=Test', $this->link('other', ['dir' => 'desc'])); + Assert::same('/index.php?action=other&presenter=Test', $this->link('other', ['dir' => Sort::Asc])); + + $this->terminate(); + } + + + public function actionOther(Sort $dir = Sort::Asc): void + { + } +} + + +function createPresenter(): TestPresenter +{ + $url = new Http\UrlScript('http://localhost/index.php', '/index.php'); + $presenterFactory = Mockery::mock(Nette\Application\IPresenterFactory::class); + $presenterFactory->shouldReceive('getPresenterClass') + ->andReturnUsing(fn($presenter) => $presenter . 'Presenter'); + + $presenter = new TestPresenter; + $presenter->injectPrimary( + new Http\Request($url), + new Http\Response, + $presenterFactory, + new Application\Routers\SimpleRouter, + ); + $presenter->autoCanonicalize = false; + return $presenter; +} + + +createPresenter()->run(new Application\Request('Test', Http\Request::Get, ['sort' => 'desc'])); + + +Assert::exception( + fn() => createPresenter()->run(new Application\Request('Test', Http\Request::Get, ['sort' => 'bogus'])), + Application\BadRequestException::class, + "Value passed to persistent parameter 'sort' in presenter Test must be Sort, string given.", +); + + +Assert::exception( + fn() => createPresenter()->run(new Application\Request('Test', Http\Request::Get, ['sort' => ['name' => 'desc', 'value' => 'desc']])), + Application\BadRequestException::class, + "Value passed to persistent parameter 'sort' in presenter Test must be Sort, array given.", +); + + +Assert::exception( + fn() => createPresenter()->run(new Application\Request('Test', Http\Request::Get, ['action' => 'other', 'dir' => 'bogus'])), + Application\BadRequestException::class, + 'Argument $dir passed to TestPresenter::actionOther() must be Sort, string given.', +); diff --git a/tests/UI/Presenter.link().phpt b/tests/UI/Presenter.link().phpt index 37223f8ee..0e0e59fb7 100644 --- a/tests/UI/Presenter.link().phpt +++ b/tests/UI/Presenter.link().phpt @@ -113,7 +113,7 @@ class TestPresenter extends Application\UI\Presenter $this->absoluteUrls = false; // persistent params - Assert::same('/index.php?action=params&presenter=Test', $this->link('params', ['pint' => $this->pint, 'p' => ''])); + Assert::same('/index.php?p=&action=params&presenter=Test', $this->link('params', ['pint' => $this->pint, 'p' => ''])); Assert::same('/index.php?pint=20&parr%5B0%5D=1&action=params&presenter=Test', $this->link('params', ['pint' => $this->pint * 2, 'pbool' => true, 'parr' => [1]])); Assert::same(['pint' => 20, 'pbool' => null, 'parr' => [1], 'action' => 'params'], $this->getLastCreatedRequest()->getParameters()); Assert::same('/index.php?pint=1&pbool=0&action=params&presenter=Test', $this->link('params', ['pint' => true, 'pbool' => '0', 'parr' => []])); @@ -150,8 +150,8 @@ class TestPresenter extends Application\UI\Presenter Assert::same('/index.php?mycontrol-x=0a&mycontrol-y=1a&action=default&do=mycontrol-click&presenter=Test', $this['mycontrol']->link('click', '0a', '1a')); Assert::same('/index.php?mycontrol-x=1&action=default&do=mycontrol-click&presenter=Test', $this['mycontrol']->link('click', [1])); Assert::same('#error: Argument $x passed to TestControl::handleClick() must be scalar, array given.', $this['mycontrol']->link('click', [1], (object) [1])); - Assert::same('/index.php?mycontrol-x=1&mycontrol-y=0&action=default&do=mycontrol-click&presenter=Test', $this['mycontrol']->link('click', true, false)); - Assert::same('/index.php?action=default&do=mycontrol-click&presenter=Test', $this['mycontrol']->link('click', null, '')); + Assert::same('/index.php?mycontrol-x=1&mycontrol-y=0&action=default&do=mycontrol-click&presenter=Test', $this['mycontrol']->link('click', TRUE, FALSE)); + Assert::same('/index.php?mycontrol-y=&action=default&do=mycontrol-click&presenter=Test', $this['mycontrol']->link('click', NULL, '')); Assert::same('#error: Passed more parameters than method TestControl::handleClick() expects.', $this['mycontrol']->link('click', 1, 2, 3)); Assert::same('http://localhost/index.php?mycontrol-x=1&mycontrol-round=1&action=default&presenter=Test#frag', $this['mycontrol']->link('//this#frag', ['x' => 1, 'round' => 1])); Assert::same('/index.php?mycontrol-x=1&mycontrol-y=2&action=default&do=mycontrol-click&presenter=Test', $this->link('mycontrol:click!', ['x' => 1, 'y' => 2, 'round' => 0])); @@ -166,11 +166,11 @@ class TestPresenter extends Application\UI\Presenter // type checking Assert::same('/index.php?action=params&presenter=Test', $this->link('params', [])); - Assert::same(['pint' => null, 'parr' => null, 'pbool' => null, 'action' => 'params'], $this->getLastCreatedRequest()->getParameters()); - Assert::same('/index.php?action=params&presenter=Test', $this->link('params', ['int' => null, 'bool' => null, 'str' => null, 'arr' => null])); - Assert::same(['int' => null, 'bool' => null, 'str' => null, 'arr' => null, 'pint' => null, 'parr' => null, 'pbool' => null, 'action' => 'params'], $this->getLastCreatedRequest()->getParameters()); - Assert::same('/index.php?int=1&bool=1&str=abc&arr=1&action=params&presenter=Test', $this->link('params', ['int' => 1, 'bool' => true, 'str' => 'abc', 'arr' => '1'])); - Assert::same('/index.php?int=0&bool=0&action=params&presenter=Test', $this->link('params', ['int' => 0, 'bool' => false, 'str' => '', 'arr' => ''])); + Assert::same(['pint' => NULL, 'parr' => NULL, 'pbool' => NULL, 'action' => 'params'], $this->getLastCreatedRequest()->getParameters()); + Assert::same('/index.php?action=params&presenter=Test', $this->link('params', ['int' => NULL, 'bool' => NULL, 'str' => NULL, 'arr' => NULL])); + Assert::same(['int' => NULL, 'bool' => NULL, 'str' => NULL, 'arr' => NULL, 'pint' => NULL, 'parr' => NULL, 'pbool' => NULL, 'action' => 'params'], $this->getLastCreatedRequest()->getParameters()); + Assert::same('/index.php?int=1&bool=1&str=abc&arr=1&action=params&presenter=Test', $this->link('params', ['int' => 1, 'bool' => TRUE, 'str' => 'abc', 'arr' => '1'])); + Assert::same('/index.php?int=0&bool=0&str=&arr=&action=params&presenter=Test', $this->link('params', ['int' => 0, 'bool' => FALSE, 'str' => '', 'arr' => ''])); Assert::same('/index.php?action=params&presenter=Test', $this->link('params', ['int' => new stdClass])); Assert::same('#error: Missing parameter $int required by TestPresenter::actionHints()', $this->link('hints', []));