From 9bdeaea0a68580efc9b7492d7398428d7d852214 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Ruz=CC=8Cevic=CC=81?= Date: Thu, 14 May 2026 16:55:34 +0200 Subject: [PATCH 01/17] update --- OPTIMIZATIONS.md | 241 ++++++++++++++++++++++++++++++ src/Helpers/AttributesTrait.php | 11 +- src/Helpers/CssVariablesTrait.php | 117 +++++++-------- src/Helpers/TailwindTrait.php | 13 +- 4 files changed, 315 insertions(+), 67 deletions(-) create mode 100644 OPTIMIZATIONS.md diff --git a/OPTIMIZATIONS.md b/OPTIMIZATIONS.md new file mode 100644 index 00000000..50f79cd1 --- /dev/null +++ b/OPTIMIZATIONS.md @@ -0,0 +1,241 @@ +# Eightshift-Libs — Optimization Plan + +A consolidated list of proposed changes from the in-depth review, grouped by category, with file references, severity, effort, and confidence. Followed by a phased implementation plan ordered by ROI and dependency. + +> Codebase snapshot: 134 PHP files, ~35k LOC, PHP 8.4+, PHP-DI 7, PHPUnit 12. Line coverage ~15% (Exception ~89%, Helpers ~38%, everything else 0%). + +--- + +## Legend + +- **Severity / Impact**: `high` | `med` | `low` +- **Effort**: `S` (≤1h) | `M` (1–4h) | `L` (4h+) +- **Confidence**: % that the change is correct and worth doing as described +- **Status**: `[ ]` open / `[x]` done + +--- + +## 1. Performance — hot paths + +### 1.1 CSS variable generation (highest-value) + +- [ ] **Triple-nested lookup → indexed map** — `src/Helpers/CssVariablesTrait.php:680` + Inside `foreach $variables × foreach $variableValue`, the inner `foreach $data as $index => $item` is a linear search by `name`+`type`. Build a `["{$name}---{$type}" => $index]` map once. + _Impact: high · Effort: M · Confidence: 90_ + +- [ ] **`$output .= …` → `$parts[]` + `implode`** — `CssVariablesTrait.php:31-38, 486-513` + Repeated string concatenation in long global-variables loops; O(n²) memory copies. + _Impact: high · Effort: S · Confidence: 85_ + +- [ ] **3× `str_replace()` → single `strtr($s, $map)`** — `CssVariablesTrait.php:815-829` + Per attribute/variable pair, three sequential `str_replace` calls. One `strtr` is a single pass. + _Impact: med · Effort: S · Confidence: 85_ + +- [ ] **`gettype()` checks → `is_int()/is_float()`** — `CssVariablesTrait.php:686-691` + Five `gettype($x) === 'integer'` checks per item in the inner loop. `is_*()` are faster and clearer. + _Impact: low · Effort: S · Confidence: 95_ + +- [ ] **Memoize breakpoint setup** — `CssVariablesTrait.php:98` (`prepareVariableData`), `~:84` (`getSettingsGlobalVariablesBreakpoints`) + Both are stable per request but rebuilt every render. Add `static $cache = [];` keyed by manifest hash / breakpoint signature. + _Impact: med · Effort: S · Confidence: 75_ + +- [ ] **`esc_attr()` on configured CSS selector ID** — `CssVariablesTrait.php:25-48, 62` + Config-driven, but escape the value placed in `id='…'` and similar attributes anyway. + _Impact: low (security/defensive) · Effort: S · Confidence: 75_ + +### 1.2 HTML attribute building + +- [ ] **`$htmlAttrs .= …` → array + `implode`** — `src/Helpers/AttributesTrait.php:339, 343` + _Impact: med · Effort: S · Confidence: 80_ + +- [ ] **Loose `==` → strict `===`** — `AttributesTrait.php:338` + `if ($value == 0 || !empty($value))` is type-coercive; tighten. + _Impact: low (correctness) · Effort: S · Confidence: 90_ + +### 1.3 Tailwind debug path + +- [ ] **Move regex out of render path** — `src/Helpers/TailwindTrait.php:496` + `preg_replace('/[^a-zA-Z]+/', '-', $manifest['title'])` runs on every block render when `WP_DEBUG` is on. Precompute the slug at manifest cache build, store on the manifest. + _Impact: low (only debug) · Effort: S · Confidence: 80_ + +### 1.4 Cache stampede protection + +- [ ] **`flock`-based advisory lock around rebuild** — `src/Helpers/CacheTrait.php` rebuild path (~line 141) + When the timestamp option changes the transient is deleted; any concurrent request triggers a full rebuild. Wrap the rebuild orchestration with `flock(LOCK_EX)` and re-check validity after acquiring. + _Impact: med (only under deploy/concurrent load) · Effort: M · Confidence: 70_ + +### 1.5 Dev-mode autowiring cache + +- [ ] **Short-TTL dev cache for service scanning** — `src/Main/Autowiring.php:209` and `src/Main/AbstractMain.php:262-283` + Production caches the service list; development scans the namespace dir on every page load. Add a filemtime-based dev cache (e.g., key on max mtime of `src/` files), or 30s TTL. + _Impact: med (DX) · Effort: M · Confidence: 80_ + +- [ ] **Document compiled-container reuse** — `src/Main/AbstractMain.php:216` + Verify `buildDiContainer()` is invoked once and the result reused per request in consumer projects; add an example to the README/wiki. + _Impact: low (docs) · Effort: S · Confidence: 60_ + +--- + +## 2. Code quality / modernization (PHP 8.4) + +- [ ] **Constructor property promotion** — `src/Main/AbstractMain.php:52-56`, `src/Main/Autowiring.php:35-42` + _Effort: S · Confidence: 90_ + +- [ ] **`gettype()` → `is_*()` sweep** — `AbstractMain.php:203`, `Exception/ComponentException.php:31-36`, plus the CssVariablesTrait hits above + _Effort: S · Confidence: 95_ + +- [ ] **`array_merge(…)` → spread `[...$a, ...$b]`** — `Autowiring.php:82-85` and several traits + _Effort: S · Confidence: 80_ + +- [ ] **Empty closures → arrow functions** — `Autowiring.php:335-339` (`function () { return true; }` → `fn() => true`) + _Effort: S · Confidence: 95_ + +- [ ] **First-class callable syntax in hook callbacks** — `*Example.php` and abstracts, e.g. `[$this, 'method']` → `$this->method(...)` + Better PHPStan inference, no behavioural change. + _Effort: M (broad sweep) · Confidence: 85_ + +- [ ] **`readonly` properties** — `AbstractMain::$container` (set once) + _Effort: S · Confidence: 80_ + +- [ ] **`JSON_THROW_ON_ERROR`** — `src/Helpers/GeneralTrait.php:254`, `CacheTrait` decode sites + Replace `json_last_error()` plumbing with `try { json_decode(…, flags: JSON_THROW_ON_ERROR) } catch (JsonException)`. + _Effort: S · Confidence: 85_ + +- [ ] **Property hooks / asymmetric visibility (8.4)** — opportunistic; scan for trivial getters wrapping a private field + _Effort: M · Confidence: 50_ + +--- + +## 3. Security + +No high/critical findings. All defensive. + +- [ ] **`esc_attr()` on CSS selector outputs** — `CssVariablesTrait.php:25-48, 62` (see 1.1) + _Severity: low · Effort: S · Confidence: 75_ + +- [ ] **Justify or nonce `$_GET['context']`** — `CssVariablesTrait.php:144, 167` + Sanitized but unnonced; only flips render context. Either add a code comment justifying it, or restrict to admin contexts. + _Severity: low · Effort: S · Confidence: 70_ + +- [ ] **Validate manual IP override** — `src/Geolocation/AbstractGeolocation.php:308-313` + `getIpAddress()` allows an override that bypasses `FILTER_VALIDATE_IP`. Apply the same validation to the override path. + _Severity: low · Effort: S · Confidence: 80_ + +- [ ] **`realpath()` containment for CLI template reads** — `src/Cli/AbstractCli.php:425-426` + Defensive only; CLI-only attack surface. + _Severity: low · Effort: S · Confidence: 70_ + +--- + +## 4. Architecture + +- [ ] **New functionality behind injectable interfaces** — `src/Helpers/Helpers.php` static facade is convenient but blocks DI testing. Don't break BC; add interfaces alongside the static delegate going forward. + _Effort: L (incremental, per-feature) · Confidence: 70_ + +- [ ] **Optional service priority** — `src/Services/ServiceInterface.php` + Add `getPriority(): int { return 10; }` default; consumers can override to control hook ordering declaratively. + _Effort: M · Confidence: 55_ + +--- + +## 5. Tests — biggest gap + +Existing tests are good (Brain/Monkey + Mockery + data providers); pattern is established in `tests/Unit/Helpers/`. The task is breadth. + +Highest-ROI targets (in order): + +- [ ] **`src/Main/Autowiring.php`** — reflection-heavy, silent breakage if wrong +- [ ] **`src/Cache/ManifestCache.php` + `CacheTrait`** — perf-critical, fail-silent prone +- [ ] **`src/Helpers/CssVariablesTrait.php`** — largest untested trait; must precede the refactor in §1.1 +- [ ] **`src/Blocks/AbstractBlocks.php`** — manifest discovery + rendering +- [ ] **`src/Rest/Routes/AbstractRoute.php`** — public surface, permission checks + +_Confidence: 95 — refactoring §1.1 without §5.3 first is risky._ + +--- + +## Implementation plan + +Five PRs, sequenced to minimize risk and keep diffs reviewable. + +### Phase 1 — Safety net (tests first) + +**Goal: lock down behaviour before changing hot paths.** + +1. Add unit tests for `CssVariablesTrait` covering global output, block-level output, breakpoint inheritance, and the `name`+`type` lookup currently at `:680`. Pattern: copy `tests/Unit/Helpers/RenderTraitTest`. +2. Add unit tests for `CacheTrait`: read/write, transient invalidation, missing file fallback. +3. Add unit tests for `Autowiring`: simple dependency tree, interface resolution, circular detection. + +**Exit criteria:** coverage for these three traits ≥ 70%. `composer test` green. + +### Phase 2 — Hot-path performance + +**Goal: measurable render-time win on CSS variable generation.** + +4. Apply §1.1 fixes in this order, one commit each: + - Indexed `name`+`type` lookup map + - `$parts[]` + `implode` for global output + - `strtr` for variable substitution + - `is_int/is_float` swap + - Memoize breakpoint setup +5. Apply §1.2 (AttributesTrait) `implode` + strict comparison. +6. Apply §1.3 (Tailwind debug slug precomputation). + +**Exit criteria:** all phase-1 tests still green. Optional: micro-benchmark a representative page (10–20 blocks) before/after. + +### Phase 3 — Dev-mode + cache safety + +**Goal: better DX locally, no stampede in production.** + +7. Add filemtime-based dev cache to `Autowiring` (§1.5). +8. Add `flock` advisory lock around `CacheTrait` rebuild (§1.4). +9. Add `esc_attr()` to CSS selector outputs (§1.1 / §3). + +**Exit criteria:** dev page-load no longer rescans `src/`. Concurrent rebuild test (two simultaneous warm-ups) produces a single rebuild. + +### Phase 4 — PHP 8.4 modernization sweep + +**Goal: one mechanical PR, no behaviour changes.** + +10. Apply §2 in a single commit each: + - Constructor property promotion + - `gettype` → `is_*` + - `array_merge` → spread + - Empty closures → arrow fns + - `readonly` on `AbstractMain::$container` + - `JSON_THROW_ON_ERROR` migration + - First-class callable syntax in hook callbacks (separate PR if diff is large) + +**Exit criteria:** PHPStan level 6 still clean. PHPCS clean. All tests green. + +### Phase 5 — Defensive cleanups + +**Goal: low-priority hardening.** + +11. Validate manual IP override in `Geolocation` (§3). +12. `realpath()` containment in CLI template reads (§3). +13. Comment / nonce decision for `$_GET['context']` (§3). + +**Exit criteria:** no PHPCS security warnings introduced. + +### Optional follow-up — Architectural + +14. Service priority API (§4). +15. Begin injectable interface pattern alongside `Helpers` static facade (§4) — apply only to new functionality. + +--- + +## Risks & rollback + +- **CssVariables refactor (Phase 2)** is the riskiest change; Phase 1 is the mitigation. If a regression escapes, each Phase 2 step is in its own commit and can be reverted independently. +- **Autowiring dev cache (Phase 3)** could mask added classes during dev. Use filemtime keying — not TTL alone — to avoid surprises. +- **PHP 8.4 sweep (Phase 4)** is mechanical and reviewable; low risk if PHPStan + tests pass. + +--- + +## Out of scope (intentionally) + +- Replacing the static `Helpers` facade wholesale — BC break for every consumer. +- Switching to a different DI container — PHP-DI 7 is fine. +- WP-CLI scaffold output changes — separate concern. +- Public API renames. diff --git a/src/Helpers/AttributesTrait.php b/src/Helpers/AttributesTrait.php index 35fa546f..5a078232 100644 --- a/src/Helpers/AttributesTrait.php +++ b/src/Helpers/AttributesTrait.php @@ -327,7 +327,7 @@ public static function getDefaultRenderAttributes(array $manifest, array $attrib */ public static function getAttrsOutput(array $attrs, bool $escape = true): string { - $htmlAttrs = ''; + $parts = []; foreach ($attrs as $key => $value) { if ($escape) { @@ -335,14 +335,15 @@ public static function getAttrsOutput(array $attrs, bool $escape = true): string $key = \esc_attr($key); } - if ($value == 0 || !empty($value)) { // intentional loose comparison to allow 0 values. - $htmlAttrs .= " {$key}='{$value}'"; + // Write key-only form for empty string; keep '0' / non-empty values quoted. + if ($value !== '') { + $parts[] = " {$key}='{$value}'"; continue; } - $htmlAttrs .= " {$key}"; + $parts[] = " {$key}"; } - return $htmlAttrs; + return \implode('', $parts); } } diff --git a/src/Helpers/CssVariablesTrait.php b/src/Helpers/CssVariablesTrait.php index 28d4c245..9019d8b6 100644 --- a/src/Helpers/CssVariablesTrait.php +++ b/src/Helpers/CssVariablesTrait.php @@ -24,21 +24,20 @@ trait CssVariablesTrait */ public static function outputCssVariablesGlobalClean(array $globalSettings = []): string { - $output = ''; - $globalVariables = !empty($globalSettings) ? ($globalSettings['globalVariables'] ?? []) : Helpers::getSettingsGlobalVariables(); + $parts = []; foreach ($globalVariables as $itemKey => $itemValue) { $itemKey = Helpers::camelToKebabCase($itemKey); - if (\gettype($itemValue) === 'array') { - $output .= self::globalInner($itemValue, $itemKey); + if (\is_array($itemValue)) { + $parts[] = self::globalInner($itemValue, $itemKey); } else { - $output .= "--global-{$itemKey}: {$itemValue};\n"; + $parts[] = "--global-{$itemKey}: {$itemValue};\n"; } } - $output = ":root {{$output}}"; + $output = ':root {' . \implode('', $parts) . '}'; if (Helpers::getConfigOutputCssOptimize()) { $output = \str_replace(["\n", "\r"], '', $output); @@ -485,32 +484,28 @@ private static function getCssVariablesTypeInline(string $name, array $data, arr */ private static function globalInner(array $itemValues, string $itemKey): string { - $output = ''; + $itemKey = Helpers::camelToKebabCase($itemKey); + $parts = []; foreach ($itemValues as $key => $value) { - $key = Helpers::camelToKebabCase((string)$key); - $itemKey = Helpers::camelToKebabCase((string)$itemKey); - switch ($itemKey) { case 'colors': - $output .= "--global-{$itemKey}-{$value['slug']}: {$value['color']};\n"; - - $rgbValues = self::hexToRgb($value['color']); - $output .= "--global-{$itemKey}-{$value['slug']}-values: {$rgbValues};\n"; + $parts[] = "--global-{$itemKey}-{$value['slug']}: {$value['color']};\n"; + $parts[] = "--global-{$itemKey}-{$value['slug']}-values: " . self::hexToRgb($value['color']) . ";\n"; break; case 'gradients': - $output .= "--global-{$itemKey}-{$value['slug']}: {$value['gradient']};\n"; + $parts[] = "--global-{$itemKey}-{$value['slug']}: {$value['gradient']};\n"; break; case 'font-sizes': - $output .= "--global-{$itemKey}-{$value['slug']}: {$value['slug']};\n"; + $parts[] = "--global-{$itemKey}-{$value['slug']}: {$value['slug']};\n"; break; default: - $output .= "--global-{$itemKey}-{$key}: {$value};\n"; + $parts[] = '--global-' . $itemKey . '-' . Helpers::camelToKebabCase((string)$key) . ": {$value};\n"; break; } } - return $output; + return \implode('', $parts); } /** @@ -643,6 +638,12 @@ private static function getDefaultBreakpoints(array $breakpoints): array */ private static function setVariablesToBreakpoints(array $attributes, array $variables, array $data, array $manifest, array $defaultBreakpoints): array { + // Build an O(1) lookup from name+type to index so the inner match avoids a linear scan per breakpoint item. + $dataIndex = []; + foreach ($data as $index => $item) { + $dataIndex["{$item['name']}---{$item['type']}"] = $index; + } + foreach ($variables as $variableName => $variableValue) { // Constant for attributes set value (in db or default). $attributeValue = $attributes[Helpers::getAttrKey($variableName, $attributes, $manifest)] ?? ''; @@ -675,24 +676,22 @@ private static function setVariablesToBreakpoints(array $attributes, array $vari $isDefaultBreakpoint = empty($breakpointItem['breakpoint']) || $breakpointItem['breakpoint'] === $defaultBreakpoints[$type]; $breakpoint = $isDefaultBreakpoint ? 'default' : $breakpointItem['breakpoint']; + $lookupKey = "{$breakpoint}---{$type}"; + if (!isset($dataIndex[$lookupKey])) { + continue; + } - // Iterate each data array to find the correct breakpoint. - foreach ($data as $index => $item) { - // Check if breakpoint and type match. - if ( - $item['name'] === $breakpoint && - $item['type'] === $type && - ( - !empty((string) $attributeValue) || - \gettype($attributeValue) === 'integer' || - \gettype($attributeValue) === 'float' || - \gettype($attributeValue) === 'double' || - $attributeValue === '0' // @phpstan-ignore-line - ) - ) { - // Merge data variables with the new variables array. - $data[$index]['variable'] = \array_merge($item['variable'], self::variablesInner($variable, $attributeValue, $attributes, $manifest)); - } + if ( + !empty((string) $attributeValue) || + \is_int($attributeValue) || + \is_float($attributeValue) || + $attributeValue === '0' // @phpstan-ignore-line + ) { + $index = $dataIndex[$lookupKey]; + $data[$index]['variable'] = \array_merge( + $data[$index]['variable'], + self::variablesInner($variable, $attributeValue, $attributes, $manifest) + ); } } } @@ -709,6 +708,12 @@ private static function setVariablesToBreakpoints(array $attributes, array $vari */ private static function prepareVariableData(array $globalBreakpoints): array { + // Request-scoped cache: breakpoints are stable per request but this function is invoked once per block render. + static $cache = []; + $cacheKey = (string) \wp_json_encode($globalBreakpoints); + if (isset($cache[$cacheKey])) { + return $cache[$cacheKey]; + } // Define the min and max arrays. $min = []; @@ -779,8 +784,8 @@ private static function prepareVariableData(array $globalBreakpoints): array ] ); - // Merge both arrays. - return \array_merge($min, $max); + $cache[$cacheKey] = \array_merge($min, $max); + return $cache[$cacheKey]; } /** @@ -802,33 +807,27 @@ private static function variablesInner(array $variables, $attributeValue, array return $output; } - // Iterate each attribute and make corrections. - foreach ($variables as $variableKey => $variableValue) { - // Convert to correct case. - $internalKey = Helpers::camelToKebabCase($variableKey); + // Build the token replacement map once. Previously the inner attribute loop rebuilt the + // prefix/key for every variable, producing N (variables) * M (attributes) work. + $replacementMap = ['%value%' => (string) $attributeValue]; - // If value contains magic variable swap that variable with original attribute value. - if (\str_contains($variableValue, '%value%')) { - $variableValue = \str_replace('%value%', (string) $attributeValue, $variableValue); + $prefix = $attributes['prefix'] ?? null; + if ($prefix !== null && $prefix !== '') { + $replacement = Helpers::kebabToCamelCase(Helpers::getConfigUseLegacyComponents() ? $manifest['componentName'] : $manifest['blockName']); + foreach ($attributes as $attrKey => $attrValue) { + $key = (string) \str_replace($prefix, $replacement, (string) $attrKey); + $replacementMap["%attr-{$key}%"] = (string) $attrValue; } - + } else { foreach ($attributes as $attrKey => $attrValue) { - if (isset($attributes['prefix'])) { - $key = (string)\str_replace( - $attributes['prefix'], - Helpers::kebabToCamelCase(Helpers::getConfigUseLegacyComponents() ? $manifest['componentName'] : $manifest['blockName']), - $attrKey - ); - } else { - $key = $attrKey; - } - - if (\str_contains($variableValue, "%attr-{$key}%")) { - $variableValue = \str_replace("%attr-{$key}%", (string) $attrValue, $variableValue); - } + $replacementMap["%attr-{$attrKey}%"] = (string) $attrValue; } + } + + foreach ($variables as $variableKey => $variableValue) { + $internalKey = Helpers::camelToKebabCase($variableKey); + $variableValue = \strtr($variableValue, $replacementMap); - // Output the custom CSS variable by adding the attribute key + custom object key. $output[] = "--{$internalKey}: {$variableValue};"; } diff --git a/src/Helpers/TailwindTrait.php b/src/Helpers/TailwindTrait.php index 1d457e53..e6b9f9c2 100644 --- a/src/Helpers/TailwindTrait.php +++ b/src/Helpers/TailwindTrait.php @@ -493,9 +493,16 @@ public static function tailwindClasses($part, $attributes, $manifest, ...$custom $combinationClasses[] = self::processCombination($partName, $combo, $attributes, $manifest); } - $partPrefix = \strtolower(\preg_replace('/[^a-zA-Z]+/', '-', $manifest['title'])); - $isWpDebugActive = \defined('WP_DEBUG') && \WP_DEBUG; + $debugPrefix = ''; + if (\defined('WP_DEBUG') && \WP_DEBUG) { + static $slugCache = []; + $title = (string) ($manifest['title'] ?? ''); + if (!isset($slugCache[$title])) { + $slugCache[$title] = \strtolower(\preg_replace('/[^a-zA-Z]+/', '-', $title)); + } + $debugPrefix = "_es__{$slugCache[$title]}/{$part}"; + } - return Helpers::clsx([$isWpDebugActive ? "_es__{$partPrefix}/{$part}" : '', $baseClasses, ...$optionClasses, ...$combinationClasses, ...$custom]); + return Helpers::clsx([$debugPrefix, $baseClasses, ...$optionClasses, ...$combinationClasses, ...$custom]); } } From 5b212fe434fd45508ac23bb47f301ea33924af83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Ruz=CC=8Cevic=CC=81?= Date: Thu, 14 May 2026 17:01:29 +0200 Subject: [PATCH 02/17] update --- src/Helpers/CssVariablesTrait.php | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/Helpers/CssVariablesTrait.php b/src/Helpers/CssVariablesTrait.php index 9019d8b6..0cabbd26 100644 --- a/src/Helpers/CssVariablesTrait.php +++ b/src/Helpers/CssVariablesTrait.php @@ -809,17 +809,29 @@ private static function variablesInner(array $variables, $attributeValue, array // Build the token replacement map once. Previously the inner attribute loop rebuilt the // prefix/key for every variable, producing N (variables) * M (attributes) work. - $replacementMap = ['%value%' => (string) $attributeValue]; + // Skip non-stringable attributes (arrays/objects) — substituting them into a CSS template + // is never meaningful and the original code only cast them when a template actually + // referenced the token, so emitting an unconditional cast here would log warnings. + $replacementMap = []; + if (\is_scalar($attributeValue) || $attributeValue === null) { + $replacementMap['%value%'] = (string) $attributeValue; + } $prefix = $attributes['prefix'] ?? null; if ($prefix !== null && $prefix !== '') { $replacement = Helpers::kebabToCamelCase(Helpers::getConfigUseLegacyComponents() ? $manifest['componentName'] : $manifest['blockName']); foreach ($attributes as $attrKey => $attrValue) { + if (!\is_scalar($attrValue) && $attrValue !== null) { + continue; + } $key = (string) \str_replace($prefix, $replacement, (string) $attrKey); $replacementMap["%attr-{$key}%"] = (string) $attrValue; } } else { foreach ($attributes as $attrKey => $attrValue) { + if (!\is_scalar($attrValue) && $attrValue !== null) { + continue; + } $replacementMap["%attr-{$attrKey}%"] = (string) $attrValue; } } From 45fec5d715da077d1671722a56ea6409c982ae54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Ruz=CC=8Cevic=CC=81?= Date: Thu, 14 May 2026 17:12:41 +0200 Subject: [PATCH 03/17] update --- src/Helpers/CacheTrait.php | 125 ++++++++++++++++++------ src/Helpers/CssVariablesTrait.php | 4 +- src/Main/AbstractMain.php | 157 ++++++++++++++++++++++++++++++ 3 files changed, 255 insertions(+), 31 deletions(-) diff --git a/src/Helpers/CacheTrait.php b/src/Helpers/CacheTrait.php index a4354ba1..c3eb0b3d 100644 --- a/src/Helpers/CacheTrait.php +++ b/src/Helpers/CacheTrait.php @@ -122,53 +122,120 @@ public static function setAllCache(): void $transientKey = self::getTransientKey(); $timestampKey = self::getTimestampKey(); - // Try to load from transient first. + // Fast path: try transient + file without locking. + if (self::tryLoadFromCache($cacheFile, $transientKey, $timestampKey)) { + return; + } + + // Slow path: rebuild under an advisory lock to prevent stampede. + $lockHandle = self::acquireRebuildLock($cacheFile); + + try { + // Another request may have rebuilt while we were waiting on the lock. + if (self::tryLoadFromCache($cacheFile, $transientKey, $timestampKey)) { + return; + } + + $data = self::getAllManifests(); + $encoded = \wp_json_encode($data); + + if (\is_string($encoded) && self::writeFileOptimized($cacheFile, $encoded)) { + self::updateTransientCache($transientKey, $timestampKey, $encoded, $cacheFile); + } + + self::$cache = $data; + } finally { + self::releaseRebuildLock($lockHandle); + } + } + + /** + * Try to populate the in-memory cache from transient or file. Returns true on hit. + * + * @phpstan-impure Result depends on external state (transient store, file mtime) that another process can change between calls. + * + * @param string $cacheFile Path to the cache file. + * @param string $transientKey Transient key. + * @param string $timestampKey Timestamp option key. + * + * @throws Exception If a stored payload fails to parse. + * + * @return bool + */ + private static function tryLoadFromCache(string $cacheFile, string $transientKey, string $timestampKey): bool + { $transientData = \get_transient($transientKey); if ($transientData !== false && \is_string($transientData)) { - // Validate timestamp consistency between transient and file. if (self::isTransientValid($cacheFile, $timestampKey)) { - try { - self::$cache = self::parseManifest($transientData); - } catch (Exception $e) { - throw $e; - } - - return; + self::$cache = self::parseManifest($transientData); + return true; } // Timestamp mismatch, remove invalid transient. \delete_transient($transientKey); } - // Try to load from file cache. if (\file_exists($cacheFile)) { $content = \file_get_contents($cacheFile); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents - if ($content !== false) { - try { - $decoded = self::parseManifest($content); - // Update transient and timestamp from file cache. - self::updateTransientCache($transientKey, $timestampKey, $content, $cacheFile); - self::$cache = $decoded; - return; - } catch (Exception $e) { - throw $e; - } + $decoded = self::parseManifest($content); + self::updateTransientCache($transientKey, $timestampKey, $content, $cacheFile); + self::$cache = $decoded; + return true; } } - // Generate new cache data. - $data = self::getAllManifests(); + return false; + } - // Write to file and update transient. - if (self::writeFileOptimized($cacheFile, \wp_json_encode($data))) { - self::updateTransientCache($transientKey, $timestampKey, \wp_json_encode($data), $cacheFile); - self::$cache = $data; - } else { - // Fallback if file writing fails. - self::$cache = $data; + /** + * Acquire an exclusive advisory lock for the cache rebuild path. + * + * Returns the lock handle on success, or null when the lock can't be obtained + * (caller proceeds without stampede protection in that case). + * + * @param string $cacheFile Path to the cache file (lock lives next to it). + * + * @return resource|null + */ + private static function acquireRebuildLock(string $cacheFile) + { + $lockPath = $cacheFile . '.lock'; + $directory = \dirname($lockPath); + + if (!\is_dir($directory) && !\mkdir($directory, 0755, true) && !\is_dir($directory)) { + return null; + } + + $handle = \fopen($lockPath, 'c'); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen + if ($handle === false) { + return null; } + + if (!\flock($handle, \LOCK_EX)) { + \fclose($handle); + return null; + } + + return $handle; + } + + /** + * Release a previously-acquired rebuild lock. + * + * @param resource|null $handle Lock handle returned by acquireRebuildLock(). + * + * @return void + */ + private static function releaseRebuildLock($handle): void + { + if (!\is_resource($handle)) { + return; + } + + \flock($handle, \LOCK_UN); + \fclose($handle); } /** diff --git a/src/Helpers/CssVariablesTrait.php b/src/Helpers/CssVariablesTrait.php index 0cabbd26..077a6344 100644 --- a/src/Helpers/CssVariablesTrait.php +++ b/src/Helpers/CssVariablesTrait.php @@ -56,7 +56,7 @@ public static function outputCssVariablesGlobalClean(array $globalSettings = []) public static function outputCssVariablesGlobal(array $globalSettings = []): string { $output = self::outputCssVariablesGlobalClean($globalSettings); - $id = Helpers::getConfigOutputCssSelectorName() . '-global'; + $id = \esc_attr(Helpers::getConfigOutputCssSelectorName() . '-global'); return ""; } @@ -291,7 +291,7 @@ static function () { public static function outputCssVariablesInline(array $globalSettings = []): string { $output = self::outputCssVariablesInlineClean($globalSettings); - $selector = Helpers::getConfigOutputCssSelectorName(); + $selector = \esc_attr(Helpers::getConfigOutputCssSelectorName()); return ""; } diff --git a/src/Main/AbstractMain.php b/src/Main/AbstractMain.php index 16bcdda1..a0465267 100644 --- a/src/Main/AbstractMain.php +++ b/src/Main/AbstractMain.php @@ -21,6 +21,8 @@ // phpcs:ignore SlevomatCodingStandard.Namespaces.UnusedUses.UnusedUse use Exception; use ReflectionClass; +use RecursiveIteratorIterator; +use RecursiveDirectoryIterator; /** * The main start class. @@ -168,6 +170,12 @@ function ($class) use ($container) { */ private function getServiceClassesPreparedArray(): array { + // Dev-mode fast path: reuse the previous autowire result while project source is unchanged. + $cached = $this->loadDevServiceCache(); + if ($cached !== null) { + return $cached; + } + $output = []; foreach ($this->getServiceClassesWithAutowire() as $class => $dependencies) { @@ -179,9 +187,158 @@ private function getServiceClassesPreparedArray(): array $output[$dependencies] = []; } + $this->writeDevServiceCache($output); + return $output; } + /** + * Load the development service cache, keyed by max mtime of the namespace's source tree. + * + * Returns null when the cache is disabled, missing, malformed, or stale. + * + * @return array|null + */ + private function loadDevServiceCache(): ?array + { + if (!$this->isDevServiceCacheEnabled()) { + return null; + } + + $cachePath = $this->getDevServiceCachePath(); + if (!\is_file($cachePath)) { + return null; + } + + $content = \file_get_contents($cachePath); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + if ($content === false || $content === '') { + return null; + } + + $payload = \json_decode($content, true); + if ( + !\is_array($payload) || + !isset($payload['mtime'], $payload['services']) || + !\is_array($payload['services']) + ) { + return null; + } + + if ((int) $payload['mtime'] !== $this->getNamespaceMaxMtime()) { + return null; + } + + return $payload['services']; + } + + /** + * Persist the prepared services array to the development cache. + * + * No-op when the dev cache is disabled or the target is unwritable. + * + * @param array $services Services array to persist. + * + * @return void + */ + private function writeDevServiceCache(array $services): void + { + if (!$this->isDevServiceCacheEnabled()) { + return; + } + + $cachePath = $this->getDevServiceCachePath(); + $directory = \dirname($cachePath); + + if (!\is_dir($directory) && !\mkdir($directory, 0755, true) && !\is_dir($directory)) { + return; + } + + $encoded = \wp_json_encode([ + 'mtime' => $this->getNamespaceMaxMtime(), + 'services' => $services, + ]); + + if (!\is_string($encoded)) { + return; + } + + if (\file_put_contents($cachePath, $encoded, \LOCK_EX) !== false) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents + \chmod($cachePath, 0644); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod + } + } + + /** + * Whether the development autowiring cache should be consulted. + * + * Disabled in production (compiled container handles it) and under WP-CLI + * to ensure scaffolding sees freshly added classes. + * + * @return bool + */ + private function isDevServiceCacheEnabled(): bool + { + if (Helpers::shouldCache()) { + return false; + } + + if (\defined('WP_CLI') && \WP_CLI) { + return false; + } + + return true; + } + + /** + * Cache file path for the development autowiring cache. + * + * @return string + */ + private function getDevServiceCachePath(): string + { + $file = \explode('\\', $this->namespace); + return Helpers::getEightshiftOutputPath("{$file[0]}DevServiceClasses.json"); + } + + /** + * Maximum mtime of any PHP file under the namespace's psr-4 root. Memoized per request. + * + * @return int + */ + private function getNamespaceMaxMtime(): int + { + static $cached = null; + if ($cached !== null) { + return $cached; + } + + $namespaceWithSlash = "{$this->namespace}\\"; + $pathToNamespace = $this->psr4Prefixes[$namespaceWithSlash][0] ?? ''; + + if (!\is_string($pathToNamespace) || !\is_dir($pathToNamespace)) { + $cached = 0; + return 0; + } + + $max = 0; + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($pathToNamespace, RecursiveDirectoryIterator::SKIP_DOTS) + ); + + foreach ($iterator as $entry) { + if (!$entry->isFile() || $entry->getExtension() !== 'php') { + continue; + } + + $mtime = $entry->getMTime(); + if ($mtime > $max) { + $max = $mtime; + } + } + + $cached = $max; + return $max; + } + /** * Implement PHP-DI. * From 636c9f13a654dc6537d6d5b8b95b751f3f71c1f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Ruz=CC=8Cevic=CC=81?= Date: Thu, 14 May 2026 17:24:54 +0200 Subject: [PATCH 04/17] update --- src/Exception/ComponentException.php | 6 ++-- src/Helpers/GeneralTrait.php | 40 +++++++++++------------ src/Main/AbstractMain.php | 14 +-------- src/Main/Autowiring.php | 47 +++++++++++++--------------- 4 files changed, 45 insertions(+), 62 deletions(-) diff --git a/src/Exception/ComponentException.php b/src/Exception/ComponentException.php index 357e6c97..a2b31444 100644 --- a/src/Exception/ComponentException.php +++ b/src/Exception/ComponentException.php @@ -28,15 +28,15 @@ final class ComponentException extends InvalidArgumentException implements Gener */ public static function throwNotStringOrArray($variable): ComponentException { - if (\gettype($variable) !== 'object') { + if (\is_object($variable)) { + $output = \esc_html__('Object couldn\'t be converted to string. Please provide only string or array.', 'eightshift-libs'); + } else { $output = \sprintf( /* translators: %1$s is replaced with the name of the variable, and %2$s with its type. */ \esc_html__('%1$s variable is not a string or array but rather %2$s', 'eightshift-libs'), $variable, \gettype($variable) ); - } else { - $output = \esc_html__('Object couldn\'t be converted to string. Please provide only string or array.', 'eightshift-libs'); } return new ComponentException($output); diff --git a/src/Helpers/GeneralTrait.php b/src/Helpers/GeneralTrait.php index baf3aded..fb782b96 100644 --- a/src/Helpers/GeneralTrait.php +++ b/src/Helpers/GeneralTrait.php @@ -14,6 +14,7 @@ use EightshiftLibs\Exception\InvalidManifest; use RecursiveArrayIterator; use RecursiveIteratorIterator; +use JsonException; /** * Class General Helper @@ -251,29 +252,26 @@ public static function parseManifest(string $manifest): array throw InvalidManifest::manifestStructureException(\esc_html__('Empty manifest provided.', 'eightshift-libs')); } - $result = \json_decode($manifest, true); - $jsonError = \json_last_error(); - - // Fast path for no errors. - if ($jsonError === \JSON_ERROR_NONE) { - return $result ?? []; + try { + $result = \json_decode($manifest, true, 512, \JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + $errorMessages = [ + \JSON_ERROR_DEPTH => \esc_html__('The maximum stack depth has been exceeded.', 'eightshift-libs'), + \JSON_ERROR_STATE_MISMATCH => \esc_html__('Invalid or malformed JSON.', 'eightshift-libs'), + \JSON_ERROR_CTRL_CHAR => \esc_html__('Control character error, possibly incorrectly encoded.', 'eightshift-libs'), + \JSON_ERROR_SYNTAX => \esc_html__('Syntax error, malformed JSON.', 'eightshift-libs'), + \JSON_ERROR_UTF8 => \esc_html__('Malformed UTF-8 characters, possibly incorrectly encoded.', 'eightshift-libs'), + \JSON_ERROR_RECURSION => \esc_html__('One or more recursive references in the value to be encoded.', 'eightshift-libs'), + \JSON_ERROR_INF_OR_NAN => \esc_html__('One or more NAN or INF values in the value to be encoded.', 'eightshift-libs'), + \JSON_ERROR_UNSUPPORTED_TYPE => \esc_html__('A value of a type that cannot be encoded was given.', 'eightshift-libs'), + ]; + + $error = $errorMessages[$e->getCode()] ?? \esc_html__('Unknown JSON error occurred.', 'eightshift-libs'); + + throw InvalidManifest::manifestStructureException($error); } - // Optimized error handling using lookup table. - $errorMessages = [ - \JSON_ERROR_DEPTH => \esc_html__('The maximum stack depth has been exceeded.', 'eightshift-libs'), - \JSON_ERROR_STATE_MISMATCH => \esc_html__('Invalid or malformed JSON.', 'eightshift-libs'), - \JSON_ERROR_CTRL_CHAR => \esc_html__('Control character error, possibly incorrectly encoded.', 'eightshift-libs'), - \JSON_ERROR_SYNTAX => \esc_html__('Syntax error, malformed JSON.', 'eightshift-libs'), - \JSON_ERROR_UTF8 => \esc_html__('Malformed UTF-8 characters, possibly incorrectly encoded.', 'eightshift-libs'), - \JSON_ERROR_RECURSION => \esc_html__('One or more recursive references in the value to be encoded.', 'eightshift-libs'), - \JSON_ERROR_INF_OR_NAN => \esc_html__('One or more NAN or INF values in the value to be encoded.', 'eightshift-libs'), - \JSON_ERROR_UNSUPPORTED_TYPE => \esc_html__('A value of a type that cannot be encoded was given.', 'eightshift-libs'), - ]; - - $error = $errorMessages[$jsonError] ?? \esc_html__('Unknown JSON error occurred.', 'eightshift-libs'); - - throw InvalidManifest::manifestStructureException($error); + return \is_array($result) ? $result : []; } /** diff --git a/src/Main/AbstractMain.php b/src/Main/AbstractMain.php index a0465267..bfff0552 100644 --- a/src/Main/AbstractMain.php +++ b/src/Main/AbstractMain.php @@ -45,18 +45,6 @@ abstract class AbstractMain extends Autowiring implements ServiceInterface */ protected Container $container; - /** - * Constructs object and inserts prefixes from composer. - * - * @param array $psr4Prefixes Composer's ClassLoader psr4Prefixes. $ClassLoader->getPsr4Prefixes(). - * @param string $projectNamespace Projects namespace. - */ - public function __construct(array $psr4Prefixes, string $projectNamespace) - { - $this->psr4Prefixes = $psr4Prefixes; - $this->namespace = $projectNamespace; - } - /** * Register the individual services with optional dependency injection. * @@ -357,7 +345,7 @@ private function getDiContainer(array $services): Container $definitions = []; foreach ($services as $serviceKey => $serviceValues) { - if (\gettype($serviceValues) !== 'array') { + if (!\is_array($serviceValues)) { continue; } diff --git a/src/Main/Autowiring.php b/src/Main/Autowiring.php index 46c0167a..274177e0 100644 --- a/src/Main/Autowiring.php +++ b/src/Main/Autowiring.php @@ -28,18 +28,16 @@ class Autowiring { /** - * Array of psr-4 prefixes. Should be provided by Composer's ClassLoader. $ClassLoader->getPsr4Prefixes(). + * Initialize the autowiring scanner with the composer PSR-4 map and the target project namespace. * - * @var array + * @param array $psr4Prefixes Composer's ClassLoader psr4Prefixes. $ClassLoader->getPsr4Prefixes(). + * @param string $namespace Project namespace. */ - protected array $psr4Prefixes; - - /** - * Project namespace - * - * @var string - */ - protected string $namespace; + public function __construct( + protected array $psr4Prefixes, + protected string $namespace, + ) { + } /** * Autowiring. @@ -79,10 +77,10 @@ public function buildServiceClasses(array $manuallyDefinedDependencies = [], boo } // Build the dependency tree. - $dependencyTree = \array_merge( - $this->buildDependencyTree($projectClass, $filenameIndex, $classInterfaceIndex), - $dependencyTree - ); + $dependencyTree = [ + ...$this->buildDependencyTree($projectClass, $filenameIndex, $classInterfaceIndex), + ...$dependencyTree, + ]; } // Build dependency tree for dependencies. Things that need to be injected but were skipped because @@ -94,15 +92,18 @@ public function buildServiceClasses(array $manuallyDefinedDependencies = [], boo continue; } - $dependencyTree = \array_merge( - $this->buildDependencyTree((string)$depClass, $filenameIndex, $classInterfaceIndex), - $dependencyTree - ); + $dependencyTree = [ + ...$this->buildDependencyTree((string)$depClass, $filenameIndex, $classInterfaceIndex), + ...$dependencyTree, + ]; } } // Convert dependency tree into PHP-DI's definition list. - return \array_merge($this->convertDependencyTreeIntoDefinitionList($dependencyTree), $manuallyDefinedDependencies); + return [ + ...$this->convertDependencyTreeIntoDefinitionList($dependencyTree), + ...$manuallyDefinedDependencies, + ]; } // phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber @@ -332,14 +333,10 @@ private function buildClassInterfaceIndex(array $reflectionClasses): array { $classInterfaceIndex = []; foreach ($reflectionClasses as $projectClass => $reflectionClass) { - $interfaces = \array_map( - function () { - return true; - }, + $classInterfaceIndex[$projectClass] = \array_map( + static fn () => true, $reflectionClass->getInterfaces() ); - - $classInterfaceIndex[$projectClass] = $interfaces; } return $classInterfaceIndex; From 981afd45bb0e62cb801981f9e42e3d16306d9ccd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Ruz=CC=8Cevic=CC=81?= Date: Thu, 14 May 2026 17:33:17 +0200 Subject: [PATCH 05/17] update --- src/Helpers/CssVariablesTrait.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Helpers/CssVariablesTrait.php b/src/Helpers/CssVariablesTrait.php index 077a6344..f9f80a0f 100644 --- a/src/Helpers/CssVariablesTrait.php +++ b/src/Helpers/CssVariablesTrait.php @@ -80,7 +80,7 @@ public static function outputCssVariables(array $attributes, array $manifest, st } // Define variables from globalManifest. - $breakpoints = !empty($globalSettings) ? ($globalSettings['globalVariables']['breakpoints'] ?? []) : self::getSettingsGlobalVariablesBreakpoints(); + $breakpoints = !empty($globalSettings) ? ($globalSettings['globalVariables']['breakpoints'] ?? []) : Helpers::getSettingsGlobalVariablesBreakpoints(); // Sort breakpoints in ascending order. \asort($breakpoints); @@ -140,6 +140,7 @@ static function ($key) use ($attributes) { } // Load normal styles if server side render is used. + // Read-only switch between two render paths for the block editor's SSR preview; no state change, so a nonce is not required. $context = isset($_GET['context']) ? \sanitize_text_field(\wp_unslash($_GET['context'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended // If default output just echo. @@ -163,6 +164,7 @@ static function ($key) use ($attributes) { public static function outputCssVariablesInlineClean(array $globalSettings = []): string { // Load normal styles if server side render is used. + // Read-only switch between two render paths for the block editor's SSR preview; no state change, so a nonce is not required. $context = isset($_GET['context']) ? \sanitize_text_field(\wp_unslash($_GET['context'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended // If default output just echo. @@ -178,7 +180,7 @@ public static function outputCssVariablesInlineClean(array $globalSettings = []) // Bailout if styles are missing. if ($styles) { // Define variables from globalManifest. - $breakpointsData = !empty($globalSettings) ? ($globalSettings['globalVariables']['breakpoints'] ?? []) : self::getSettingsGlobalVariablesBreakpoints(); + $breakpointsData = !empty($globalSettings) ? ($globalSettings['globalVariables']['breakpoints'] ?? []) : Helpers::getSettingsGlobalVariablesBreakpoints(); // Sort breakpoints in ascending order. \asort($breakpointsData); From 5b5d345f7c4f037caa1b1799c9a6625ffc37efc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Ruz=CC=8Cevic=CC=81?= Date: Thu, 14 May 2026 17:40:16 +0200 Subject: [PATCH 06/17] update --- src/Helpers/SelectorsTrait.php | 6 +++--- src/Helpers/TailwindTrait.php | 13 +++++++++++-- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/Helpers/SelectorsTrait.php b/src/Helpers/SelectorsTrait.php index ee9a95a3..ffe88960 100644 --- a/src/Helpers/SelectorsTrait.php +++ b/src/Helpers/SelectorsTrait.php @@ -147,11 +147,11 @@ public static function ensureString($variable): string if ($isAssociative) { // For associative arrays, build data attributes. - $output = ''; + $parts = []; foreach ($variable as $key => $value) { - $output .= $key . '="' . \htmlspecialchars((string)$value, \ENT_QUOTES, 'UTF-8') . '" '; + $parts[] = $key . '="' . \htmlspecialchars((string)$value, \ENT_QUOTES, 'UTF-8') . '"'; } - return \rtrim($output); // Remove trailing space. + return \implode(' ', $parts); } else { // For sequential arrays, join elements. return \implode('', $variable); diff --git a/src/Helpers/TailwindTrait.php b/src/Helpers/TailwindTrait.php index e6b9f9c2..61c963cc 100644 --- a/src/Helpers/TailwindTrait.php +++ b/src/Helpers/TailwindTrait.php @@ -27,6 +27,13 @@ trait TailwindTrait */ public static function getTwBreakpoints($desktopFirst = false) { + static $cache = []; + + $key = $desktopFirst ? 'desktop' : 'mobile'; + if (isset($cache[$key])) { + return $cache[$key]; + } + $breakpointData = Helpers::getSettingsGlobalVariablesBreakpoints(); $breakpointNames = \array_keys($breakpointData); @@ -34,10 +41,12 @@ public static function getTwBreakpoints($desktopFirst = false) \usort($breakpointNames, fn($a, $b) => $breakpointData[$a] - $breakpointData[$b]); if ($desktopFirst) { - return \array_map(fn($breakpoint) => "max-{$breakpoint}", $breakpointNames); + $cache[$key] = \array_map(fn($breakpoint) => "max-{$breakpoint}", $breakpointNames); + return $cache[$key]; } - return $breakpointNames; + $cache[$key] = $breakpointNames; + return $cache[$key]; } /** From c0e148b27312a051b9fa4d8cc0ed42e94a8cb7be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Ruz=CC=8Cevic=CC=81?= Date: Thu, 14 May 2026 17:58:28 +0200 Subject: [PATCH 07/17] update --- src/Helpers/PathsTrait.php | 10 ++-- src/Helpers/RenderTrait.php | 91 +++++++++++++++----------------- src/Helpers/StoreBlocksTrait.php | 9 +++- 3 files changed, 57 insertions(+), 53 deletions(-) diff --git a/src/Helpers/PathsTrait.php b/src/Helpers/PathsTrait.php index 4efa3e13..3f8d911d 100644 --- a/src/Helpers/PathsTrait.php +++ b/src/Helpers/PathsTrait.php @@ -92,16 +92,16 @@ public static function getProjectPaths(string $type = '', array|string $suffix = // Fast path for empty type. if ($type === '') { - return self::joinPaths(\array_merge([self::$basePaths['root']], $suffix)); + return self::joinPaths([self::$basePaths['root'], ...$suffix]); } // Use cached path configuration for fast lookup. if (isset(self::$pathConfigs[$type])) { - return self::joinPaths(\array_merge(self::$pathConfigs[$type], $suffix)); + return self::joinPaths([...self::$pathConfigs[$type], ...$suffix]); } // Fallback for unknown type (should rarely happen). - return self::joinPaths(\array_merge([self::$basePaths['root']], $suffix)); + return self::joinPaths([self::$basePaths['root'], ...$suffix]); } /** @@ -135,7 +135,9 @@ public static function joinPaths(array $paths): string $joinedPath = $sep . \implode($sep, $filteredPaths); - if (\pathinfo($joinedPath, \PATHINFO_EXTENSION)) { + // Treat as a file path when the last segment carries an extension (dot after the final separator). + $lastDot = \strrpos($joinedPath, '.'); + if ($lastDot !== false && $lastDot > \strrpos($joinedPath, $sep)) { return $joinedPath; } diff --git a/src/Helpers/RenderTrait.php b/src/Helpers/RenderTrait.php index 67666d34..405ef6d0 100644 --- a/src/Helpers/RenderTrait.php +++ b/src/Helpers/RenderTrait.php @@ -42,31 +42,18 @@ trait RenderTrait private static ?array $allowedNamesFlipped = null; /** - * Cached render type patterns for performance. + * Cached default render path name (resolved from legacy-components config). * - * @var array|null + * @var string|null */ - private static ?array $renderHandlers = null; + private static ?string $defaultPathName = null; /** - * Initialize render-related static caches if not already done. + * Cache of resolved render paths already verified to exist on disk. * - * @return void + * @var array */ - private static function initializeRenderCaches(): void - { - if (self::$allowedNamesFlipped === null) { - self::$allowedNamesFlipped = \array_flip(self::PROJECT_RENDER_ALLOWED_NAMES); - } - - if (self::$renderHandlers === null) { - self::$renderHandlers = [ - 'components' => [self::class, 'handleComponentsRender'], - 'wrapper' => [self::class, 'handleWrapperRender'], - 'blocks' => [self::class, 'handleBlocksRender'], - ]; - } - } + private static array $fileExistsCache = []; /** * Handle components render logic. @@ -175,59 +162,60 @@ public static function render( string $renderContent = '', ?WP_Block $renderBlock = null ): string { - // Initialize render caches and path caches. - self::initializeRenderCaches(); + if (self::$allowedNamesFlipped === null) { + self::$allowedNamesFlipped = \array_flip(self::PROJECT_RENDER_ALLOWED_NAMES); + } Helpers::initializePathCaches(); - // Set default path name if not provided (optimized with early return). if (!$renderPathName) { - $renderPathName = Helpers::getConfigUseLegacyComponents() ? 'components' : 'blocks'; + if (self::$defaultPathName === null) { + self::$defaultPathName = Helpers::getConfigUseLegacyComponents() ? 'components' : 'blocks'; + } + $renderPathName = self::$defaultPathName; } - // Fast path validation using pre-cached flipped array. if (!isset(self::$allowedNamesFlipped[$renderPathName])) { throw InvalidPath::wrongOrNotAllowedParentPathException($renderPathName, \implode(', ', self::PROJECT_RENDER_ALLOWED_NAMES)); } - // Extract component/block name once if needed (optimized extraction). $componentName = ''; if ($renderPrefixPath && ($renderPathName === 'components' || $renderPathName === 'blocks')) { $separatorPos = \strpos($renderPrefixPath, \DIRECTORY_SEPARATOR); $componentName = $separatorPos !== false ? \substr($renderPrefixPath, 0, $separatorPos) : $renderPrefixPath; } - // Use optimized render handlers. - if (isset(self::$renderHandlers[$renderPathName])) { - $result = self::$renderHandlers[$renderPathName]($renderName, $renderPrefixPath, $componentName); - $renderPath = $result['path']; - $manifest = $result['manifest']; - } else { - // Default case - optimized path building. - $renderPath = Helpers::getProjectPaths('', [$renderPathName, $renderPrefixPath, "{$renderName}.php"]); - $manifest = []; - } - - // Early file existence check to fail fast. - if (!\file_exists($renderPath)) { - throw InvalidPath::missingFileException($renderPath); + $result = match ($renderPathName) { + 'components' => self::handleComponentsRender($renderName, $renderPrefixPath, $componentName), + 'wrapper' => self::handleWrapperRender($renderName), + 'blocks' => self::handleBlocksRender($renderName, $renderPrefixPath, $componentName), + default => [ + 'path' => Helpers::getProjectPaths('', [$renderPathName, $renderPrefixPath, "{$renderName}.php"]), + 'manifest' => [], + ], + }; + + $renderPath = $result['path']; + $manifest = $result['manifest']; + + if (!isset(self::$fileExistsCache[$renderPath])) { + if (!\file_exists($renderPath)) { + throw InvalidPath::missingFileException($renderPath); + } + self::$fileExistsCache[$renderPath] = true; } - // Optimize attribute merging with early return. if ($renderUseComponentDefaults && !empty($manifest)) { $renderAttributes = Helpers::getDefaultRenderAttributes($manifest, $renderAttributes); } - // Optimize output buffering and variable assignment. \ob_start(); - // Pre-assign variables for performance (avoid repeated method calls). $attributes = $renderAttributes; $globalManifest = Helpers::getSettings(); $innerBlockData = null; - // Only process innerBlocks data for blocks to avoid unnecessary processing. if ($renderPathName === 'blocks') { // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps // @phpstan-ignore nullCoalesce.property @@ -238,14 +226,21 @@ public static function render( } } - // Unset variables for memory optimization. - unset($renderName, $renderAttributes, $renderPathName, $renderUseComponentDefaults, $renderPrefixPath, $componentName, $renderBlock); + // Strip internal variables so only the intentional set leaks into the included template scope. + unset( + $renderName, + $renderAttributes, + $renderPathName, + $renderUseComponentDefaults, + $renderPrefixPath, + $componentName, + $renderBlock, + $separatorPos, + $result + ); include $renderPath; - // Clean up variables. - unset($attributes, $renderContent, $innerBlockData, $renderPath, $manifest, $globalManifest); - return \trim((string) \ob_get_clean()); } } diff --git a/src/Helpers/StoreBlocksTrait.php b/src/Helpers/StoreBlocksTrait.php index d3248b6b..e3bd6d5d 100644 --- a/src/Helpers/StoreBlocksTrait.php +++ b/src/Helpers/StoreBlocksTrait.php @@ -276,13 +276,20 @@ public static function getConfigUseLegacyComponents(): bool */ public static function getSettings(): array { + static $settingsCache = null; + + if ($settingsCache !== null) { + return $settingsCache; + } + $data = self::getCachedData(AbstractManifestCache::TYPE_BLOCKS, AbstractManifestCache::SETTINGS_KEY); if (empty($data)) { throw InvalidBlock::missingItemException('project', 'global settings'); } - return $data; + $settingsCache = $data; + return $settingsCache; } /** From 2f20bedcf101da4fcaa99453d3c4d3c37717b1ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Ruz=CC=8Cevic=CC=81?= Date: Thu, 14 May 2026 20:22:21 +0200 Subject: [PATCH 08/17] update --- src/Blocks/AbstractBlocks.php | 109 ++++++++++++++++++++-------------- 1 file changed, 63 insertions(+), 46 deletions(-) diff --git a/src/Blocks/AbstractBlocks.php b/src/Blocks/AbstractBlocks.php index 1d5c0dee..7034bc3e 100644 --- a/src/Blocks/AbstractBlocks.php +++ b/src/Blocks/AbstractBlocks.php @@ -22,6 +22,20 @@ */ abstract class AbstractBlocks implements ServiceInterface, RenderableBlockInterface { + /** + * Memoized camel-to-kebab conversions for component names. + * + * @var array + */ + private static array $camelToKebabMemo = []; + + /** + * Memoized kebab-to-camel conversions for parent prefixes. + * + * @var array + */ + private static array $kebabToCamelMemo = []; + /** * Create custom project color palette. * These colors are fetched from the main settings manifest.json. @@ -65,16 +79,17 @@ public function getAllAllowedBlocksList($allowedBlockTypes, WP_Block_Editor_Cont return $allowedBlockTypes; } - $blocks = Helpers::getBlocks(); + static $projectBlockNames = null; + + if ($projectBlockNames === null) { + $blocks = Helpers::getBlocks(); + $projectBlockNames = $blocks + ? \array_values(\array_map(static fn($block) => $block['blockFullName'], $blocks)) + : []; + } - if ($blocks) { - $allowedBlockTypes = \array_values(\array_merge( - \array_map( - fn($block) => $block['blockFullName'], - $blocks - ), - $allowedBlockTypes, - )); + if ($projectBlockNames) { + $allowedBlockTypes = \array_values(\array_merge($projectBlockNames, $allowedBlockTypes)); } // Allow reusable block. @@ -110,8 +125,15 @@ public function getAllBlocksList($allowedBlockTypes, WP_Block_Editor_Context $bl */ public function registerBlocks(): void { + $settings = Helpers::getSettings(); + $context = [ + 'blockClassPrefix' => $settings['blockClassPrefix'] ?? 'block', + 'settingsAttributes' => $settings['attributes'] ?? [], + 'wrapperAttributes' => Helpers::getConfigUseWrapper() ? (Helpers::getWrapper()['attributes'] ?? []) : [], + ]; + foreach (Helpers::getBlocks() as $block) { - $this->registerBlock($block); + $this->registerBlock($block, $context); } } @@ -230,10 +252,11 @@ public function outputCssVariablesGlobal(): void * It uses native register_block_type() function from WP. * * @param array $blockDetails Full Block Manifest details. + * @param array $context Shared registration context (blockClassPrefix, settingsAttributes, wrapperAttributes). * * @return void */ - private function registerBlock(array $blockDetails): void + private function registerBlock(array $blockDetails, array $context): void { if (($blockDetails['active'] ?? true) === false) { return; @@ -243,7 +266,7 @@ private function registerBlock(array $blockDetails): void $blockDetails['blockFullName'], [ 'render_callback' => [$this, 'render'], - 'attributes' => $this->getAttributes($blockDetails), + 'attributes' => $this->getAttributes($blockDetails, $context), ] ); } @@ -257,19 +280,14 @@ private function registerBlock(array $blockDetails): void * Also it is doing recursive loop for all children components and their attributes. * * @param array $blockDetails Block Manifest details. + * @param array $context Shared registration context (blockClassPrefix, settingsAttributes, wrapperAttributes). * * @return array */ - private function getAttributes(array $blockDetails): array + private function getAttributes(array $blockDetails, array $context): array { $blockName = $blockDetails['blockName']; - $blockClassPrefix = Helpers::getSettings()['blockClassPrefix'] ?? 'block'; - - $wrapperAttributes = []; - - if (Helpers::getConfigUseWrapper()) { - $wrapperAttributes = Helpers::getWrapper()['attributes'] ?? []; - } + $blockClassPrefix = $context['blockClassPrefix']; return \array_merge( [ @@ -302,8 +320,8 @@ private function getAttributes(array $blockDetails): array 'default' => false, ], ], - Helpers::getSettings()['attributes'] ?? [], - $wrapperAttributes, + $context['settingsAttributes'], + $context['wrapperAttributes'], $this->prepareComponentAttributes($blockDetails) ); } @@ -332,32 +350,36 @@ private function prepareComponentAttribute(array $manifest, string $newName, str } // Make sure the case is always correct for parent. - $newParent = Helpers::kebabToCamelCase($parent); + $newParent = self::$kebabToCamelMemo[$parent] ??= Helpers::kebabToCamelCase($parent); + + // Precompute the prefix that gets stripped when currentAttributes is true. + $currentPrefix = $currentAttributes + ? \lcfirst(self::$kebabToCamelMemo[$realName] ??= Helpers::kebabToCamelCase($realName)) + : ''; // Iterate each attribute and attach parent prefixes. - $componentAttributeKeys = \array_keys($componentAttributes); - foreach ($componentAttributeKeys as $componentAttribute) { - $attribute = $componentAttribute; + foreach ($componentAttributes as $componentAttribute => $attributeValue) { + $attribute = (string) $componentAttribute; // If there is an attribute name switch, use the new one. if ($newName !== $realName) { - $attribute = \str_replace($realName, $newName, (string) $componentAttribute); + $attribute = \str_replace($realName, $newName, $attribute); } // Check if current attribute is used strip component prefix from attribute and replace it with parent prefix. if ($currentAttributes) { - $attribute = \str_replace(\lcfirst(Helpers::kebabToCamelCase($realName)), '', (string) $componentAttribute); + $attribute = \str_replace($currentPrefix, '', (string) $componentAttribute); } // Determine if parent is empty and if parent name is the same as component/block name and skip wrapper attributes. - if (\substr((string)$attribute, 0, \strlen('wrapper')) === 'wrapper') { + if (\str_starts_with($attribute, 'wrapper')) { $attributeName = $attribute; } else { - $attributeName = $newParent . \ucfirst((string)$attribute); + $attributeName = $newParent . \ucfirst($attribute); } // Output new attribute names. - $output[$attributeName] = $componentAttributes[$componentAttribute]; + $output[$attributeName] = $attributeValue; } return $output; @@ -376,8 +398,6 @@ private function prepareComponentAttribute(array $manifest, string $newName, str */ private function prepareComponentAttributes(array $manifest, string $parent = ''): array { - $output = []; - // Determine if this is component or block and provide the name, not used for anything important but only to output the error msg. $name = $manifest['blockName'] ?? ''; @@ -389,10 +409,13 @@ private function prepareComponentAttributes(array $manifest, string $parent = '' $newParent = ($parent === '') ? $name : $parent; + $collected = []; + // Iterate over components key in manifest recursively and check component names. foreach ($components as $newComponentName => $realComponentName) { // Filter components real name. - $component = Helpers::getComponent(Helpers::camelToKebabCase($realComponentName)); + $realKebab = self::$camelToKebabMemo[$realComponentName] ??= Helpers::camelToKebabCase($realComponentName); + $component = Helpers::getComponent($realKebab); // Bailout if component doesn't exist. if (!$component) { @@ -401,22 +424,16 @@ private function prepareComponentAttributes(array $manifest, string $parent = '' // If component has more components do recursive loop. if (isset($component['components'])) { - $outputAttributes = $this->prepareComponentAttributes($component, $newParent . \ucfirst(Helpers::camelToKebabCase($newComponentName))); + $newKebab = self::$camelToKebabMemo[$newComponentName] ??= Helpers::camelToKebabCase($newComponentName); + $collected[] = $this->prepareComponentAttributes($component, $newParent . \ucfirst($newKebab)); } else { // Output the component attributes if there is no nesting left, and append the parent prefixes. - $outputAttributes = $this->prepareComponentAttribute($component, $newComponentName, $realComponentName, $newParent); + $collected[] = $this->prepareComponentAttribute($component, $newComponentName, $realComponentName, $newParent); } - - // Populate the output recursively. - $output = \array_merge( - $output, - $outputAttributes - ); } - return \array_merge( - $output, - $this->prepareComponentAttribute($manifest, '', $name, $newParent, true) - ); + $collected[] = $this->prepareComponentAttribute($manifest, '', $name, $newParent, true); + + return \array_merge(...$collected); } } From 8b6a4022ca1f4d59f35466b1d7d9f2b856a46928 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Ruz=CC=8Cevic=CC=81?= Date: Thu, 14 May 2026 20:27:14 +0200 Subject: [PATCH 09/17] update --- src/Main/Autowiring.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Main/Autowiring.php b/src/Main/Autowiring.php index 274177e0..fd9721c3 100644 --- a/src/Main/Autowiring.php +++ b/src/Main/Autowiring.php @@ -130,6 +130,7 @@ private function buildDependencyTree(string $relevantClass, array $filenameIndex // Ignore dependencies for autowire and main class. $ignorePaths = \array_flip([ 'psr4Prefixes', + 'namespace', 'projectNamespace', ]); From 8c74af2b4c6a2e4a9864e1353b2b72c02fc58fc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Ruz=CC=8Cevic=CC=81?= Date: Thu, 14 May 2026 20:51:37 +0200 Subject: [PATCH 10/17] update --- composer.json | 4 +- src/Helpers/SelectorsTrait.php | 2 +- src/Main/Autowiring.php | 181 ++++++++++++++++++++------------- 3 files changed, 114 insertions(+), 73 deletions(-) diff --git a/composer.json b/composer.json index 4610414b..ee9b577a 100644 --- a/composer.json +++ b/composer.json @@ -32,8 +32,8 @@ "php-di/php-di": "^7.1.1" }, "require-dev": { - "dealerdirect/phpcodesniffer-composer-installer": "1.2.0", - "infinum/eightshift-coding-standards": "^3.0.1", + "dealerdirect/phpcodesniffer-composer-installer": "1.2.1", + "infinum/eightshift-coding-standards": "^3.1.0", "php-parallel-lint/php-parallel-lint": "^v1.4.0", "php-stubs/wordpress-stubs": "6.9.1", "szepeviktor/phpstan-wordpress": "2.0.3", diff --git a/src/Helpers/SelectorsTrait.php b/src/Helpers/SelectorsTrait.php index ffe88960..3901e7b4 100644 --- a/src/Helpers/SelectorsTrait.php +++ b/src/Helpers/SelectorsTrait.php @@ -116,7 +116,7 @@ public static function responsiveSelectors(array $items, string $selector, strin } // Use optimized classnames method. - return self::classnames($output); + return Helpers::clsx($output); } /** diff --git a/src/Main/Autowiring.php b/src/Main/Autowiring.php index fd9721c3..319ee314 100644 --- a/src/Main/Autowiring.php +++ b/src/Main/Autowiring.php @@ -27,6 +27,19 @@ */ class Autowiring { + /** + * Constructor parameter names that are passed through from Main itself and must + * be ignored when the autowire scanner encounters them as primitive dependencies. + * + * Classes whose constructors contain ONLY ignored primitives (e.g. Main) are + * intentionally not registered in the dependency tree. + */ + private const IGNORED_PRIMITIVE_PARAMS = [ + 'psr4Prefixes' => true, + 'namespace' => true, + 'projectNamespace' => true, + ]; + /** * Initialize the autowiring scanner with the composer PSR-4 map and the target project namespace. * @@ -34,8 +47,8 @@ class Autowiring * @param string $namespace Project namespace. */ public function __construct( - protected array $psr4Prefixes, - protected string $namespace, + protected readonly array $psr4Prefixes, + protected readonly string $namespace, ) { } @@ -60,6 +73,7 @@ public function buildServiceClasses(array $manuallyDefinedDependencies = [], boo ); $dependencyTree = []; + $reflectionCache = $projectReflectionClasses; // Prepare the filename index. $filenameIndex = $this->buildFilenameIndex($projectReflectionClasses); @@ -76,26 +90,30 @@ public function buildServiceClasses(array $manuallyDefinedDependencies = [], boo continue; } - // Build the dependency tree. - $dependencyTree = [ - ...$this->buildDependencyTree($projectClass, $filenameIndex, $classInterfaceIndex), - ...$dependencyTree, - ]; + // First-write-wins so the initial entry for a class survives later passes. + foreach ($this->buildDependencyTree($projectClass, $filenameIndex, $classInterfaceIndex, $reflectionCache) as $class => $deps) { + $dependencyTree[$class] ??= $deps; + } } - // Build dependency tree for dependencies. Things that need to be injected but were skipped because - // they were initially irrelevant. - foreach ($dependencyTree as &$dependencies) { - foreach ($dependencies as $depClass => $subDeps) { - // No need to build dependencies for this again if we already have them. + // Resolve transitive dependencies via a work list queue rather than mutating + // the array while iterating it by reference. + $queue = \array_keys($dependencyTree); + while ($queue !== []) { + $current = (string) \array_shift($queue); + foreach (\array_keys($dependencyTree[$current] ?? []) as $depClass) { if (isset($dependencyTree[$depClass])) { continue; } - $dependencyTree = [ - ...$this->buildDependencyTree((string)$depClass, $filenameIndex, $classInterfaceIndex), - ...$dependencyTree, - ]; + foreach ($this->buildDependencyTree((string)$depClass, $filenameIndex, $classInterfaceIndex, $reflectionCache) as $newClass => $newDeps) { + if (isset($dependencyTree[$newClass])) { + continue; + } + + $dependencyTree[$newClass] = $newDeps; + $queue[] = $newClass; + } } } @@ -113,79 +131,100 @@ public function buildServiceClasses(array $manuallyDefinedDependencies = [], boo * @param string $relevantClass Class we're building dependency tree for. * @param array $filenameIndex Filename index. Maps filenames to class names. * @param array $classInterfaceIndex Class interface index. Map classes to interface they implement. + * @param array> $reflectionCache Memoized ReflectionClass instances shared across the build. * - * @throws InvalidAutowireDependency If a primitive dependency is found. + * @throws InvalidAutowireDependency If a primitive dependency is found without a default value. * @throws ReflectionException If reflection exception happens. * @throws Exception General exception. * * @return array */ - private function buildDependencyTree(string $relevantClass, array $filenameIndex, array $classInterfaceIndex): array - { + private function buildDependencyTree( + string $relevantClass, + array $filenameIndex, + array $classInterfaceIndex, + array &$reflectionCache, + ): array { // Keeping PHPStan happy. if (!\class_exists($relevantClass, false)) { return []; } - // Ignore dependencies for autowire and main class. - $ignorePaths = \array_flip([ - 'psr4Prefixes', - 'namespace', - 'projectNamespace', - ]); + $reflClass = $reflectionCache[$relevantClass] ??= new ReflectionClass($relevantClass); + $constructor = $reflClass->getConstructor(); + + // No constructor or no parameters - register the class with no deps. + if ($constructor === null || $constructor->getParameters() === []) { + return [$relevantClass => []]; + } $dependencyTree = []; - $reflClass = new ReflectionClass($relevantClass); - // If this class has dependencies, we need to figure those out. Otherwise, - // we just add it to the dependency tree as a class without dependencies. - if (!empty($reflClass->getConstructor()) && !empty($reflClass->getConstructor()->getParameters())) { - // Go through each constructor parameter. - foreach ($reflClass->getConstructor()->getParameters() as $reflParam) { - $type = $reflParam->gettype(); - - // Skip parameters without type hints. - if ($type instanceof ReflectionNamedType) { - $className = $type->getName(); - $isBuiltin = $type->isBuiltin(); - } else { + $hasDefaultedPrimitive = false; + + // Go through each constructor parameter. + foreach ($constructor->getParameters() as $reflParam) { + $type = $reflParam->getType(); + + // Skip parameters without a single named type hint (preserves legacy behavior). + if (!$type instanceof ReflectionNamedType) { + continue; + } + + $paramName = $reflParam->getName(); + $isBuiltin = $type->isBuiltin(); + + if ($isBuiltin) { + // Allowlisted primitives (Main's psr4Prefixes/namespace) — skip silently + // without registering the owning class so Main isn't autowired. + if (isset(self::IGNORED_PRIMITIVE_PARAMS[$paramName])) { continue; } - // We're unable to autowire primitive dependency and there doesn't seem to yet be a way - // to check if this parameter has a default value or not (so we need to throw an exception regardless). - // See: https://www.php.net/manual/en/class.reflectionnamedtype.php. - if ($isBuiltin && !isset($ignorePaths[$reflParam->getName()])) { - throw InvalidAutowireDependency::throwPrimitiveDependencyFound($relevantClass, $reflParam->getName()); + // Primitive with a default value — PHP-DI will use the default at construction time. + if ($reflParam->isDefaultValueAvailable() || $reflParam->isOptional()) { + $hasDefaultedPrimitive = true; + continue; } - // Keeping PHPStan happy. - if (\class_exists($className, false) || \interface_exists($className, false)) { - $reflClassForParam = new ReflectionClass($className); - - // If the expected type is interface, try guessing based on var name. - // Otherwise, just inject that class. - if ($reflClassForParam->isInterface()) { - $matchedClass = $this->tryToFindMatchingClass( - $reflParam->getName(), - $className, - $filenameIndex, - $classInterfaceIndex - ); - - // If we're unable to find exactly 1 class for whatever reason, just skip it, the user - // will have to define the dependencies manually. - if (empty($matchedClass)) { - continue; - } - $dependencyTree[$relevantClass][$matchedClass] = []; - } else { - $dependencyTree[$relevantClass][$className] = []; - } + throw InvalidAutowireDependency::throwPrimitiveDependencyFound($relevantClass, $paramName); + } + + $className = $type->getName(); + + // Keeping PHPStan happy. + if (!\class_exists($className, false) && !\interface_exists($className, false)) { + continue; + } + + $reflClassForParam = $reflectionCache[$className] ??= new ReflectionClass($className); + + // If the expected type is interface, try guessing based on var name. + // Otherwise, just inject that class. + if ($reflClassForParam->isInterface()) { + $matchedClass = $this->tryToFindMatchingClass( + $paramName, + $className, + $filenameIndex, + $classInterfaceIndex + ); + + // If we're unable to find exactly 1 class for whatever reason, just skip it, the user + // will have to define the dependencies manually. + if ($matchedClass === '') { + continue; } + + $dependencyTree[$relevantClass][$matchedClass] = []; + } else { + $dependencyTree[$relevantClass][$className] = []; } - } else { + } + + // Class has resolvable defaults but no class-typed deps - still register so DI can construct it. + if (!isset($dependencyTree[$relevantClass]) && $hasDefaultedPrimitive) { $dependencyTree[$relevantClass] = []; } + return $dependencyTree; } // phpcs:enable @@ -208,13 +247,15 @@ private function getClassesInNamespace(string $namespaceName, array $psr4Prefixe return []; } - $it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($pathToNamespace)); + $it = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($pathToNamespace, RecursiveDirectoryIterator::SKIP_DOTS) + ); foreach ($it as $file) { if ($file->isDir()) { continue; } - if (\preg_match('/^[A-Z]{1}[A-Za-z0-9]+\.php/', $file->getFileName())) { + if (\preg_match('/^[A-Z][A-Za-z0-9]+\.php$/', $file->getFileName())) { $classes[] = $this->getNamespaceFromFilepath($file->getPathname(), $namespaceName, $pathToNamespace); } } @@ -335,7 +376,7 @@ private function buildClassInterfaceIndex(array $reflectionClasses): array $classInterfaceIndex = []; foreach ($reflectionClasses as $projectClass => $reflectionClass) { $classInterfaceIndex[$projectClass] = \array_map( - static fn () => true, + static fn() => true, $reflectionClass->getInterfaces() ); } From 25ab2cb66da76244fc83a17fa72889953e3e0781 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Ruz=CC=8Cevic=CC=81?= Date: Thu, 14 May 2026 20:57:12 +0200 Subject: [PATCH 11/17] update --- src/Helpers/AttributesTrait.php | 127 ++++++++++++-------------------- 1 file changed, 47 insertions(+), 80 deletions(-) diff --git a/src/Helpers/AttributesTrait.php b/src/Helpers/AttributesTrait.php index 5a078232..769b0103 100644 --- a/src/Helpers/AttributesTrait.php +++ b/src/Helpers/AttributesTrait.php @@ -17,6 +17,27 @@ */ trait AttributesTrait { + /** + * Attribute keys that are always copied through `props()` regardless of prefix matching. + * + * @var array + */ + private const PROPS_PASSTHROUGH_KEYS = [ + 'blockName' => true, + 'blockClientId' => true, + 'blockTopLevelId' => true, + 'blockFullName' => true, + 'blockClass' => true, + 'blockJsClass' => true, + 'blockStyles' => true, + 'blockSsr' => true, + 'componentJsClass' => true, + 'selectorClass' => true, + 'additionalClass' => true, + 'uniqueWrapperId' => true, + 'parentClass' => true, + ]; + /** * Check if attribute exist in attributes list and add default value if not. * This is used because Block editor will not output attributes that don't have a default value. @@ -30,26 +51,19 @@ trait AttributesTrait * * @return mixed */ - public static function checkAttr(string $key, array $attributes, array $manifest, bool $undefinedAllowed = false) + public static function checkAttr(string $key, array $attributes, array $manifest, bool $undefinedAllowed = false): mixed { - // Fast path: Check if the original key exists first (most common case). if (isset($attributes[$key])) { return $attributes[$key]; } - // Cache manifest attributes to avoid repeated array access. $manifestAttrs = $manifest['attributes'] ?? null; if ($manifestAttrs === null) { - // Handle missing attributes array case. - $contextName = $manifest['blockName'] ?? $manifest['componentName'] ?? 'unknown'; - $contextType = isset($manifest['blockName']) ? 'block' : 'component'; + [$contextName, $contextType] = self::manifestContext($manifest); throw new Exception("{$key} key does not exist - missing attributes in {$contextName} {$contextType} manifest."); } - // Only compute the transformed key if the original key wasn't found. - $newKey = $key; // Default to original key. - - // Only call getAttrKey if we're in a component context and need prefix transformation. + $newKey = $key; if ( !isset($manifest['blockName']) && !\str_contains($key, 'wrapper') && @@ -62,33 +76,28 @@ public static function checkAttr(string $key, array $attributes, array $manifest ); } - // Check transformed key if different from original. if ($newKey !== $key && isset($attributes[$newKey])) { return $attributes[$newKey]; } - // Cache manifest key to avoid repeated access. $manifestKey = $manifestAttrs[$key] ?? null; if ($manifestKey === null) { - $contextName = $manifest['blockName'] ?? $manifest['componentName'] ?? 'unknown'; - $contextType = isset($manifest['blockName']) ? 'block' : 'component'; + [$contextName, $contextType] = self::manifestContext($manifest); $tipOutput = isset($manifest['components']) ? ' If you are using additional components, check if you used the correct block/component prefix in your attribute name.' : ''; throw new Exception("{$key} key does not exist in the {$contextName} {$contextType} manifest. Please check your implementation.{$tipOutput}"); } - // Early return for undefined allowed case. + // Block-attribute semantics: falsy defaults are treated as undefined when allowed. if ($undefinedAllowed && empty($manifestKey['default'])) { return null; } - // Optimized default value assignment - avoid switch statement overhead. $default = $manifestKey['default'] ?? null; if ($default !== null) { return $default; } - // Fallback defaults based on type (only when no default is specified). $type = $manifestKey['type'] ?? 'string'; return match ($type) { 'boolean' => false, @@ -112,24 +121,18 @@ public static function checkAttr(string $key, array $attributes, array $manifest */ public static function checkAttrResponsive(string $keyName, array $attributes, array $manifest, bool $undefinedAllowed = false): array { - // Cache responsive attributes to avoid repeated array access. $responsiveAttrs = $manifest['responsiveAttributes'] ?? null; if ($responsiveAttrs === null) { - $contextName = $manifest['blockName'] ?? $manifest['componentName'] ?? 'unknown'; - $contextType = isset($manifest['blockName']) ? 'block' : 'component'; + [$contextName, $contextType] = self::manifestContext($manifest); throw new Exception("It looks like you are missing responsiveAttributes key in your {$contextName} {$contextType} manifest."); } - // Cache the specific keyName array to avoid repeated lookups. $keyConfig = $responsiveAttrs[$keyName] ?? null; if ($keyConfig === null) { throw new Exception("It looks like you are missing the {$keyName} key in your manifest responsiveAttributes array."); } - // Pre-allocate output array with known size for better memory performance. $output = []; - - // Batch process all responsive attributes. foreach ($keyConfig as $key => $value) { $output[$key] = self::checkAttr($value, $attributes, $manifest, $undefinedAllowed); } @@ -148,31 +151,24 @@ public static function checkAttrResponsive(string $keyName, array $attributes, a */ public static function getAttrKey(string $key, array $attributes, array $manifest): string { - // Fast path: Most common cases first. - - // Skip if using this helper in block (most common case). if (isset($manifest['blockName'])) { return $key; } - // Skip if attribute is wrapper (use modern PHP function). if (\str_contains($key, 'wrapper')) { return $key; } - // Cache prefix to avoid repeated array access. $prefix = $attributes['prefix'] ?? ''; if ($prefix === '') { return $key; } - // Cache component name to avoid repeated array access. $componentName = $manifest['componentName'] ?? ''; if ($componentName === '') { return $key; } - // Only compute kebab-to-camel conversion if we actually need it. return \str_replace(Helpers::kebabToCamelCase($componentName), $prefix, $key); } @@ -187,34 +183,10 @@ public static function getAttrKey(string $key, array $attributes, array $manifes */ public static function props(string $newName, array $attributes, array $manual = []): array { - // Cache flipped includes array for O(1) lookup instead of O(n) in_array. - static $includesFlipped = null; - if ($includesFlipped === null) { - $includes = [ - 'blockName', - 'blockClientId', - 'blockTopLevelId', - 'blockFullName', - 'blockClass', - 'blockJsClass', - 'blockStyles', - 'blockSsr', - 'componentJsClass', - 'selectorClass', - 'additionalClass', - 'uniqueWrapperId', - 'parentClass' - ]; - $includesFlipped = \array_flip($includes); - } - $output = []; - // Cache frequently accessed values. $blockName = $attributes['blockName'] ?? ''; $attributesPrefix = $attributes['prefix'] ?? null; - - // Compute prefix once and cache kebab-to-camel conversions. $newNameCamel = Helpers::kebabToCamelCase($newName); if ($attributesPrefix === null) { @@ -223,33 +195,24 @@ public static function props(string $newName, array $attributes, array $manual = $prefix = $attributesPrefix; } - // Set component prefix. $output['prefix'] = empty($prefix) ? $newNameCamel : $prefix . \ucfirst($newNameCamel); - - // Cache prefix length for substr comparison optimization. $prefixLength = \strlen($output['prefix']); - // Process main attributes in a single optimized loop. foreach ($attributes as $key => $value) { - // Fast lookup for includes using array key existence (O(1) vs O(n)). - if (isset($includesFlipped[$key])) { + if (isset(self::PROPS_PASSTHROUGH_KEYS[$key])) { $output[$key] = $value; } elseif ($prefixLength > 0 && \str_starts_with($key, $output['prefix'])) { - // Use modern PHP str_starts_with for better performance. $output[$key] = $value; } } - // Process manual attributes if present. if ($manual) { - // Cache the component name pattern for string replacement. $componentPattern = \lcfirst($newNameCamel); foreach ($manual as $key => $value) { - if (isset($includesFlipped[$key])) { + if (isset(self::PROPS_PASSTHROUGH_KEYS[$key])) { $output[$key] = $value; } else { - // Optimize string replacement - only do it once. $newKey = \str_replace($componentPattern, '', $key); $transformedKey = $output['prefix'] . \ucfirst($newKey); $output[$transformedKey] = $value; @@ -270,23 +233,17 @@ public static function props(string $newName, array $attributes, array $manual = */ public static function getDefaultRenderAttributes(array $manifest, array $attributes): array { - // Cache manifest attributes to avoid repeated array access. $attrs = $manifest['attributes'] ?? null; - - // Early return for empty or invalid attributes. - if ($attrs === null || !\is_iterable($attrs) || empty($attrs)) { + if (!\is_array($attrs) || $attrs === []) { return $attributes; } - // Pre-allocate with estimated size for better memory performance. $defaultAttributes = []; - // Determine if we need key transformation (only for components with prefix). $needsKeyTransformation = !isset($manifest['blockName']) && !empty($attributes['prefix']) && !empty($manifest['componentName']); - // Cache values for key transformation if needed. $componentNameCamel = null; $prefix = null; if ($needsKeyTransformation) { @@ -294,27 +251,22 @@ public static function getDefaultRenderAttributes(array $manifest, array $attrib $prefix = $attributes['prefix']; } - // Process attributes in a single optimized loop. foreach ($attrs as $itemKey => $itemValue) { - // Skip if no default value is set. if (!isset($itemValue['default'])) { continue; } - // Optimize key transformation. if ($needsKeyTransformation && !\str_contains($itemKey, 'wrapper')) { - // Apply transformation directly without function call. $newKey = \str_replace($componentNameCamel, $prefix, $itemKey); } else { - // Use original key (block context or no transformation needed). $newKey = $itemKey; } $defaultAttributes[$newKey] = $itemValue['default']; } - // Merge defaults with provided attributes (provided attributes take precedence). - return \array_merge($defaultAttributes, $attributes); + // `+` keeps left-side values on key collision, so provided attrs win over defaults without array_merge's reindex cost. + return $attributes + $defaultAttributes; } /** @@ -346,4 +298,19 @@ public static function getAttrsOutput(array $attrs, bool $escape = true): string return \implode('', $parts); } + + /** + * Resolve a human-readable name/type pair for the given manifest, used in error messages. + * + * @param array $manifest Block/component manifest data. + * + * @return array{0: string, 1: string} + */ + private static function manifestContext(array $manifest): array + { + return [ + $manifest['blockName'] ?? $manifest['componentName'] ?? 'unknown', + isset($manifest['blockName']) ? 'block' : 'component', + ]; + } } From 78c59d554fe91eb9461a543af03d0b2019e9c76d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Ruz=CC=8Cevic=CC=81?= Date: Thu, 14 May 2026 21:07:13 +0200 Subject: [PATCH 12/17] update --- OPTIMIZATIONS.md | 241 ---------------------------------- src/Helpers/GeneralTrait.php | 154 ++++++++-------------- src/Helpers/TailwindTrait.php | 65 ++++----- 3 files changed, 85 insertions(+), 375 deletions(-) delete mode 100644 OPTIMIZATIONS.md diff --git a/OPTIMIZATIONS.md b/OPTIMIZATIONS.md deleted file mode 100644 index 50f79cd1..00000000 --- a/OPTIMIZATIONS.md +++ /dev/null @@ -1,241 +0,0 @@ -# Eightshift-Libs — Optimization Plan - -A consolidated list of proposed changes from the in-depth review, grouped by category, with file references, severity, effort, and confidence. Followed by a phased implementation plan ordered by ROI and dependency. - -> Codebase snapshot: 134 PHP files, ~35k LOC, PHP 8.4+, PHP-DI 7, PHPUnit 12. Line coverage ~15% (Exception ~89%, Helpers ~38%, everything else 0%). - ---- - -## Legend - -- **Severity / Impact**: `high` | `med` | `low` -- **Effort**: `S` (≤1h) | `M` (1–4h) | `L` (4h+) -- **Confidence**: % that the change is correct and worth doing as described -- **Status**: `[ ]` open / `[x]` done - ---- - -## 1. Performance — hot paths - -### 1.1 CSS variable generation (highest-value) - -- [ ] **Triple-nested lookup → indexed map** — `src/Helpers/CssVariablesTrait.php:680` - Inside `foreach $variables × foreach $variableValue`, the inner `foreach $data as $index => $item` is a linear search by `name`+`type`. Build a `["{$name}---{$type}" => $index]` map once. - _Impact: high · Effort: M · Confidence: 90_ - -- [ ] **`$output .= …` → `$parts[]` + `implode`** — `CssVariablesTrait.php:31-38, 486-513` - Repeated string concatenation in long global-variables loops; O(n²) memory copies. - _Impact: high · Effort: S · Confidence: 85_ - -- [ ] **3× `str_replace()` → single `strtr($s, $map)`** — `CssVariablesTrait.php:815-829` - Per attribute/variable pair, three sequential `str_replace` calls. One `strtr` is a single pass. - _Impact: med · Effort: S · Confidence: 85_ - -- [ ] **`gettype()` checks → `is_int()/is_float()`** — `CssVariablesTrait.php:686-691` - Five `gettype($x) === 'integer'` checks per item in the inner loop. `is_*()` are faster and clearer. - _Impact: low · Effort: S · Confidence: 95_ - -- [ ] **Memoize breakpoint setup** — `CssVariablesTrait.php:98` (`prepareVariableData`), `~:84` (`getSettingsGlobalVariablesBreakpoints`) - Both are stable per request but rebuilt every render. Add `static $cache = [];` keyed by manifest hash / breakpoint signature. - _Impact: med · Effort: S · Confidence: 75_ - -- [ ] **`esc_attr()` on configured CSS selector ID** — `CssVariablesTrait.php:25-48, 62` - Config-driven, but escape the value placed in `id='…'` and similar attributes anyway. - _Impact: low (security/defensive) · Effort: S · Confidence: 75_ - -### 1.2 HTML attribute building - -- [ ] **`$htmlAttrs .= …` → array + `implode`** — `src/Helpers/AttributesTrait.php:339, 343` - _Impact: med · Effort: S · Confidence: 80_ - -- [ ] **Loose `==` → strict `===`** — `AttributesTrait.php:338` - `if ($value == 0 || !empty($value))` is type-coercive; tighten. - _Impact: low (correctness) · Effort: S · Confidence: 90_ - -### 1.3 Tailwind debug path - -- [ ] **Move regex out of render path** — `src/Helpers/TailwindTrait.php:496` - `preg_replace('/[^a-zA-Z]+/', '-', $manifest['title'])` runs on every block render when `WP_DEBUG` is on. Precompute the slug at manifest cache build, store on the manifest. - _Impact: low (only debug) · Effort: S · Confidence: 80_ - -### 1.4 Cache stampede protection - -- [ ] **`flock`-based advisory lock around rebuild** — `src/Helpers/CacheTrait.php` rebuild path (~line 141) - When the timestamp option changes the transient is deleted; any concurrent request triggers a full rebuild. Wrap the rebuild orchestration with `flock(LOCK_EX)` and re-check validity after acquiring. - _Impact: med (only under deploy/concurrent load) · Effort: M · Confidence: 70_ - -### 1.5 Dev-mode autowiring cache - -- [ ] **Short-TTL dev cache for service scanning** — `src/Main/Autowiring.php:209` and `src/Main/AbstractMain.php:262-283` - Production caches the service list; development scans the namespace dir on every page load. Add a filemtime-based dev cache (e.g., key on max mtime of `src/` files), or 30s TTL. - _Impact: med (DX) · Effort: M · Confidence: 80_ - -- [ ] **Document compiled-container reuse** — `src/Main/AbstractMain.php:216` - Verify `buildDiContainer()` is invoked once and the result reused per request in consumer projects; add an example to the README/wiki. - _Impact: low (docs) · Effort: S · Confidence: 60_ - ---- - -## 2. Code quality / modernization (PHP 8.4) - -- [ ] **Constructor property promotion** — `src/Main/AbstractMain.php:52-56`, `src/Main/Autowiring.php:35-42` - _Effort: S · Confidence: 90_ - -- [ ] **`gettype()` → `is_*()` sweep** — `AbstractMain.php:203`, `Exception/ComponentException.php:31-36`, plus the CssVariablesTrait hits above - _Effort: S · Confidence: 95_ - -- [ ] **`array_merge(…)` → spread `[...$a, ...$b]`** — `Autowiring.php:82-85` and several traits - _Effort: S · Confidence: 80_ - -- [ ] **Empty closures → arrow functions** — `Autowiring.php:335-339` (`function () { return true; }` → `fn() => true`) - _Effort: S · Confidence: 95_ - -- [ ] **First-class callable syntax in hook callbacks** — `*Example.php` and abstracts, e.g. `[$this, 'method']` → `$this->method(...)` - Better PHPStan inference, no behavioural change. - _Effort: M (broad sweep) · Confidence: 85_ - -- [ ] **`readonly` properties** — `AbstractMain::$container` (set once) - _Effort: S · Confidence: 80_ - -- [ ] **`JSON_THROW_ON_ERROR`** — `src/Helpers/GeneralTrait.php:254`, `CacheTrait` decode sites - Replace `json_last_error()` plumbing with `try { json_decode(…, flags: JSON_THROW_ON_ERROR) } catch (JsonException)`. - _Effort: S · Confidence: 85_ - -- [ ] **Property hooks / asymmetric visibility (8.4)** — opportunistic; scan for trivial getters wrapping a private field - _Effort: M · Confidence: 50_ - ---- - -## 3. Security - -No high/critical findings. All defensive. - -- [ ] **`esc_attr()` on CSS selector outputs** — `CssVariablesTrait.php:25-48, 62` (see 1.1) - _Severity: low · Effort: S · Confidence: 75_ - -- [ ] **Justify or nonce `$_GET['context']`** — `CssVariablesTrait.php:144, 167` - Sanitized but unnonced; only flips render context. Either add a code comment justifying it, or restrict to admin contexts. - _Severity: low · Effort: S · Confidence: 70_ - -- [ ] **Validate manual IP override** — `src/Geolocation/AbstractGeolocation.php:308-313` - `getIpAddress()` allows an override that bypasses `FILTER_VALIDATE_IP`. Apply the same validation to the override path. - _Severity: low · Effort: S · Confidence: 80_ - -- [ ] **`realpath()` containment for CLI template reads** — `src/Cli/AbstractCli.php:425-426` - Defensive only; CLI-only attack surface. - _Severity: low · Effort: S · Confidence: 70_ - ---- - -## 4. Architecture - -- [ ] **New functionality behind injectable interfaces** — `src/Helpers/Helpers.php` static facade is convenient but blocks DI testing. Don't break BC; add interfaces alongside the static delegate going forward. - _Effort: L (incremental, per-feature) · Confidence: 70_ - -- [ ] **Optional service priority** — `src/Services/ServiceInterface.php` - Add `getPriority(): int { return 10; }` default; consumers can override to control hook ordering declaratively. - _Effort: M · Confidence: 55_ - ---- - -## 5. Tests — biggest gap - -Existing tests are good (Brain/Monkey + Mockery + data providers); pattern is established in `tests/Unit/Helpers/`. The task is breadth. - -Highest-ROI targets (in order): - -- [ ] **`src/Main/Autowiring.php`** — reflection-heavy, silent breakage if wrong -- [ ] **`src/Cache/ManifestCache.php` + `CacheTrait`** — perf-critical, fail-silent prone -- [ ] **`src/Helpers/CssVariablesTrait.php`** — largest untested trait; must precede the refactor in §1.1 -- [ ] **`src/Blocks/AbstractBlocks.php`** — manifest discovery + rendering -- [ ] **`src/Rest/Routes/AbstractRoute.php`** — public surface, permission checks - -_Confidence: 95 — refactoring §1.1 without §5.3 first is risky._ - ---- - -## Implementation plan - -Five PRs, sequenced to minimize risk and keep diffs reviewable. - -### Phase 1 — Safety net (tests first) - -**Goal: lock down behaviour before changing hot paths.** - -1. Add unit tests for `CssVariablesTrait` covering global output, block-level output, breakpoint inheritance, and the `name`+`type` lookup currently at `:680`. Pattern: copy `tests/Unit/Helpers/RenderTraitTest`. -2. Add unit tests for `CacheTrait`: read/write, transient invalidation, missing file fallback. -3. Add unit tests for `Autowiring`: simple dependency tree, interface resolution, circular detection. - -**Exit criteria:** coverage for these three traits ≥ 70%. `composer test` green. - -### Phase 2 — Hot-path performance - -**Goal: measurable render-time win on CSS variable generation.** - -4. Apply §1.1 fixes in this order, one commit each: - - Indexed `name`+`type` lookup map - - `$parts[]` + `implode` for global output - - `strtr` for variable substitution - - `is_int/is_float` swap - - Memoize breakpoint setup -5. Apply §1.2 (AttributesTrait) `implode` + strict comparison. -6. Apply §1.3 (Tailwind debug slug precomputation). - -**Exit criteria:** all phase-1 tests still green. Optional: micro-benchmark a representative page (10–20 blocks) before/after. - -### Phase 3 — Dev-mode + cache safety - -**Goal: better DX locally, no stampede in production.** - -7. Add filemtime-based dev cache to `Autowiring` (§1.5). -8. Add `flock` advisory lock around `CacheTrait` rebuild (§1.4). -9. Add `esc_attr()` to CSS selector outputs (§1.1 / §3). - -**Exit criteria:** dev page-load no longer rescans `src/`. Concurrent rebuild test (two simultaneous warm-ups) produces a single rebuild. - -### Phase 4 — PHP 8.4 modernization sweep - -**Goal: one mechanical PR, no behaviour changes.** - -10. Apply §2 in a single commit each: - - Constructor property promotion - - `gettype` → `is_*` - - `array_merge` → spread - - Empty closures → arrow fns - - `readonly` on `AbstractMain::$container` - - `JSON_THROW_ON_ERROR` migration - - First-class callable syntax in hook callbacks (separate PR if diff is large) - -**Exit criteria:** PHPStan level 6 still clean. PHPCS clean. All tests green. - -### Phase 5 — Defensive cleanups - -**Goal: low-priority hardening.** - -11. Validate manual IP override in `Geolocation` (§3). -12. `realpath()` containment in CLI template reads (§3). -13. Comment / nonce decision for `$_GET['context']` (§3). - -**Exit criteria:** no PHPCS security warnings introduced. - -### Optional follow-up — Architectural - -14. Service priority API (§4). -15. Begin injectable interface pattern alongside `Helpers` static facade (§4) — apply only to new functionality. - ---- - -## Risks & rollback - -- **CssVariables refactor (Phase 2)** is the riskiest change; Phase 1 is the mitigation. If a regression escapes, each Phase 2 step is in its own commit and can be reverted independently. -- **Autowiring dev cache (Phase 3)** could mask added classes during dev. Use filemtime keying — not TTL alone — to avoid surprises. -- **PHP 8.4 sweep (Phase 4)** is mechanical and reviewable; low risk if PHPStan + tests pass. - ---- - -## Out of scope (intentionally) - -- Replacing the static `Helpers` facade wholesale — BC break for every consumer. -- Switching to a different DI container — PHP-DI 7 is fine. -- WP-CLI scaffold output changes — separate concern. -- Public API renames. diff --git a/src/Helpers/GeneralTrait.php b/src/Helpers/GeneralTrait.php index fb782b96..8b5d7d4e 100644 --- a/src/Helpers/GeneralTrait.php +++ b/src/Helpers/GeneralTrait.php @@ -22,8 +22,7 @@ trait GeneralTrait { /** - * Check if XML is valid file used for svg. - * Optimized with early validation and error handling. + * Check if XML is a valid document (used for SVG validation). * * @param string $xml Full xml document. * @@ -31,37 +30,25 @@ trait GeneralTrait */ public static function isValidXml(string $xml): bool { - // Early return for empty or very short strings. - if (\strlen($xml) < 5) { + if (\strlen($xml) < 5 || !\str_contains($xml, '<') || !\str_contains($xml, '>')) { return false; } - // Quick check for basic XML structure. - if (!\str_contains($xml, '<') || !\str_contains($xml, '>')) { - return false; - } - - // Suppress errors during validation. $originalErrorState = \libxml_use_internal_errors(true); try { $doc = new DOMDocument('1.0', 'utf-8'); - $doc->strictErrorChecking = false; - $doc->recover = true; - - $result = $doc->loadXML($xml); - $errors = \libxml_get_errors(); - - return $result && empty($errors); + return $doc->loadXML($xml) && \libxml_get_errors() === []; } finally { - // Restore original settings. \libxml_use_internal_errors($originalErrorState); \libxml_clear_errors(); } } /** - * Flatten multidimensional array with optimized performance. + * Flatten a multidimensional array into a single-level list. + * + * Null values are skipped; all other scalars (including 0, false, '') are preserved. * * @param array $arrayToFlatten Multidimensional array to flatten. * @@ -73,9 +60,9 @@ public static function flattenArray(array $arrayToFlatten): array \array_walk_recursive( $arrayToFlatten, - function ($a) use (&$output) { - if (!empty($a)) { - $output[] = $a; + function ($value) use (&$output): void { + if ($value !== null) { + $output[] = $value; } } ); @@ -84,30 +71,32 @@ function ($a) use (&$output) { } /** - * Find array value by key in recursive array with optimized search. + * Find array value by key in a recursive array. * - * @param array $array Array to find. + * @param array $array Array to search. * @param string $needle Key name to find. * - * @return array + * @return array */ public static function recursiveArrayFind(array $array, string $needle): array { - $iterator = new RecursiveArrayIterator($array); + $iterator = new RecursiveArrayIterator($array); $recursive = new RecursiveIteratorIterator($iterator, RecursiveIteratorIterator::SELF_FIRST); - $aHitList = []; + $hits = []; foreach ($recursive as $key => $value) { if ($key === $needle) { - \array_push($aHitList, $value); + $hits[] = $value; } } - return $aHitList; + return $hits; } /** - * Sanitize all values in an array with optimized recursion. + * Sanitize all values in an array recursively. + * + * Resolves the sanitization function once and reuses it across recursive calls. * * @link https://developer.wordpress.org/themes/theme-security/data-sanitization-escaping/ * @@ -118,32 +107,29 @@ public static function recursiveArrayFind(array $array, string $needle): array */ public static function sanitizeArray(array $arrayToSanitize, string $sanitizationFunction): array { - // Early return for empty array. - if (empty($arrayToSanitize)) { + if ($arrayToSanitize === []) { return []; } - // Validate function exists. if (!\function_exists($sanitizationFunction)) { return $arrayToSanitize; } - $sanitized = []; + $callable = $sanitizationFunction(...); - foreach ($arrayToSanitize as $key => $value) { - if (\is_array($value)) { - $sanitized[$key] = self::sanitizeArray($value, $sanitizationFunction); - } else { - $sanitized[$key] = $sanitizationFunction($value); + $walk = static function (array $items) use (&$walk, $callable): array { + $result = []; + foreach ($items as $key => $value) { + $result[$key] = \is_array($value) ? $walk($value) : $callable($value); } - } + return $result; + }; - return $sanitized; + return $walk($arrayToSanitize); } /** - * Sort array by order key. Used to sort terms. - * Already optimized but added safety checks. + * Sort array by `order` key (used for term ordering). * * @param list> $items Items array to sort. Must have order key. * @@ -151,25 +137,22 @@ public static function sanitizeArray(array $arrayToSanitize, string $sanitizatio */ public static function sortArrayByOrderKey(array $items): array { - // Early return for arrays with less than 2 items. if (\count($items) < 2) { return $items; } \usort( $items, - function ($item1, $item2) { - $order1 = $item1['order'] ?? 0; - $order2 = $item2['order'] ?? 0; - return $order1 <=> $order2; - } + fn($a, $b) => ($a['order'] ?? 0) <=> ($b['order'] ?? 0) ); return $items; } /** - * Convert string from camel to kebab case. + * Convert string from camel case to kebab case. + * + * Handles acronyms (`APIKey` → `api-key`) and existing separators (`foo_bar` → `foo-bar`). * * @param string $input String to convert. * @@ -177,14 +160,18 @@ function ($item1, $item2) { */ public static function camelToKebabCase(string $input): string { - // Early return for empty string. if ($input === '') { return ''; } - // Optimized conversion using modern PHP functions. - $output = \ltrim(\mb_strtolower((string)\preg_replace('/[A-Z]([A-Z](?![a-z]))*/', '-$0', $input)), '-'); - return \str_replace(['_', ' ', '--'], ['-', '-', '-'], $output); + $normalized = \str_replace(['_', ' '], '-', $input); + $output = (string) \preg_replace( + ['/([a-z\d])([A-Z])/', '/([A-Z]+)([A-Z][a-z])/'], + '$1-$2', + $normalized + ); + + return \mb_strtolower(\trim($output, '-')); } /** @@ -235,19 +222,16 @@ public static function kebabToSnakeCase(string $input): string } /** - * Helper method to check the validity of JSON string with optimized error handling. - * - * @link https://stackoverflow.com/a/15198925/629127 + * Parse and validate a JSON manifest string. * * @param string $manifest JSON string to validate. * - * @throws InvalidManifest Error in the case json file has errors. + * @throws InvalidManifest When the manifest is empty or contains invalid JSON. * * @return array Parsed JSON string into an array. */ public static function parseManifest(string $manifest): array { - // Early return for empty manifest. if ($manifest === '') { throw InvalidManifest::manifestStructureException(\esc_html__('Empty manifest provided.', 'eightshift-libs')); } @@ -255,56 +239,39 @@ public static function parseManifest(string $manifest): array try { $result = \json_decode($manifest, true, 512, \JSON_THROW_ON_ERROR); } catch (JsonException $e) { - $errorMessages = [ - \JSON_ERROR_DEPTH => \esc_html__('The maximum stack depth has been exceeded.', 'eightshift-libs'), - \JSON_ERROR_STATE_MISMATCH => \esc_html__('Invalid or malformed JSON.', 'eightshift-libs'), - \JSON_ERROR_CTRL_CHAR => \esc_html__('Control character error, possibly incorrectly encoded.', 'eightshift-libs'), - \JSON_ERROR_SYNTAX => \esc_html__('Syntax error, malformed JSON.', 'eightshift-libs'), - \JSON_ERROR_UTF8 => \esc_html__('Malformed UTF-8 characters, possibly incorrectly encoded.', 'eightshift-libs'), - \JSON_ERROR_RECURSION => \esc_html__('One or more recursive references in the value to be encoded.', 'eightshift-libs'), - \JSON_ERROR_INF_OR_NAN => \esc_html__('One or more NAN or INF values in the value to be encoded.', 'eightshift-libs'), - \JSON_ERROR_UNSUPPORTED_TYPE => \esc_html__('A value of a type that cannot be encoded was given.', 'eightshift-libs'), - ]; - - $error = $errorMessages[$e->getCode()] ?? \esc_html__('Unknown JSON error occurred.', 'eightshift-libs'); - - throw InvalidManifest::manifestStructureException($error); + throw InvalidManifest::manifestStructureException(\esc_html($e->getMessage())); } return \is_array($result) ? $result : []; } /** - * Get current URL with params using optimized string building. + * Get the current request URL (including query string). + * + * Result is cached for the lifetime of the request since the URL cannot change mid-request. * * @return string */ public static function getCurrentUrl(): string { - // Cache server variables to avoid repeated sanitization. - static $cachedUrl = null; - static $lastRequestTime = null; - $currentTime = isset($_SERVER['REQUEST_TIME']) ? \sanitize_text_field(\wp_unslash($_SERVER['REQUEST_TIME'])) : \time(); - - // Return cached URL if it's from the same request. - if ($cachedUrl !== null && $lastRequestTime === $currentTime) { - return $cachedUrl; + static $cached = null; + + if ($cached !== null) { + return $cached; } - $isHttps = isset($_SERVER['HTTPS']) && \sanitize_text_field(\wp_unslash($_SERVER['HTTPS'])); + $https = isset($_SERVER['HTTPS']) ? \sanitize_text_field(\wp_unslash($_SERVER['HTTPS'])) : ''; $host = isset($_SERVER['HTTP_HOST']) ? \sanitize_text_field(\wp_unslash($_SERVER['HTTP_HOST'])) : ''; $request = isset($_SERVER['REQUEST_URI']) ? \sanitize_text_field(\wp_unslash($_SERVER['REQUEST_URI'])) : ''; - // Optimized URL building. - $protocol = $isHttps ? 'https' : 'http'; - $cachedUrl = "{$protocol}://{$host}{$request}"; - $lastRequestTime = $currentTime; + $protocol = ($https !== '' && $https !== 'off') ? 'https' : 'http'; + $cached = "{$protocol}://{$host}{$request}"; - return $cachedUrl; + return $cached; } /** - * Clean url from query params using optimized string operations. + * Strip query string and fragment from a URL. * * @param string $url URL to clean. * @@ -312,17 +279,12 @@ public static function getCurrentUrl(): string */ public static function cleanUrlParams(string $url): string { - // Early return for empty URL. if ($url === '') { return ''; } - // Fast path using strpos instead of preg_replace for simple cases. - $queryPos = \strpos($url, '?'); - if ($queryPos === false) { - return $url; - } + $cutoff = \strcspn($url, '?#'); - return \substr($url, 0, $queryPos); + return $cutoff === \strlen($url) ? $url : \substr($url, 0, $cutoff); } } diff --git a/src/Helpers/TailwindTrait.php b/src/Helpers/TailwindTrait.php index 61c963cc..05a7bfd1 100644 --- a/src/Helpers/TailwindTrait.php +++ b/src/Helpers/TailwindTrait.php @@ -18,6 +18,13 @@ */ trait TailwindTrait { + /** + * Per-title slug cache for the debug prefix emitted by `tailwindClasses` under `WP_DEBUG`. + * + * @var array + */ + private static array $tailwindDebugSlugCache = []; + /** * Get Tailwind breakpoints. * @@ -25,7 +32,7 @@ trait TailwindTrait * * @return array */ - public static function getTwBreakpoints($desktopFirst = false) + public static function getTwBreakpoints(bool $desktopFirst = false): array { static $cache = []; @@ -316,7 +323,7 @@ public static function getTwClasses($attributes, $manifest, ...$custom) * * @return string The unified string of CSS classes. */ - private static function unifyClasses($input): string + private static function unifyClasses(string|array $input): string { if (\is_array($input)) { return Helpers::clsx($input); @@ -337,15 +344,14 @@ private static function unifyClasses($input): string * * @return string The processed option value. */ - private static function processOption($partName, $optionValue, $defs): string + private static function processOption(string $partName, string|array $optionValue, array $defs): string { $optionClasses = []; $isResponsive = $defs['responsive'] ?? false; - $itemPartName = isset($defs['part']) ? $defs['part'] : 'base'; + $itemPartName = $defs['part'] ?? 'base'; $isSingleValue = isset($defs['twClasses']) || isset($defs['twClassesEditor']); - // Part checks. if (!$isSingleValue && !isset($defs[$partName])) { return ''; } @@ -354,24 +360,14 @@ private static function processOption($partName, $optionValue, $defs): string return ''; } - // Non-responsive options. if (!$isResponsive) { $rawValue = $defs['twClasses'][$optionValue] ?? $defs[$partName]['twClasses'][$optionValue] ?? ''; return self::unifyClasses($rawValue); } - // Responsive options. - $breakpoints = \array_keys($optionValue); - - if (\in_array('_desktopFirst', $breakpoints, true)) { - $breakpoints = \array_filter($breakpoints, fn($breakpoint) => $breakpoint !== '_desktopFirst'); - } - - foreach ($breakpoints as $breakpoint) { - $breakpointValue = $optionValue[$breakpoint]; - - if (!$breakpointValue) { + foreach ($optionValue as $breakpoint => $breakpointValue) { + if ($breakpoint === '_desktopFirst' || !$breakpointValue) { continue; } @@ -380,14 +376,15 @@ private static function processOption($partName, $optionValue, $defs): string if ($breakpoint === '_default') { $optionClasses[] = $rawClasses; - continue; } - $splitClasses = \explode(' ', $rawClasses); - $splitClasses = \array_map(fn($cn) => empty($cn) ? null : "{$breakpoint}:{$cn}", $splitClasses); - - $optionClasses = [...$optionClasses, ...$splitClasses]; + foreach (\explode(' ', $rawClasses) as $cn) { + if ($cn === '') { + continue; + } + $optionClasses[] = "{$breakpoint}:{$cn}"; + } } return self::unifyClasses($optionClasses); @@ -408,7 +405,7 @@ private static function processOption($partName, $optionValue, $defs): string * * @return string The processed combination value. */ - private static function processCombination($partName, $combo, $attributes, $manifest): string + private static function processCombination(string $partName, array $combo, array $attributes, array $manifest): string { $matches = true; @@ -430,7 +427,7 @@ private static function processCombination($partName, $combo, $attributes, $mani return ''; } - $itemPartName = isset($combo['part']) ? $combo['part'] : 'base'; + $itemPartName = $combo['part'] ?? 'base'; $isSingleValue = isset($combo['twClasses']) || isset($combo['twClassesEditor']); if ($isSingleValue && !\str_contains($itemPartName, $partName)) { @@ -458,29 +455,24 @@ private static function processCombination($partName, $combo, $attributes, $mani * * @return string */ - public static function tailwindClasses($part, $attributes, $manifest, ...$custom): string + public static function tailwindClasses(string $part, array $attributes, array $manifest, ...$custom): string { // If nothing is set, return custom classes as a fallback. - if (!$part || !$manifest || !isset($manifest['tailwind']) || \array_keys($manifest['tailwind']) === []) { + if (!$part || !$manifest || !isset($manifest['tailwind']) || $manifest['tailwind'] === []) { return $custom ? Helpers::clsx($custom) : ''; // @phpstan-ignore-line } - $allParts = isset($manifest['tailwind']['parts']) ? ['base', ...\array_keys($manifest['tailwind']['parts'])] : ['base']; - $partName = 'base'; - if (isset($manifest['tailwind']['parts'][$part]) && \in_array($part, $allParts, true)) { + if (isset($manifest['tailwind']['parts'][$part])) { $partName = $part; } elseif ($part !== 'base') { throw new Exception("Part '{$part}' is not defined in the manifest."); } - // Base classes. $baseClasses = self::unifyClasses($manifest['tailwind']['parts'][$partName]['twClasses'] ?? $manifest['tailwind']['base']['twClasses'] ?? ['']); - // Option classes. $options = $manifest['tailwind']['options'] ?? []; - $optionClasses = []; foreach ($options as $attributeName => $defs) { @@ -493,9 +485,7 @@ public static function tailwindClasses($part, $attributes, $manifest, ...$custom $optionClasses[] = self::processOption($partName, $optionValue, $defs); } - // Combinations. $combinations = $manifest['tailwind']['combinations'] ?? []; - $combinationClasses = []; foreach ($combinations as $combo) { @@ -504,12 +494,11 @@ public static function tailwindClasses($part, $attributes, $manifest, ...$custom $debugPrefix = ''; if (\defined('WP_DEBUG') && \WP_DEBUG) { - static $slugCache = []; $title = (string) ($manifest['title'] ?? ''); - if (!isset($slugCache[$title])) { - $slugCache[$title] = \strtolower(\preg_replace('/[^a-zA-Z]+/', '-', $title)); + if (!isset(self::$tailwindDebugSlugCache[$title])) { + self::$tailwindDebugSlugCache[$title] = \strtolower(\preg_replace('/[^a-zA-Z]+/', '-', $title)); } - $debugPrefix = "_es__{$slugCache[$title]}/{$part}"; + $debugPrefix = "_es__" . self::$tailwindDebugSlugCache[$title] . "/{$part}"; } return Helpers::clsx([$debugPrefix, $baseClasses, ...$optionClasses, ...$combinationClasses, ...$custom]); From fec4a13f291e55ef34858cd5ccd458fac5977db8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Ruz=CC=8Cevic=CC=81?= Date: Thu, 14 May 2026 21:15:47 +0200 Subject: [PATCH 13/17] update --- src/Main/AbstractMain.php | 270 +++++++++++++++++++++----------------- 1 file changed, 152 insertions(+), 118 deletions(-) diff --git a/src/Main/AbstractMain.php b/src/Main/AbstractMain.php index bfff0552..62e864eb 100644 --- a/src/Main/AbstractMain.php +++ b/src/Main/AbstractMain.php @@ -34,7 +34,7 @@ abstract class AbstractMain extends Autowiring implements ServiceInterface /** * Array of instantiated services. * - * @var Object[] + * @var object[] */ protected array $services = []; @@ -52,44 +52,36 @@ abstract class AbstractMain extends Autowiring implements ServiceInterface * * @return void */ - public function registerServices() + public function registerServices(): void { - // Bail early so we don't instantiate services twice. - if (!empty($this->services)) { + if ($this->services !== []) { return; } $this->services = $this->getServiceClassesWithDi(); - \array_walk( - $this->services, - function ($class) { - // Load services classes but not in the WP-CLI env, unless they have the ShouldLoadInCliContext attr. - if (!\defined('WP_CLI') && $class instanceof ServiceInterface) { + $isCli = \defined('WP_CLI'); + $cliLoadCache = []; + + foreach ($this->services as $class) { + if (!$isCli) { + if ($class instanceof ServiceInterface) { $class->register(); } + continue; + } - if (\defined('WP_CLI')) { - if ($class instanceof ServiceCliInterface) { - // Classes implementing ServiceCliInterface should be loaded only in CLI contexts. - $class->register(); - return; - } - - // Allow loading service classes in CLI contexts if it - // or a parent class has ShouldLoadInCliContext attribute. - $reflection = new ReflectionClass($class); - while ($reflection) { - if (\count($reflection->getAttributes(ShouldLoadInCliContext::class))) { - $class->register(); - return; - } - - $reflection = $reflection->getParentClass(); - } - } + if ($class instanceof ServiceCliInterface) { + $class->register(); + continue; } - ); + + $className = $class::class; + $cliLoadCache[$className] ??= $this->classWantsCliLoad($class); + if ($cliLoadCache[$className]) { + $class->register(); + } + } } /** @@ -124,7 +116,7 @@ private function getServiceClassesWithAutowire(): array /** * Return array of services with Dependency Injection parameters. * - * @return Object[] + * @return object[] * * @throws Exception Exception thrown by the DI container. */ @@ -132,20 +124,19 @@ protected function getServiceClassesWithDi(): array { $services = $this->getServiceClassesPreparedArray(); - if (!$services) { + if ($services === []) { return []; } - $services = $this->createServiceClassesCacheFile($services); - + $services = $this->maybeCacheProductionServices($services); $container = $this->getDiContainer($services); - return \array_map( - function ($class) use ($container) { - return $container->get($class); - }, - \array_keys($services) - ); + $instances = []; + foreach (\array_keys($services) as $class) { + $instances[] = $container->get($class); + } + + return $instances; } /** @@ -158,14 +149,17 @@ function ($class) use ($container) { */ private function getServiceClassesPreparedArray(): array { - // Dev-mode fast path: reuse the previous autowire result while project source is unchanged. - $cached = $this->loadDevServiceCache(); - if ($cached !== null) { - return $cached; + $devCacheEnabled = $this->isDevServiceCacheEnabled(); + $devCachePath = $devCacheEnabled ? $this->getCachePath('DevServiceClasses.json') : ''; + + if ($devCacheEnabled) { + $cached = $this->loadServicesCache($devCachePath, true); + if ($cached !== null) { + return $cached; + } } $output = []; - foreach ($this->getServiceClassesWithAutowire() as $class => $dependencies) { if (\is_array($dependencies)) { $output[$class] = $dependencies; @@ -175,30 +169,57 @@ private function getServiceClassesPreparedArray(): array $output[$dependencies] = []; } - $this->writeDevServiceCache($output); + if ($devCacheEnabled) { + $this->storeServicesCache($devCachePath, $output, true); + } return $output; } /** - * Load the development service cache, keyed by max mtime of the namespace's source tree. + * Read or write the production service-classes cache. + * + * Returns the cached services when available, otherwise persists the input array and returns it. + * Returns the input untouched when production caching is disabled. * - * Returns null when the cache is disabled, missing, malformed, or stale. + * @param array $services Services to cache if no cache exists yet. * - * @return array|null + * @return array */ - private function loadDevServiceCache(): ?array + private function maybeCacheProductionServices(array $services): array { - if (!$this->isDevServiceCacheEnabled()) { - return null; + if (!Helpers::shouldCache()) { + return $services; + } + + $path = $this->getCachePath('ServiceClasses.json'); + + $cached = $this->loadServicesCache($path, false); + if ($cached !== null) { + return $cached; } - $cachePath = $this->getDevServiceCachePath(); - if (!\is_file($cachePath)) { + $this->storeServicesCache($path, $services, false); + + return $services; + } + + /** + * Load a cached services array from disk. + * + * @param string $path Absolute cache file path. + * @param bool $checkMtime When true, the cache is invalidated if the namespace mtime changed. + * + * @return array|null Cached services, or null if missing/stale/malformed. + */ + private function loadServicesCache(string $path, bool $checkMtime): ?array + { + if (!\is_file($path)) { return null; } - $content = \file_get_contents($cachePath); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + $content = \file_get_contents($path); if ($content === false || $content === '') { return null; } @@ -206,13 +227,13 @@ private function loadDevServiceCache(): ?array $payload = \json_decode($content, true); if ( !\is_array($payload) || - !isset($payload['mtime'], $payload['services']) || + !isset($payload['services']) || !\is_array($payload['services']) ) { return null; } - if ((int) $payload['mtime'] !== $this->getNamespaceMaxMtime()) { + if ($checkMtime && (int) ($payload['mtime'] ?? -1) !== $this->getNamespaceMaxMtime()) { return null; } @@ -220,38 +241,35 @@ private function loadDevServiceCache(): ?array } /** - * Persist the prepared services array to the development cache. - * - * No-op when the dev cache is disabled or the target is unwritable. + * Persist a services array to the cache file atomically. * - * @param array $services Services array to persist. + * @param string $path Absolute cache file path. + * @param array $services Services to persist. + * @param bool $includeMtime When true, embed the namespace mtime for later invalidation. * * @return void */ - private function writeDevServiceCache(array $services): void + private function storeServicesCache(string $path, array $services, bool $includeMtime): void { - if (!$this->isDevServiceCacheEnabled()) { - return; - } - - $cachePath = $this->getDevServiceCachePath(); - $directory = \dirname($cachePath); + $directory = \dirname($path); if (!\is_dir($directory) && !\mkdir($directory, 0755, true) && !\is_dir($directory)) { return; } - $encoded = \wp_json_encode([ - 'mtime' => $this->getNamespaceMaxMtime(), - 'services' => $services, - ]); + $payload = ['services' => $services]; + if ($includeMtime) { + $payload['mtime'] = $this->getNamespaceMaxMtime(); + } + $encoded = \wp_json_encode($payload); if (!\is_string($encoded)) { return; } - if (\file_put_contents($cachePath, $encoded, \LOCK_EX) !== false) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents - \chmod($cachePath, 0644); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents + if (\file_put_contents($path, $encoded, \LOCK_EX) !== false) { + \chmod($path, 0644); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod } } @@ -277,34 +295,50 @@ private function isDevServiceCacheEnabled(): bool } /** - * Cache file path for the development autowiring cache. + * Absolute path to a cache file within the Eightshift output directory. + * + * Namespaced by the first segment of the project's PHP namespace so multiple + * AbstractMain subclasses don't collide. + * + * @param string $filename Filename including extension. + * + * @return string + */ + private function getCachePath(string $filename): string + { + return Helpers::getEightshiftOutputPath("{$this->getNamespaceRoot()}{$filename}"); + } + + /** + * First segment of the configured namespace, used as a cache key prefix. * * @return string */ - private function getDevServiceCachePath(): string + private function getNamespaceRoot(): string { - $file = \explode('\\', $this->namespace); - return Helpers::getEightshiftOutputPath("{$file[0]}DevServiceClasses.json"); + static $cache = []; + return $cache[$this->namespace] ??= \explode('\\', $this->namespace)[0]; } /** - * Maximum mtime of any PHP file under the namespace's psr-4 root. Memoized per request. + * Maximum mtime of any PHP file under the namespace's psr-4 root. + * + * Memoized per namespace so independent AbstractMain subclasses do not poison each other's cache. * * @return int */ private function getNamespaceMaxMtime(): int { - static $cached = null; - if ($cached !== null) { - return $cached; + static $cache = []; + if (isset($cache[$this->namespace])) { + return $cache[$this->namespace]; } $namespaceWithSlash = "{$this->namespace}\\"; $pathToNamespace = $this->psr4Prefixes[$namespaceWithSlash][0] ?? ''; if (!\is_string($pathToNamespace) || !\is_dir($pathToNamespace)) { - $cached = 0; - return 0; + return $cache[$this->namespace] = 0; } $max = 0; @@ -323,8 +357,7 @@ private function getNamespaceMaxMtime(): int } } - $cached = $max; - return $max; + return $cache[$this->namespace] = $max; } /** @@ -350,15 +383,16 @@ private function getDiContainer(array $services): Container } $autowire = new AutowireDefinitionHelper(); - $definitions[$serviceKey] = $autowire->constructor(...$this->getDiDependencies($serviceValues)); } $builder = new ContainerBuilder(); if (Helpers::shouldCache()) { - $fileName = \explode('\\', $this->namespace); - $builder->enableCompilation(Helpers::getEightshiftOutputPath(), "{$fileName[0]}CompiledContainer"); + $builder->enableCompilation( + Helpers::getEightshiftOutputPath(), + "{$this->getNamespaceRoot()}CompiledContainer" + ); } return $builder->addDefinitions($definitions)->build(); @@ -366,23 +400,32 @@ private function getDiContainer(array $services): Container /** * Return prepared Dependency Injection objects. - * If you pass a class use PHP-DI to prepare if not just output it. + * + * If a dependency value is a known class name it becomes a `Reference`, otherwise it is + * passed through unchanged. `class_exists` lookups are memoized to avoid repeated autoload hits. * * @param array $dependencies Array of classes/parameters to push in constructor. * - * @return array + * @return array */ private function getDiDependencies(array $dependencies): array { - return \array_map( - function ($dependency) { - if (\class_exists($dependency)) { - return new Reference($dependency); + static $classExistsCache = []; + + $resolved = []; + foreach ($dependencies as $dependency) { + if (\is_string($dependency)) { + $exists = $classExistsCache[$dependency] ??= \class_exists($dependency); + if ($exists) { + $resolved[] = new Reference($dependency); + continue; } - return $dependency; - }, - $dependencies - ); + } + + $resolved[] = $dependency; + } + + return $resolved; } /** @@ -398,33 +441,24 @@ protected function getServiceClasses(): array } /** - * Create the service classes cache file and return the services array. + * Determine whether a service class (or any ancestor) is marked with + * the ShouldLoadInCliContext attribute. * - * @param array $services Array of services. + * @param object $class Service instance. * - * @return array + * @return bool */ - private function createServiceClassesCacheFile(array $services): array + private function classWantsCliLoad(object $class): bool { - if (Helpers::shouldCache()) { - $file = \explode('\\', $this->namespace); - - $cacheFile = Helpers::getEightshiftOutputPath("{$file[0]}ServiceClasses.json"); + $reflection = new ReflectionClass($class); - if (\file_exists($cacheFile)) { - $handle = \fopen($cacheFile, 'r'); - $output = \stream_get_contents($handle); - - return \json_decode($output, true); + while ($reflection !== false) { + if ($reflection->getAttributes(ShouldLoadInCliContext::class) !== []) { + return true; } - - if (\file_put_contents($cacheFile, \json_encode($services))) { // phpcs:ignore - \chmod($cacheFile, 0644); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod - } - - return $services; + $reflection = $reflection->getParentClass(); } - return $services; + return false; } } From b525f55e971da03f644a78957a7344397be841cd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 20:13:07 +0000 Subject: [PATCH 14/17] perf: replace array_shift() with index pointer in Autowiring dependency queue Agent-Logs-Url: https://github.com/infinum/eightshift-libs/sessions/302f0961-7f41-467c-bea3-114424bfc3a3 Co-authored-by: iruzevic <23283324+iruzevic@users.noreply.github.com> --- src/Main/Autowiring.php | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/Main/Autowiring.php b/src/Main/Autowiring.php index 319ee314..b0ad779c 100644 --- a/src/Main/Autowiring.php +++ b/src/Main/Autowiring.php @@ -97,10 +97,13 @@ public function buildServiceClasses(array $manuallyDefinedDependencies = [], boo } // Resolve transitive dependencies via a work list queue rather than mutating - // the array while iterating it by reference. + // the array while iterating it by reference. Use an index pointer instead of + // array_shift() to avoid O(n) reindexing on each dequeue. isset() is used + // rather than count() because the queue grows during iteration. $queue = \array_keys($dependencyTree); - while ($queue !== []) { - $current = (string) \array_shift($queue); + $queueIndex = 0; + while (isset($queue[$queueIndex])) { + $current = (string) $queue[$queueIndex++]; foreach (\array_keys($dependencyTree[$current] ?? []) as $depClass) { if (isset($dependencyTree[$depClass])) { continue; From d4ec4e314722daf98f97d0d05795394c228aff01 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 20:19:06 +0000 Subject: [PATCH 15/17] fix: clear libxml error buffer before loadXML() in isValidXml() Agent-Logs-Url: https://github.com/infinum/eightshift-libs/sessions/6659a92b-522f-40af-9d1c-621ced7ccd8c Co-authored-by: iruzevic <23283324+iruzevic@users.noreply.github.com> --- src/Helpers/GeneralTrait.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Helpers/GeneralTrait.php b/src/Helpers/GeneralTrait.php index 8b5d7d4e..dca60de6 100644 --- a/src/Helpers/GeneralTrait.php +++ b/src/Helpers/GeneralTrait.php @@ -35,6 +35,7 @@ public static function isValidXml(string $xml): bool } $originalErrorState = \libxml_use_internal_errors(true); + \libxml_clear_errors(); try { $doc = new DOMDocument('1.0', 'utf-8'); From 1457c40164b381613feb259ff7d26fa5c74871fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Ru=C5=BEevi=C4=87?= Date: Thu, 14 May 2026 22:25:34 +0200 Subject: [PATCH 16/17] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/Helpers/PathsTrait.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Helpers/PathsTrait.php b/src/Helpers/PathsTrait.php index 3f8d911d..097dcd63 100644 --- a/src/Helpers/PathsTrait.php +++ b/src/Helpers/PathsTrait.php @@ -135,9 +135,10 @@ public static function joinPaths(array $paths): string $joinedPath = $sep . \implode($sep, $filteredPaths); - // Treat as a file path when the last segment carries an extension (dot after the final separator). + // Treat as a file path when the last segment carries a non-empty extension. $lastDot = \strrpos($joinedPath, '.'); - if ($lastDot !== false && $lastDot > \strrpos($joinedPath, $sep)) { + $lastSeparator = \strrpos($joinedPath, $sep); + if ($lastDot !== false && $lastDot > $lastSeparator && $lastDot < \strlen($joinedPath) - 1) { return $joinedPath; } From f1b91d4a1c0315900a8998170cd72a901c7bd915 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Ruz=CC=8Cevic=CC=81?= Date: Fri, 15 May 2026 09:11:51 +0200 Subject: [PATCH 17/17] Auto stash before checking out "origin/main" adding change log --- CHANGELOG.md | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ac22c95..5e0991ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,45 @@ All notable changes to this project will be documented in this file. This projects adheres to [Semantic Versioning](https://semver.org/) and [Keep a CHANGELOG](https://keepachangelog.com/). +## [13.0.0] + +### Added + +- Added a production service-classes cache (`{Namespace}ServiceClasses.json`) alongside the compiled PHP-DI container so the autowire scan only runs on a cold cache. +- Added a development service-classes cache (`{Namespace}DevServiceClasses.json`) keyed by the namespace mtime, disabled under WP-CLI so scaffolding always sees freshly added classes. +- Added stampede protection to `CacheTrait::populateCacheData()` via an advisory `flock()` lock with a double-checked load on contention. +- Added support in `Autowiring` for primitive constructor parameters that declare a default value — DI now registers classes that mix object and defaulted scalar dependencies instead of throwing. +- Added a shared `ReflectionClass` cache reused across the autowire build. +- Added a `PROPS_PASSTHROUGH_KEYS` constant in `AttributesTrait` for the special keys propagated through `props()`. + +### Changed + +- Promoted `Autowiring` constructor parameters to `readonly` properties and removed the now-redundant `AbstractMain::__construct()` (inherited from `Autowiring`). +- Rewrote `Autowiring::buildServiceClasses()` to resolve transitive dependencies through a work-queue instead of mutating the array under a `foreach` reference. +- Hoisted `Helpers::getSettings()` and wrapper attribute lookups out of the per-block loop in `AbstractBlocks::registerBlocks()` and threaded a shared `$context` into block registration. +- Memoized camel↔kebab conversions in `AbstractBlocks::prepareComponentAttribute()`/`prepareComponentAttributes()` and replaced per-iteration `array_merge` chains with a single varargs merge. +- Memoized `StoreBlocksTrait::getSettings()`, `TailwindTrait::getTwBreakpoints()`, `RenderTrait` default path name, and per-file existence checks for the request lifetime. +- Tightened parameter/return types in `TailwindTrait` (`unifyClasses`, `processOption`, `getTwBreakpoints`) and `AttributesTrait::checkAttr()` (now `: mixed`). +- `CssVariablesTrait::outputCssVariablesGlobalClean()` now builds the inner CSS with a single `implode()` and escapes the `id` / selector attributes via `esc_attr()` in `outputCssVariablesGlobal`/`outputCssVariablesInline`. +- `PathsTrait::joinPaths()` replaces `pathinfo()` extension detection with an inline `strrpos` check; `getProjectPaths()` uses spread-operator path composition. +- `GeneralTrait::isValidXml()` simplified to a single tight check; `flattenArray()` now preserves all non-`null` scalars; `recursiveArrayFind()` annotated as `array`. +- `SelectorsTrait` serializers use `implode()` instead of trailing-space concatenation and route through `Helpers::clsx()` directly. +- `ComponentException::throwNotStringOrArray()` inverted to use `is_object()` instead of `gettype() !== 'object'`. +- Bumped dev dependencies: `dealerdirect/phpcodesniffer-composer-installer` → `1.2.1`, `infinum/eightshift-coding-standards` → `^3.1.0`. + +### Removed + +- Removed `RenderTrait::initializeRenderCaches()` and the `$renderHandlers` lookup table — dispatch is now inlined. +- Removed `AbstractMain::__construct()` (the promoted `Autowiring` constructor is used instead). + +### Fixed + +- Fixed `Autowiring::buildClasses()` filename regex so it requires the `.php` extension to terminate the filename (previously `Foo.phpinfo` would have matched). +- Fixed `Autowiring`/`AbstractMain` directory walks to skip `.` and `..` via `RecursiveDirectoryIterator::SKIP_DOTS`. +- Fixed `CssVariablesTrait` to call `Helpers::getSettingsGlobalVariablesBreakpoints()` instead of `self::` so the trait works when consumed by classes that don't import that helper directly. +- Fixed double `wp_json_encode()` of the manifest payload on the cache write path in `CacheTrait`. +- Fixed `flattenArray()` dropping legitimate falsy scalars (`0`, `false`, `''`). + ## [12.3.4] ### Changed @@ -1136,6 +1175,7 @@ Init setup - Gutenberg Blocks Registration. - Assets Manifest data. +[13.0.0]: https://github.com/infinum/eightshift-libs/compare/12.3.4...13.0.0 [12.3.4]: https://github.com/infinum/eightshift-libs/compare/12.3.3...12.3.4 [12.3.3]: https://github.com/infinum/eightshift-libs/compare/12.3.2...12.3.3 [12.3.2]: https://github.com/infinum/eightshift-libs/compare/12.3.1...12.3.2