Skip to content

Commit 11cdcbe

Browse files
committed
added AGENTS.md & DOCS
1 parent fad2492 commit 11cdcbe

6 files changed

Lines changed: 308 additions & 0 deletions

File tree

‎.gitattributes‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
.gitattributes export-ignore
22
.github/ export-ignore
33
.gitignore export-ignore
4+
AGENTS.md export-ignore
45
ncs.* export-ignore
56
phpstan*.neon export-ignore
7+
docs/ export-ignore
68
tests/ export-ignore
79

810
*.php* diff=php

‎AGENTS.md‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# To My Agents!
2+
3+
It is my fervent wish that this file guide every AI coding agent working with code in this repository.
4+
5+
## Documentation
6+
7+
Any distilled, agent-facing documentation for this package - how it works
8+
internally and the rationale behind key design decisions - lives in `docs/`.
9+
Consult it before non-trivial changes; it is the source of truth from which the
10+
public manual is distilled.
11+
12+
The data flow, validation evaluation, and rendering each carry sharp traps
13+
(the `:valid` re-validation loop, accumulating filters, message-override keying).
14+
Read `docs/internals/` before touching them. Note the internals describe the
15+
current **v3.x** line; a v4 redesign is planned but not in this code.
16+
17+
## Project Overview
18+
19+
Nette Forms (since 2004) creates, validates, and processes web forms with **both**
20+
server-side (PHP) and client-side (`netteForms.ts`) validation kept in sync.
21+
22+
- **PHP Version**: 8.3 - 8.5
23+
- **Package**: `nette/forms`
24+
- **Dependencies**: nette/component-model, nette/http, nette/utils; Latte 3.1.4+.
25+
26+
## Essential Commands
27+
28+
```bash
29+
# PHP tests / static analysis
30+
vendor/bin/tester tests -s -C # or: composer tester
31+
vendor/bin/tester tests/Forms/ -s -C
32+
composer phpstan # PHPStan level 8
33+
34+
# JavaScript (client-side validator)
35+
npm install
36+
npm run build # UMD + minified + .d.ts into src/assets/; runs JS tests after
37+
npm run test # Vitest (jsdom); test:watch / test:ui also available
38+
npm run lint:fix # ESLint with @nette/eslint-plugin
39+
npm run typecheck
40+
```
41+
42+
## Conventions
43+
44+
- Every PHP file starts with `declare(strict_types=1);`; **tabs** for indentation;
45+
everything typed; single quotes unless the string has an apostrophe; Nette Coding
46+
Standard. JS source is TypeScript in `src/assets/`, built by Rollup (a
47+
`spaces2tabs()` plugin enforces tabs, `fix()` adds the header + auto-init).
48+
- PHP tests are Nette Tester `.phpt` (`tests/Forms/`, `tests/Forms.DI/`,
49+
`tests/Forms.Latte/`); JS tests are Vitest specs in `tests/netteForms/`.
50+
51+
## Working in this repo
52+
53+
- **Validation is dual-sided: a rule lives in BOTH places.** A server rule is a
54+
`Validator::validateXxx` method; its client twin goes in `src/assets/validators.ts`
55+
and is exported via `data-nette-rules`. Adding/changing a rule means editing PHP
56+
*and* TypeScript, then `npm run build`.
57+
- **`:valid` toggle evaluation is a known trap.** Computing toggle states runs full
58+
validation (with filters and `addError`) and there is **no recursion guard**, so
59+
it can mutate values and add phantom errors. Filters also **accumulate** (each pass
60+
re-applies to the already-filtered value). See `docs/internals/validation.md`.
61+
- **Custom-message override is keyed by the operation string** and only reaches
62+
string validators - a callable/object validator never picks it up.
63+
- **CSRF defaults to same-origin `Sec-Fetch-Site` checking** (token-based protection
64+
is deprecated); `allowCrossOrigin()` disables protection entirely - use with care.
65+
- **A new control needs a PHP class** (`src/Forms/Controls/`, extend `BaseControl`),
66+
optional `Validator.php` support, a client validator, and tests on both sides.
67+
- User-facing how-to (Latte tags, validation-rule catalog, conditions/toggles, NEON
68+
messages, data mapping, rendering customization, JS loading) is manual material
69+
and lives in the public web docs, not here.

‎docs/internals/README.md‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Forms internals
2+
3+
How `nette/forms` works underneath, for agents editing it. Split by the natural
4+
seams; each file is self-contained.
5+
6+
- **[container-and-data.md](container-and-data.md)** — the Form/Container/Control
7+
tree, how submitted HTTP data is read (the flat pull model), submission
8+
detection, `fireEvents`, `getValues`, validation scope, and the deliberately
9+
minimal `Control` contract.
10+
- **[validation.md](validation.md)** — the `Rules`/`Rule` tree, evaluation order,
11+
and the sharp traps (`:valid`, filter re-application, message override keying).
12+
- **[rendering.md](rendering.md)** — `DefaultFormRenderer` wrappers and the Latte
13+
runtime, including the `{form detached}`/`{form scope}` machinery.
14+
15+
> **Version scope.** This describes the current **v3.x** release line (server-side
16+
> flat data model, string-op validation rules, a 5-method `Control` interface). A
17+
> v4 redesign of the data flow (layered pull, submission sources) and the
18+
> validation core (validator objects, message identifiers) is planned and will
19+
> change several of the mechanisms below; treat this as the v3.x source of truth
20+
> until then.
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
# Container tree & HTTP data flow (v3.x)
2+
3+
`Form extends Container extends Nette\ComponentModel\Container`. The tree and the
4+
way submitted data reaches each control are one emergent model.
5+
6+
## Data is pulled, per control, from one flat array
7+
8+
There is **no central distribution** of submitted data. Each control pulls its own
9+
value, lazily, driven by the component monitor:
10+
11+
- `BaseControl`'s constructor registers `monitor(Form::class, …)`; when the control
12+
is attached to an **anchored, submitted** form it calls `loadHttpData()`
13+
(`BaseControl::loadHttpData` → `setValue(getHttpData(Form::DataText))`).
14+
`loadHttpData` is the template-method hook overridden by `SubmitButton`,
15+
`CsrfProtection`, etc.
16+
- `BaseControl::getHttpData()` asks `Form::getHttpData($type, $htmlName)`, where
17+
`$htmlName` is the full bracketed path (`getHtmlName()` →
18+
`Helpers::generateHtmlName(lookupPath(Form::class))`).
19+
- `Form::getHttpData()` **lazily** fills `Form::$httpData` **once**, from
20+
`receiveHttpData()`, and sets `$submittedBy = is_array($data)`. This is the only
21+
place `$httpData` is populated.
22+
- `Helpers::extractHttpData()` walks that flat array by the path: it strips `]`,
23+
turns `.`→`_`, splits on `[`, then `Arrays::get`s down the keys. A trailing `[]`
24+
triggers per-element sanitization; `DataKeys` preserves keys, otherwise
25+
`array_values` renumbers.
26+
27+
**Sanitization is by data-type bit** (`Helpers::sanitize`): `DataText` normalizes
28+
newlines only; `DataLine` collapses newlines to spaces and trims (single-line
29+
inputs); `DataFile` passes only a real `FileUpload` (else `null`). An agent adding a
30+
control picks the bit that matches; picking `DataText` for a single-line field
31+
leaks newlines.
32+
33+
## Submission detection lives in `receiveHttpData`
34+
35+
`Form::receiveHttpData()` returns `null` (not submitted) unless **all** hold:
36+
37+
1. the HTTP method matches the form's method;
38+
2. for POST, the request passes the **same-origin** check
39+
(`!crossOrigin && $request->isFrom(FetchSite::SameOrigin)`) — the actual
40+
Sec-Fetch-Site / cookie logic lives in **nette/http**, not here; Forms only calls
41+
`isFrom`. `allowCrossOrigin()` disables this (and token protection via
42+
`CsrfProtection`/`addProtection()` is deprecated in favor of it);
43+
3. the **`_form_` tracker** (present only for a **named** form) equals the form's
44+
name. An unnamed form has no tracker, so detection rests on method + data alone.
45+
46+
`submittedBy` starts as the bool `true` and is **narrowed to a `SubmitButton`
47+
instance** by `SubmitButton::loadHttpData()` when that button is filled — that is
48+
how "which button submitted" is known.
49+
50+
## `fireEvents` order
51+
52+
`Form::fireEvents()` runs a fixed sequence: return if not submitted; validate only
53+
if there are no errors yet; then `$submittedBy->onClick`/`onInvalidClick` (for a
54+
`SubmitButton`), then `onSuccess` (if valid), then `onError` (if invalid), then
55+
always `onSubmit`; a warning fires if nothing was handled. `invokeHandlers`
56+
inspects each handler's first parameter type by reflection to pass `$form` / the
57+
button / `getValues($type)`, and **stops the chain the moment a handler invalidates
58+
the form**.
59+
60+
`Form::validate()` (override) pulls the validation scope from the clicked
61+
`SubmitterControl` before delegating to `Container::validate($controls)`.
62+
63+
## Reading values back out of the tree
64+
65+
- **`getValues()` = `getUntrustedValues()` + guards.** It **throws** if called
66+
during validation (`validated === null`), warns if the form is invalid, applies
67+
the validation-scope narrowing, then delegates.
68+
- **`getUntrustedValues()`** walks the component tree: non-omitted `Control`s
69+
contribute `getValue()` (with enum coercion against the target property type),
70+
nested `Container`s recurse. The return shape is `ArrayHash` by default, or
71+
`$mappedType` (`setMappedType`), or a class you pass — a **DTO class** is built by
72+
reflection (constructor with required params, else property assignment).
73+
- **`isOmitted()`** controls exclusion (`setOmitted`, or a disabled control with
74+
`omitted === null`); the tracker, buttons, and CSRF field are omitted.
75+
- **`setDefaults()` on a submitted form only fills *disabled* controls**
76+
(`onlyDisabled: form->isSubmitted()`), which is why setting defaults after submit
77+
appears to "do nothing" for normal fields.
78+
79+
## Validation scope
80+
81+
A `SubmitButton::setValidationScope(iterable)` stores only `Container`/`Control`
82+
targets. `Form::validate()` passes them down; `Container::validate($controls)`
83+
validates only that subset (`[]` validates nothing). The same scope also narrows
84+
`getValues`, and is exported to the client as `data-nette-validation-scope`.
85+
86+
## The `Control` contract is deliberately minimal — and not honored
87+
88+
`Control` declares only **five** methods: `setValue`, `getValue`, `validate`,
89+
`getErrors`, `isOmitted`. In practice the framework requires far more of every
90+
control (`getHtmlName`, `getControl`, `getLabel`, `getForm`, `getOption`,
91+
`isFilled`, …), so it is written against `BaseControl` everywhere. The current state
92+
papers over the gap with **`instanceof BaseControl` guards** (a handful of sites:
93+
`Validator` for `%label`, the renderer's `translate`, `Form`, `Blueprint`, the Latte
94+
runtime) and a **`method.notFound` ignore block in `phpstan.neon`** that enumerates
95+
the "missing" interface methods. Treat "a control is a `BaseControl`" as the real,
96+
if unstated, contract when editing v3.x.

‎docs/internals/rendering.md‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# Rendering (v3.x)
2+
3+
Two rendering paths: the programmatic `DefaultFormRenderer` and the Latte runtime.
4+
Both draw HTML from the same control **prototypes** (`getControl()`/`getLabel()`),
5+
so custom markup ultimately flows through those `Html` elements.
6+
7+
## DefaultFormRenderer: the `$wrappers` map
8+
9+
`DefaultFormRenderer` is table-oriented and driven by a nested `$wrappers` map:
10+
`section => key => Html|string|null` (e.g. `controls.container = 'table'`,
11+
`pair.container = 'tr'`, `label.container = 'th'`, `control.container = 'td'`, plus
12+
per-state keys like `pair.required`/`.error`/`.odd` and per-input-type control
13+
classes). `render()` fans out to `renderBegin`/`renderErrors`/`renderBody`/`renderEnd`;
14+
`renderBody` walks groups → `renderControls` → `renderPair` (label + control), with
15+
buttons aggregated into `renderPairMulti`. Wrapper elements are **cloned** per use
16+
(`getWrapper` clones an `Html`, else builds one from a tag string) so mutating one
17+
rendered element never leaks into the template. This renderer is stable/legacy —
18+
new rendering work goes through Latte.
19+
20+
## Latte runtime: the `$stack` / `$detachedIds` machinery
21+
22+
`Bridges/FormsLatte/Runtime` keeps two **parallel** stacks: `$stack` (the current
23+
form/container scope) and `$detachedIds` (the detached form id per stack level,
24+
`null` when not detached). `begin()` pushes and, for a `Form`, fires the render
25+
events; `end()` pops both. `get()` resolves an element from the current scope and —
26+
**this is the detached-form wiring** — if a detached id is active and the element is
27+
a `BaseControl`, sets `setHtmlAttribute('form', $id)` on it. `isNested()` is just
28+
`(bool) $stack`.
29+
30+
`FormNode` compiles the `{form}` family in three modes:
31+
32+
- **normal** — wraps the body in `renderBegin … body … renderEnd`.
33+
- **`{form scope name}`** (and the deprecated `{formContext}`) — renders the body
34+
**only**, no `<form>` tag; used to (re-)enter a form/container without emitting
35+
markup (e.g. an AJAX snippet). It takes **no arguments** (a compile error
36+
otherwise). `isNested()` decides whether it enters a container or an existing UI
37+
form.
38+
- **`{form detached name}`** — emits `renderBegin . renderEnd` **before** the body,
39+
i.e. an empty `<form id=…></form>` (carrying hidden fields incl. the tracker),
40+
and the body is rendered **after** it. The body's controls are not physically
41+
nested in that form; each is linked back to it by the `form="<id>"` attribute that
42+
`get()` stamps on. This exploits HTML5's out-of-tree `form=` association so a page
43+
form can legitimately contain an independent inner `<form>`. A detached form
44+
**must** have a non-empty id (else `InvalidStateException`), and nested containers
45+
inherit the id while a nested `Form` starts fresh.
46+
47+
The invariant a theme/renderer must respect: **the `{form}` machinery owns the
48+
`<form>` begin/end** across all three modes — nothing else may emit the `<form>` tag,
49+
or detached and nested forms break.

‎docs/internals/validation.md‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# Validation (v3.x)
2+
3+
The densest part of the package, and the one with the sharpest traps.
4+
5+
## The rule tree
6+
7+
`Rules` holds an ordered `$rules[]`, a **separate** `$required` slot, and a
8+
`$parent` pointer. A `Rule` is a **mutable** value object: `control`, `validator`
9+
(**either a string op like `':email'` or a callable**), `arg`, `isNegative`,
10+
`message`, and an optional `branch` (a nested `Rules` for conditions). There is no
11+
`op` field — the op string *is* the validator, mapped to a built-in via
12+
`getCallback()` (`':email'` → `[Validator::class, 'validateEmail']`).
13+
14+
- `addRule`: a `Filled` rule goes to `$required`, everything else to `$rules[]`;
15+
`Valid` may not be a rule.
16+
- `addCondition` turns a bool argument into `:static`; `addConditionOn` creates a
17+
`Rule` with a `branch` and **returns the branch**, which is why `addRule` chains
18+
into the condition.
19+
- `elseCondition` clones the last rule and flips it — `Filled`↔`Blank` via a lookup,
20+
otherwise `isNegative = !isNegative`.
21+
22+
## Evaluation order and `$emptyOptional`
23+
24+
`Rules::validate()`:
25+
26+
- **`$emptyOptional`** = "not required and not filled". When set, every **non-branch**
27+
rule except `Filled` is skipped — an empty optional field validates clean. It
28+
propagates into branches, but a `Blank` branch resets it to `false`.
29+
- **Order is Blank → required → the rest**, imposed by `getIterator()` (priorities
30+
0/1/2), not by insertion order.
31+
- **Filters are ordinary rules.** `addFilter` appends a rule whose closure does
32+
`setValue(filter(getValue())); return true;`, at priority 2 — so a filter runs
33+
**after** Blank and Required, interleaved with other rules, not in a dedicated
34+
phase.
35+
36+
## Trap: filters re-apply on every validation pass
37+
38+
A filter mutates the control's value in place and **nothing ever restores the raw
39+
value**. Every call to `validate()` — direct, via `:valid`, or via toggle
40+
computation — re-runs the filter on the **already-filtered** value. A
41+
`addFilter(fn($v) => $v.'x')` appends another `x` each pass. In particular,
42+
computing toggle states calls the same `validateRule()` with full side effects, so
43+
**every render that reads toggles mutates values and can add phantom errors**
44+
(there is no dry-run mode in v3.x; `validateRule` always executes the callback).
45+
46+
## Trap: `:valid` runs full validation and has no recursion guard
47+
48+
The `:valid` op routes to `Validator::validateValid()`, which calls
49+
`$control->getRules()->validate()` — the **full** pipeline, applying filters and
50+
calling `addError`. Because toggle computation (`getToggleStates`) goes through the
51+
same `validateRule` path, a condition on `:valid` writes errors and mutates values
52+
on every toggle read. Worse, there is **no visited-set**: `A` conditioned on
53+
`B :valid` and `B` conditioned on `A :valid` recurse until the stack overflows.
54+
55+
## Trap: `Validator::$messages` is keyed by op, so it misses callable validators
56+
57+
`Validator::formatMessage()` resolves the message template from `$rule->validator`
58+
**only when it is a string** (`is_string($rule->validator) && isset($messages[...])`).
59+
So `Validator::$messages[Form::Email]` (and the NEON `forms: messages:`) overrides
60+
`addRule(Form::Email)` but **never** a custom callable/object rule — those fall back
61+
to the rule's explicit message or a "Missing validation message" error. There is no
62+
stable message id in v3.x; the op string is the only key.
63+
64+
## Client export
65+
66+
`Helpers::exportRules()` serializes rules into `data-nette-rules`. A rule exports
67+
only if `canExport()` — `is_string($validator) || Callback::isStatic($validator)`.
68+
A non-exportable **non-branch** rule (typically a mutating filter closure) **`break`s**
69+
and stops export of everything after it; inside a branch it merely `continue`s.
70+
`Form::Enum` has no JS counterpart and is exported as `Form::Equal` against the
71+
enum's case values. This "a mutating filter halts client validation of later rules"
72+
behavior is intentional: the client cannot reproduce an opaque server-side mutation.

0 commit comments

Comments
 (0)