feat/static router - #15
Conversation
abdul-kaioum
commented
Jul 25, 2026
- feat: static router to render wordpress page/post url for custom usecases
- fix: route path not matching for root
- fix: returning '' on the_content filter
- fix: accessing property before initialized
- chore: refactor. added precaution
- chore: fix type
- fix: namespace
- Fix: static route regex
- fix: chained middleware() dropped args, use array_merge
Assisted-By: AI
Internal restructure with the public API frozen (consumed by downstream plugins). Response moves to instance state behind a static facade; Router gains a type-keyed registry; route blocking uses an internal exception instead of a polled flag; response emission becomes per-transport strategies; one RoutePattern compiler serves REST/AJAX/static matching; IpTool splits into Http\\Detection\\{ClientIpResolver,UserAgent}.
New collaborators: RoutePattern, MiddlewareRegistry, ResponseEnvelope, RewriteRuleSet, RouteBlockedException, MiddlewareConfigurationException, Emitter\\{ResponseEmitter,Api,Ajax,Static}ResponseEmitter.
Security: HttpClient safe-by-default (wp_safe_remote_request, allowUnsafeUrls opt-in); trusted-proxy X-Forwarded-For resolution; fail-closed middleware; response header-injection validation.
Adds a PHPUnit contract suite + PHPDBG coverage gate (100% on selected HTTP methods). PHP 7.4 compatible.
Assisted-By: AI
Bump platform floor to PHP 8.0 (composer require + phpcs compat testVersion 8.0-). Add param/return/property type declarations and 8.0 idioms (match, str_contains, constructor promotion, null-coalescing) across src via Rector run to a fixpoint. BC-conservative at extension points: public accessors on the extendable Request (and the IpTool trait) stay untyped so consumer subclass overrides keep compiling; Arr's public helper params stay untyped to preserve arg coercion. rector.php skips these files and a Request-subclass contract test pins the untyped-override guarantee. No strict_types, no enums/readonly (would break coercion / string constants / need 8.1). Fixes surfaced during typing: HttpClient::getBoundary() assigned $this to the boundary (object-to-string fatal on multipart); ShortcodeWrapper::doShortcode() and Request::input() discarded their return values; Request::files() could return null under an array contract. Tooling: remove the lefthook hook shim; add captainhook + hook-installer with a pre-commit hook running cs-fixer (check), compat, and tests. Rewrite README into an accurate quick-start. Assisted-By: AI
fix(http): restrict unsafe requests by host
| // Make case insensitive. | ||
| $t = strtolower($userAgent); | ||
|
|
||
| // If the string *starts* with the string, strpos returns 0 (i.e., FALSE). Do a ghetto hack and start with a space. | ||
| // "[strpos()] may return Boolean FALSE, but may also return a non-Boolean value which evaluates to FALSE." | ||
| // http://php.net/manual/en/function.strpos.php | ||
| $t = ' ' . $t; | ||
|
|
||
| // Humans / Regular Users | ||
| if (strpos($t, 'opera') || strpos($t, 'opr/')) { | ||
| return 'Opera'; | ||
| } | ||
|
|
||
| if (strpos($t, 'edge')) { | ||
| return 'Edge'; | ||
| } | ||
|
|
||
| if (strpos($t, 'Edg')) { | ||
| return 'Edge'; | ||
| } | ||
|
|
||
| if (strpos($t, 'chrome')) { | ||
| return 'Chrome'; | ||
| } | ||
|
|
||
| if (strpos($t, 'safari')) { | ||
| return 'Safari'; | ||
| } | ||
|
|
||
| if (strpos($t, 'firefox')) { | ||
| return 'Firefox'; | ||
| } | ||
|
|
||
| if (strpos($t, 'msie') || strpos($t, 'trident/7')) { | ||
| return 'Internet Explorer'; | ||
| } | ||
|
|
||
| if (strpos($t, 'google')) { | ||
| return 'Googlebot'; | ||
| } | ||
|
|
||
| if (strpos($t, 'bing')) { | ||
| return 'Bingbot'; | ||
| } | ||
|
|
||
| if (strpos($t, 'slurp')) { | ||
| return 'Yahoo! Slurp'; | ||
| } | ||
|
|
||
| if (strpos($t, 'duckduckgo')) { | ||
| return 'DuckDuckBot'; | ||
| } | ||
|
|
||
| if (strpos($t, 'baidu')) { | ||
| return 'Baidu'; | ||
| } | ||
|
|
||
| if (strpos($t, 'yandex')) { | ||
| return 'Yandex'; | ||
| } | ||
|
|
||
| if (strpos($t, 'sogou')) { | ||
| return 'Sogou'; | ||
| } | ||
|
|
||
| if (strpos($t, 'exabot')) { | ||
| return 'Exabot'; | ||
| } | ||
|
|
||
| if (strpos($t, 'msn')) { | ||
| return 'MSN'; | ||
| } | ||
|
|
||
| // Common Tools and Bots | ||
| if (strpos($t, 'mj12bot')) { | ||
| return 'Majestic'; | ||
| } | ||
|
|
||
| if (strpos($t, 'ahrefs')) { | ||
| return 'Ahrefs'; | ||
| } | ||
|
|
||
| if (strpos($t, 'semrush')) { | ||
| return 'SEMRush'; | ||
| } | ||
|
|
||
| if (strpos($t, 'rogerbot') || strpos($t, 'dotbot')) { | ||
| return 'Moz'; | ||
| } | ||
|
|
||
| if (strpos($t, 'frog') || strpos($t, 'screaming')) { | ||
| return 'Screaming Frog'; | ||
| } | ||
|
|
||
| if (strpos($t, 'facebook')) { | ||
| return 'Facebook'; | ||
| } | ||
|
|
||
| if (strpos($t, 'pinterest')) { | ||
| return 'Pinterest'; | ||
| } | ||
|
|
||
| if ( | ||
| strpos($t, 'crawler') | ||
| || strpos($t, 'api') | ||
| || strpos($t, 'spider') | ||
| || strpos($t, 'http') | ||
| || strpos($t, 'bot') | ||
| || strpos($t, 'archive') | ||
| || strpos($t, 'info') | ||
| || strpos($t, 'data') | ||
| ) { | ||
| return 'Bot'; | ||
| } | ||
|
|
||
| return 'Other (Unknown)'; | ||
| } |
There was a problem hiding this comment.
It will be better to use the match function instead of if
There was a problem hiding this comment.
good catch. Need to upgrade php 8
There was a problem hiding this comment.
Refactored browser and bot detection to ordered token tables. This preserves priority, removes the legacy conditional chain, and covers modern Edg/ before Chrome; match(true) would still require the same ordering and would not address the original case-normalization defect.
| public function input($offset, $default = null) | ||
| { | ||
| $this->get($offset, $default); | ||
| return $this->get($offset, $default); | ||
| } | ||
|
|
There was a problem hiding this comment.
Intentionally left Request::input() untyped for downstream subclass compatibility. Existing consumers override this extension point with legacy signatures; adding a return type would cause inheritance fatals. The compatibility test and Rector skip document this frozen contract.
| add_filter('the_content', [$this, 'renderContent']); | ||
|
|
||
| $this->content = $route->handleRequest(); | ||
|
|
There was a problem hiding this comment.
It will be a fatal error if the handleRequest method returns Array or null.
There was a problem hiding this comment.
Fixed with an explicit static-page contract: null renders empty content, scalar/Stringable values normalize to strings, arrays throw a clear UnexpectedValueException, and denied/error responses never enter HTML emission. Custom/direct route transports retain raw array output for backward compatibility.
|
Hardening/refactor update pushed in de476c9. Implemented:
Verification:
Request::input() remains deliberately untyped to preserve downstream subclass compatibility. The PR has not been merged. |