Skip to content

Commit 4a4d036

Browse files
committed
added AGENTS.md & DOCS
1 parent fad2492 commit 4a4d036

7 files changed

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

docs/internals/client-side.md

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# Client-side validation
2+
3+
The TypeScript sources in `src/assets/` re-implement the server's rule evaluation
4+
in the browser. `formValidator.ts` is the engine (`FormValidator` class:
5+
evaluation, toggles, form wiring), `validators.ts` holds the rule twins
6+
(`Validators` class, one method per op), `index.umd.ts` glues them into the
7+
exported `Nette` object (plus `version` and `webalize`). The Rollup build's
8+
`fix()` plugin rewrites the UMD banner so the built file **auto-runs
9+
`initOnLoad()`** unless the page pre-sets `Nette = {noInit: true}`.
10+
11+
The engine mirrors `Rules::validate()` semantics — `emptyOptional` computed via
12+
`:filled`, a `:blank` condition resetting it in the branch, `~` negation parsed
13+
from the op string, conditions recursing — but the invariants around it differ
14+
from PHP in ways an agent must know.
15+
16+
## Op resolution: unknown validators are skipped silently
17+
18+
`validateRule()` maps `':minLength'``Validators.minLength`; an exported static
19+
callable `Custom::method` becomes method name `Custom_method` (`::``_`,
20+
backslashes stripped). **A missing method returns `null` and the rule is simply
21+
skipped** — the exact opposite of PHP, where an unknown string op throws in
22+
`addRule()`. A server rule exported without a JS twin therefore validates as if
23+
it did not exist, with no error anywhere. Validators may themselves return `null`
24+
("cannot decide", e.g. malformed regexp) with the same skip effect.
25+
26+
## Values: filters never touch the DOM
27+
28+
`getValue()` normalizes per element type (radio/checkbox-list expansion via
29+
`RadioNodeList`, `FileList` for uploads, trimmed strings for text inputs).
30+
`getEffectiveValue()` additionally maps the `data-nette-empty-value` attribute
31+
(exported by `TextBase`) to `''` and can apply filters — but filtering runs the
32+
rules with a `{value}` **ref object**; normalizing validators (`url`, `integer`,
33+
`float`) write into that ref, never into the input. Combined with the
34+
`#preventFiltering` re-entrancy guard, the client is immune to both PHP traps:
35+
**filters do not accumulate and validation does not mutate visible state**.
36+
37+
## Form wiring (`initForm`)
38+
39+
Forms without any `data-nette-rules` element are left alone. Otherwise the
40+
engine computes initial toggles and — unless the form already had `novalidate`
41+
sets `form.noValidate = true` (taking over from HTML5) and installs a `submit`
42+
handler that cancels submission on failure. `validateForm()` first lets the
43+
browser report any `badInput` element (`reportValidity`), then walks elements
44+
with rules; a sender with `formnovalidate` narrows validation via a **regex
45+
built from `data-nette-validation-scope`** matched against the rewritten control
46+
name (`a[b][c]``a-b-c-`). Errors accumulate in `formErrors` and are shown at
47+
the end in a `<dialog>` modal (fallback `alert`), focusing the first offender.
48+
`reset` re-runs toggles; a GET form with `data-nette-compact` compacts
49+
checkbox-list values into one comma-joined field via the `formdata` event.
50+
51+
`elem.validity.badInput` also short-circuits `validateRule` itself: such an
52+
element counts as *filled* but nothing else.
53+
54+
## Toggles
55+
56+
`toggleForm()` recomputes **all** toggles from scratch into `#formToggles`,
57+
OR-combining states per id, then applies them via `toggle()` — which treats a
58+
`^\w[\w.:-]*$` selector as an id, otherwise as a CSS selector, and flips
59+
`hidden`. On the first pass each control participating in a condition gets a
60+
`change` listener (deduplicated through a `WeakMap`) that re-runs `toggleForm`.
61+
`toggle()` receives the source element and event precisely so userland can
62+
override it (animations etc.).
63+
64+
## `:submitted` depends on an external writer
65+
66+
`Validators.submitted` compares `elem.form['nette-submittedBy'] === elem`, but
67+
**nothing in this package ever assigns that property** — integrations (e.g.
68+
Naja) set it on button click. Without one, a client-side `:submitted` condition
69+
is always false.
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
# Container tree & HTTP data flow
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 a
12+
**non-disabled** control is attached to an **anchored, submitted** form it calls
13+
`loadHttpData()` (`BaseControl::loadHttpData`
14+
`setValue(getHttpData(Form::DataText))`). `loadHttpData` is the template-method
15+
hook overridden by `SubmitButton`, `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))`); an explicitly set `name`
19+
attribute overrides the generated one, and setting it on a submitted form
20+
re-triggers `loadHttpData()`.
21+
- `Form::getHttpData()` **lazily** fills `Form::$httpData` **once**, from
22+
`receiveHttpData()`, and sets `$submittedBy = is_array($data)`. This is the only
23+
place `$httpData` is populated.
24+
- `Helpers::extractHttpData()` walks that flat array by the path: it strips `]`,
25+
turns `.``_`, splits on `[`, then `Arrays::get`s down the keys. A trailing `[]`
26+
triggers per-element sanitization; `DataKeys` preserves keys, otherwise
27+
`array_values` renumbers.
28+
29+
**Sanitization is by data-type bit** (`Helpers::sanitize`): `DataText` normalizes
30+
newlines only; `DataLine` collapses newlines to spaces and trims (single-line
31+
inputs); `DataFile` passes only a real `FileUpload` (else `null`). An agent adding a
32+
control picks the bit that matches; picking `DataText` for a single-line field
33+
leaks newlines.
34+
35+
## Submission detection lives in `receiveHttpData`
36+
37+
`Form::receiveHttpData()` returns `null` (not submitted) unless **all** hold:
38+
39+
1. the HTTP method matches the form's method;
40+
2. for POST, the request passes the **same-origin** check
41+
(`!crossOrigin && $request->isFrom(FetchSite::SameOrigin)`) — the actual
42+
Sec-Fetch-Site / cookie logic lives in **nette/http**, not here; Forms only calls
43+
`isFrom`. `allowCrossOrigin()` disables this (and token protection via
44+
`CsrfProtection`/`addProtection()` is deprecated in favor of it);
45+
3. the **`_form_` tracker** (present only for a **named** form) equals the form's
46+
name. An unnamed form has no tracker, so detection rests on method + data alone;
47+
for GET an **empty query string** already means "not submitted".
48+
49+
`submittedBy` starts as the bool `true` and is **narrowed to a `SubmitButton`
50+
instance** by `SubmitButton::loadHttpData()` when that button is filled — that is
51+
how "which button submitted" is known.
52+
53+
## `fireEvents` order
54+
55+
`Form::fireEvents()` runs a fixed sequence: return if not submitted; validate only
56+
if there are no errors yet; then `$submittedBy->onClick`/`onInvalidClick` (for a
57+
`SubmitButton`), then `onSuccess` (if valid), then `onError` (if invalid), then
58+
always `onSubmit`; a warning fires if nothing was handled. `invokeHandlers`
59+
inspects each handler's first parameter type by reflection to pass `$form` / the
60+
button / `getValues($type)` (a second parameter, if present, always gets
61+
`getValues`), and **stops the chain the moment a handler invalidates the form**.
62+
63+
`Form::validate()` (override) pulls the validation scope from the clicked
64+
`SubmitterControl`, runs `validateMaxPostSize()` (a form-level error when
65+
`CONTENT_LENGTH` exceeds `post_max_size` — reusing the `MaxFileSize` message),
66+
then delegates to `Container::validate($controls)`.
67+
68+
## Reading values back out of the tree
69+
70+
- **`getValues()` = `getUntrustedValues()` + guards.** It **throws** if called
71+
during validation (`validated === null`), warns if the form is invalid, applies
72+
the validation-scope narrowing, then delegates.
73+
- **`getUntrustedValues()`** walks the component tree: non-omitted `Control`s
74+
contribute `getValue()` (with enum coercion against the target property type),
75+
nested `Container`s recurse. The return shape is `ArrayHash` by default, or
76+
`$mappedType` (`setMappedType`), or a class you pass — a **DTO class** is built by
77+
reflection (constructor with required params, else property assignment).
78+
- **`isOmitted()`** controls exclusion (`setOmitted`, or a disabled control with
79+
`omitted === null`); the tracker, buttons, and CSRF field are omitted.
80+
- **`setDefaults()` on a submitted form only fills *disabled* controls**
81+
(`onlyDisabled: form->isSubmitted()`), which is why setting defaults after submit
82+
appears to "do nothing" for normal fields.
83+
84+
## Validation scope
85+
86+
A `SubmitButton::setValidationScope(iterable)` accepts `Container`/`Control`
87+
targets or component-name strings (resolved via `$form->getComponent()`); anything
88+
else throws. `Form::validate()` passes them down; `Container::validate($controls)`
89+
validates only that subset (`[]` validates nothing). The same scope also narrows
90+
`getValues` (a container is included when any of its ancestors is in scope), and is
91+
exported to the client as `data-nette-validation-scope` (plus `formnovalidate` on
92+
the button).
93+
94+
## The `Control` contract is deliberately minimal — and not honored
95+
96+
`Control` declares only **five** methods: `setValue`, `getValue`, `validate`,
97+
`getErrors`, `isOmitted`. In practice the framework requires far more of every
98+
control (`getHtmlName`, `getControl`, `getLabel`, `getForm`, `getOption`,
99+
`isFilled`, …), so it is written against `BaseControl` everywhere. The current state
100+
papers over the gap with **`instanceof BaseControl` guards** (a handful of sites:
101+
`Validator` for `%label`, the renderer's `translate`, `Form`, `Blueprint`, the Latte
102+
runtime) and a **`method.notFound` ignore block in `phpstan.neon`** that enumerates
103+
the "missing" interface methods. Treat "a control is a `BaseControl`" as the real,
104+
if unstated, contract.

docs/internals/controls.md

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# Control value semantics
2+
3+
Per-control quirks that are invisible from the `Control` interface and bite
4+
during data loading (`setDefaults`) and reading (`getValues`).
5+
6+
## Choice controls: strict on defaults, lenient on submission
7+
8+
`ChoiceControl` / `MultiChoiceControl` enforce membership **asymmetrically**:
9+
10+
- **`setValue()` (and thus `setDefaults()`) throws** for a value outside
11+
`$items`, unless `checkDefaultValue(false)` is called first — the classic trap
12+
when filling a form from the DB before `setItems()` has the full list.
13+
`BackedEnum` values are unwrapped to their backing value first.
14+
- **`loadHttpData()` does not validate at all.** The submitted key is stored raw;
15+
membership is enforced lazily by **`getValue()`, which filters at read time**:
16+
an unknown or per-item-disabled key reads as `null` (multi: dropped). Use
17+
`getRawValue()` to see what was actually submitted.
18+
19+
Keys pass through PHP array-key coercion (`key([(string) $v => null])`), so
20+
numeric strings become ints. `setDisabled(array)` disables individual items (the
21+
control itself stays enabled); since `isFilled()` builds on `getValue()`, a
22+
selection of a disabled item counts as *not filled*. `MultiChoiceControl`
23+
appends `[]` to `getHtmlName()`.
24+
25+
`SelectBox` adds prompt machinery (the prompt key is `''`, extended with tabs on
26+
collision; for a required select it is rendered hidden+disabled) and its
27+
constructor **auto-adds a closure condition + `Filled` rule** (message
28+
`SelectBox::Valid`) guarding the "no prompt, nothing chosen" case — being a
29+
non-exportable *condition*, it is skipped in the client export, per the
30+
`exportRules` rules. `setItems()` accepts optgroups (nested arrays) and flattens
31+
them for the underlying membership checks.
32+
33+
## TextBase: two values and a maxlength side-channel
34+
35+
`TextBase` keeps the coerced `$value` **and** a string `$rawValue`; rendering
36+
uses `$rawValue` / the translated `emptyValue`. `getValue()` maps a value equal
37+
to the (trimmed, translated) `emptyValue` to `''`, and `setNullable()` further
38+
maps `''` to `null`. `emptyValue` is exported to the client as
39+
`data-nette-empty-value`.
40+
41+
`TextBase::addRule()` mirrors the value into the DOM: a `Length`/`MaxLength`
42+
rule also sets the `maxlength` attribute — but **only while all existing rules
43+
are still client-exportable**; once a non-exportable non-branch rule (a filter)
44+
precedes it, the attribute shortcut is disabled, consistent with the export
45+
`break` (the browser must not hard-enforce a limit the filter may change).
46+
47+
## UploadControl: value comes only from HTTP
48+
49+
`setValue()` is a **no-op** — uploads cannot be defaulted; `getValue()` returns
50+
the `FileUpload`(s), or a dummy `FileUpload(null)` when nothing was uploaded
51+
(`setNullable()` switches that to `null`). The constructor auto-adds rules: the
52+
`isOk()` check wrapped in `addCondition(true)` (a `:static` condition,
53+
explicitly so the non-exportable callable doesn't `break` the export of the
54+
sibling `MaxFileSize` rule), `MaxFileSize` from `upload_max_filesize`, and for
55+
`multiple` a `MaxLength` capped by `max_file_uploads`. A monitor throws unless
56+
the form method is POST and stamps `enctype="multipart/form-data"` on the form
57+
prototype. `getHtmlName()` appends `[]` when multiple.
58+
59+
## DateTimeControl: normalization funnel
60+
61+
Every inbound value (`setValue`, rule args) goes through `normalizeValue()`:
62+
string/timestamp/`DateTimeInterface``DateTimeImmutable`, then **truncated by
63+
type** (date: time zeroed; time: date collapsed to 0001-01-01, seconds dropped
64+
unless `withSeconds`). `loadHttpData()` swallows parse errors into `null`.
65+
`getValue()` re-shapes by `setFormat()`: object (default), timestamp, or a
66+
`format()` string. `getControl()` derives `min`/`max` attributes from `Min`/
67+
`Max`/`Range` rules — again stopping at the first non-exportable rule. The
68+
validator-side special-casing lives in `docs/internals/validation.md`.

0 commit comments

Comments
 (0)