Phase 7a Sanitizer clean-room (no ProcessWire residue) - #7
Merged
Conversation
Phase 7a — first sub-phase of the field-type-system rewrite.
The 1.x Sanitizer carried roughly a dozen unported $this->wire(...) calls
left over from a ProcessWire ancestor. Any code path that touched them
would Fatal at runtime (the methods don't exist on the iManager class),
so substantial parts of the file were latent-broken rather than truly
working — see docs/imanager-analysis.md §4.3.
This phase ships a fresh Imanager\Validation\Sanitizer with only the
methods the upcoming field-type plugins (7b/7c) actually need:
text — single-line: strip control chars, collapse whitespace,
trim, truncate by Unicode codepoints
multiline — preserve newlines, normalize CRLF/CR -> LF
slug — URL-safe identifier with iconv ASCII transliteration
identifier — PHP-style [A-Za-z_][A-Za-z0-9_]*, prepends `_` if it
would otherwise start with a digit
filename — basename + explicit / and \ stripping (defense-in-depth
against cross-platform path-separator smuggling)
email — RFC-ish, returns null on invalid
url — http/https only, returns null on invalid
int / float — coerce + optional [min, max] clamp
bool — FILTER_VALIDATE_BOOLEAN, defaults to false
entities — htmlspecialchars(ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5)
markdown — Parsedown in safe mode (raw HTML escaped)
html — HTMLPurifier with conservative tag allowlist
Markdown and HTMLPurifier are lazy-instantiated and overridable via
constructor injection — the Sanitizer is cheap to wire into the
container even when only the lightweight string methods are exercised.
58 new tests covering the whole API (control-character handling,
Unicode truncation, slug transliteration, identifier rewriting,
path-separator stripping, scheme allowlisting, boolean truthy/falsy
matrix, HTML5 entity quirks, raw-HTML escaping in safe markdown,
HTMLPurifier disallow). 324 tests / 644 assertions in total;
PHPStan 8 + Psalm 3 clean.
3 tasks
bigin
added a commit
that referenced
this pull request
May 2, 2026
Final sub-phase of the field-type-system rewrite. With this PR all 17 iManager 1.x field types have been ported to the Phase-7b plugin contract.
## What's in
### Seven full implementations
| Plugin | Notes |
|---|---|
| `EditorFieldType` | Rich-text source field. Modes: **`markdown`** (default, `Sanitizer::multiline`) or `html` (`Sanitizer::html` via HTMLPurifier). Renders a textarea with `data-editor-mode` for the Phase-14 admin JS to swap in a real WYSIWYG. Storage is the *source*; display rendering is the template's job. |
| `DatepickerFieldType` | ISO date string in, Unix timestamp out. INTEGER affinity so the indexed generated column from Phase 4 can do range queries with plain integer comparisons. Accepts `YYYY-MM-DD`, full datetimes, or anything `strtotime()`-grokable. |
| `DropdownFieldType` | Single-select. Validates membership in the config-declared `options` map. Auto-prepends a blank choice for optional fields. |
| `DecimalFieldType` | Float with min/max clamp and configurable precision. Step on the rendered input is derived from precision. |
| `MoneyFieldType` | `Decimal` plus currency context. Tolerates locale-specific input formatting (`1.234,56`, `1,234.56`, `€ 1.234,56`, `$1,234.56`) by detecting the dominant separator. |
| `PasswordFieldType` | bcrypt-hashes plaintext on save; NEVER echoes the stored hash back to the form. **Empty input is a sentinel for "leave the existing hash alone"**: `validate()` returns `null`; the editor controller (Phase 14) interprets `null` as a write-skip. minLength enforced. |
| `ArrayListFieldType` | `list<string>`. Editor input is a textarea; both newline- and comma-separated input parse. `maxItems` and `itemMaxLength` enforced. |
### Three Phase-13 stubs
| Plugin | Stub behavior |
|---|---|
| `FilepickerFieldType` | Treats value as opaque sanitized filename; renders plain text input with a `data-field="filepicker"` upgrade marker. Phase 13 will replace `render()` with a populated `<select>` driven by the upload directory. |
| `FileuploadFieldType` | Pass-through `list<array>` of file metadata; emits `<input type="file" multiple>`. |
| `ImageuploadFieldType` | Same as Fileupload with `accept="image/*"`. |
Each stub satisfies the `FieldTypePlugin` contract so the registry can hold all 17 built-ins from day one; only `render()` (and the validation pipeline that touches actual files) needs to grow when Phase 13 wires the upload-storage layer.
## Acceptance criteria — Phase 7 closed
- [x] **7a** Sanitizer clean-room (PR #7)
- [x] **7b** `FieldTypePlugin` interface, `FieldTypeRegistry`, six built-ins (PR #8)
- [x] **7c** Remaining 11 types — this PR
- 7 full implementations
- 3 stubs marked for Phase 13
- 1 already covered (the 1.x "summernote-Editor" maps to `EditorFieldType` here, no separate type)
## Verification
| Check | Result |
|---|---|
| PHP-CS-Fixer | 0 issues |
| PHPStan level 8 | no errors |
| Psalm level 3 | no errors, 99.32% type inference |
| PHPUnit | **426 tests, 858 assertions** (375 from 7b + 51 new) |
## Notes
- `EditorFieldType` defaults to **markdown mode**. Per discussion, this is safer (no raw HTML by default) and gives nicer source diffs in version control or Git-driven content workflows. Projects that need WYSIWYG-with-HTML can flip the `mode` config key.
- `PasswordFieldType`'s "empty == no change" sentinel is documented in its class-level PHPDoc. If we ever want a "blank the password" admin action, it's a separate explicit operation, not an accidental form save.
- `MoneyFieldType` stores as `float`. For projects that need exact arithmetic (financial reports, rounding-safe summation) the recommendation is to store cents in an `IntegerFieldType` instead — that pattern is documented in the class PHPDoc.
- `MoneyFieldType` currency lives on the *field config*, not on each row. If a use case ever needs per-row currency, it should be a separate field plugin (a small struct serialized to JSON) — explicitly noted.
- The three Phase-13 stubs have minimal validation by design: they accept what they're given without trying to verify file existence or mime type. Strict validation needs the upload pipeline; piggy-backing it onto the plugin interface now would lock in a design that's better made when we have the surrounding storage code.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
First sub-phase of the field-type-system rewrite. The Sanitizer is the foundation 7b and 7c will lean on, so it lands first and small.
Why a clean-room rewrite
The 1.x Sanitizer carried roughly a dozen unported
$this->wire(...)calls inherited from a ProcessWire ancestor — seedocs/imanager-analysis.md§4.3. Thewire()method doesn't exist on iManager, so any of those code paths that fired would Fatal at runtime. Substantial parts of the file were latent-broken rather than truly working.Rather than port-fix each one, this PR ships a fresh
Imanager\Validation\Sanitizerfrom scratch with only the methods the upcoming field-type plugins (7b / 7c) actually need.Public API
text(string, int = 255)multiline(string, int = 65535)slug(string, int = 128)identifier(string, int = 30)[A-Za-z_][A-Za-z0-9_]*; prepends_if it would otherwise start with a digitfilename(string, int = 128)basename()+ explicit/and\\stripping (defense-in-depth against cross-platform path-separator smuggling)email(string)nullurl(string)nullint(mixed, ?int min, ?int max)float(mixed, ?float min, ?float max)bool(mixed)FILTER_VALIDATE_BOOLEAN; non-truthy / non-falsy →falseentities(string)htmlspecialchars(ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5)markdown(string)html(string)ParsedownandHTMLPurifierare lazy-instantiated and overridable via constructor injection — the Sanitizer is cheap to wire into the container even when only the lightweight string methods are exercised.Acceptance criteria (Phase 7a slice of plan §7 Phase 7)
Imanager\Validation\Sanitizerexists at the location the plan placed itwire()or any other ProcessWire artifacterusev/parsedown) and HTML purification (ezyang/htmlpurifier)Verification
Notes
Cache.DefinitionImpl = null) so we don't surprise anyone with a write into a directory the framework's host owner doesn't expect. Performance hit on first call is acceptable; if it bites later, we'll wire a configurable cache path through the container.entities()emits'for the single quote (HTML5-flag behavior). The 1.x Sanitizer would have emitted'(HTML4 default). Templates that scrape sanitizer output looking for'will need to be updated — but no production templates today depend on this, so we take the cleaner default.slug()usesiconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', ...). Transliteration coverage varies across libcs (glibc, musl, BSD); the test that exercises the path withnaïve résuméonly asserts the result is slug-shaped (/^[a-z0-9-]+$/), not its exact bytes. If we ever need deterministic transliteration we'll switch tointl'sTransliterator, but that requiresext-intlwhich isn't incomposer.jsontoday.purify()method that just delegated towire('modules')->get('MarkupHTMLPurifier')— it's intentionally not preserved under the same name. The new equivalent ishtml().