diff --git a/.claude/skills/hyva-alpine-component b/.claude/skills/hyva-alpine-component new file mode 120000 index 00000000..cce7ec53 --- /dev/null +++ b/.claude/skills/hyva-alpine-component @@ -0,0 +1 @@ +/Users/willem/hyva-ai-tools/skills/hyva-alpine-component \ No newline at end of file diff --git a/.claude/skills/hyva-internal-coding-standard b/.claude/skills/hyva-internal-coding-standard new file mode 120000 index 00000000..46bb644d --- /dev/null +++ b/.claude/skills/hyva-internal-coding-standard @@ -0,0 +1 @@ +/Users/willem/hyva-internal-skills/skills/hyva-internal-coding-standard \ No newline at end of file diff --git a/.claude/skills/hyva-internal-skill-security-review b/.claude/skills/hyva-internal-skill-security-review new file mode 120000 index 00000000..e3a8e569 --- /dev/null +++ b/.claude/skills/hyva-internal-skill-security-review @@ -0,0 +1 @@ +/Users/willem/hyva-internal-skills/skills/hyva-internal-skill-security-review \ No newline at end of file diff --git a/.claude/skills/hyva-playwright-test b/.claude/skills/hyva-playwright-test new file mode 120000 index 00000000..e5b22c7d --- /dev/null +++ b/.claude/skills/hyva-playwright-test @@ -0,0 +1 @@ +/Users/willem/hyva-ai-tools/skills/hyva-playwright-test \ No newline at end of file diff --git a/.claude/skills/magewire-architecture/SKILL.md b/.claude/skills/magewire-architecture/SKILL.md new file mode 100644 index 00000000..9df5e171 --- /dev/null +++ b/.claude/skills/magewire-architecture/SKILL.md @@ -0,0 +1,596 @@ +--- +name: magewire-architecture +description: "Internals and extension guide for the Magewire framework: directory layout (src/lib/dist/portman), Mechanisms vs Features, area-scoped DI (frontend/adminhtml), snapshot/state flow, layout containers, JS extension points, and Facades. Use when extending Magewire, creating custom Features or Mechanisms, debugging the framework itself, or understanding how the codebase is structured." +license: MIT +metadata: + author: Willem Poortman +--- + +# Magewire Architecture + +Magewire's codebase is split into two layers: an upstream Livewire core (PHP, ported and maintained via Portman) and a Magento integration layer (controllers, blocks, DI, layout, templates). Understanding this split is essential for knowing where to add or change things. + +--- + +## Repository Layout + +``` +vendor/magewirephp/magewire/ +├── src/ # Magento module structure (hand-written, must live here for Magento) +│ ├── Component.php # Base component class (public API) +│ ├── MagewireServiceProvider.php +│ ├── Controller/ # Route controllers (magewire/update) +│ ├── Model/ # Magento-specific models, view utils, fragment handling +│ ├── Observer/ # Magento event observers +│ ├── Plugin/ # Magento plugin interceptors +│ ├── ViewModel/ # MagewireViewModel (utilities facade) +│ ├── Exceptions/ +│ ├── etc/ # module.xml, di.xml, events.xml, routes.xml +│ └── view/ # Layout XML, PHTML templates, JS/CSS assets +│ +├── lib/ # Hand-written custom code + downloaded Livewire source +│ ├── Livewire/ # Downloaded upstream Livewire source (Portman input cache) +│ ├── Magewire/ # Hand-written Magewire core (mechanisms, features, enums, runtime) +│ ├── MagewireBc/ # Hand-written backwards compatibility layer +│ ├── Magento/ # Hand-written Magento framework integrations +│ └── Symfony/ # Hand-written Symfony adaptations +│ +├── portman/ # Portman augmentation files +│ └── Livewire/ # Augmentations: what to change/extend in upstream Livewire +│ +├── dist/ # Portman BUILD OUTPUT — do not edit directly +│ │ # (Livewire source + augmentations, namespace-transformed) +│ ├── ComponentHook.php +│ ├── Mechanisms/ +│ └── Features/ +│ +├── themes/ # Theme support sub-modules (e.g. Hyva, Luma) +├── tests/ # Playwright + other test types +└── portman.config.php # Controls Portman: source paths, output dir, transformations +``` + +**Rule of thumb**: `src/` and `lib/` (excluding `lib/Livewire/`) are yours to edit freely. `dist/` is Portman-generated — changes belong in `portman/Livewire/` (augmentations) or `portman.config.php`. `lib/Livewire/` is the downloaded upstream source cache — don't edit it either. + +--- + +## Bootstrap & Runtime + +Magewire boots through `MagewireServiceProvider`, which is triggered by Magento's DI during the request lifecycle. It registers three types of services in order: + +1. **Containers** — Internal DI-like containers (MagewireManager, etc.) +2. **Mechanisms** — Non-optional core infrastructure (sorted, loaded in order) +3. **Features** — Optional lifecycle hooks (sorted, loaded in order) + +Boot modes per service item (`ServiceTypeItemBootMode`): +- `LAZY` (10) — Boot only when needed +- `PERSISTENT` (20) — Boot during setup phase, persists across request modes +- `ALWAYS` (30) — Boot on every request (default) + +Runtime state progression: `UNINITIALIZED → SETUP → BOOTING → BOOTED` (or `FAILED / STOPPED`) + +Request modes: `PRECEDING` (initial page load) and `SUBSEQUENT` (AJAX update). + +--- + +## Mechanisms + +Mechanisms are the non-optional core pipeline. They live in `lib/Magewire/Mechanisms/` (hand-written, Magento-specific) and `dist/Mechanisms/` (ported from Livewire), and run in `sort_order` sequence: + +| Sort | Mechanism | Role | +|------|-----------|------| +| 1000 | `ResolveComponents` | Discovers and instantiates components from layout blocks | +| 1050 | `PersistentMiddleware` | Carries persistent data between requests | +| 1100 | `HandleComponents` | Manages snapshot, properties, synthesizers | +| 1200 | `HandleRequests` | Orchestrates the full update cycle | +| 1250 | `DataStore` | Global request-scoped data storage | +| 1400 | `FrontendAssets` | Serves the JS bundle and manifest | + +Mechanisms are registered in `src/etc/frontend/di.xml` and `src/etc/adminhtml/di.xml` separately — never in the global `di.xml`. See the note on area-scoped DI below. + +To add a custom mechanism, add an entry to the DI config pointing to a class that extends the mechanism base and implements its contract. Mechanisms cannot be skipped — if you only need optional behavior, use a Feature instead. + +--- + +## Features + +Features are optional lifecycle extensions. Each Feature is a `ComponentHook` subclass that hooks into the component lifecycle via `provide()`. They live in `lib/Magewire/Features/` (hand-written, Magewire-specific) or `dist/Features/` (ported from Livewire via Portman). + +Key built-in features and their sort orders: + +| Sort | Feature | Role | +|------|---------|------| +| 700 | `SupportMagewireViewModel` | Auto-injects view_model on blocks | +| 800 | `SupportMagewireExceptionHandling` | Exception management | +| 900 | `SupportMagewireRateLimiting` | Rate limiting | +| 1000 | `SupportNestingComponents` | Parent-child relationships | +| 1200 | `SupportAttributes` | PHP attribute handling | +| 1300 | `SupportRedirects` | Redirect effects | +| 1500 | `SupportLocales` | Locale/i18n support | +| 1600 | `SupportEvents` | Inter-component events | +| 5000 | `SupportMagentoLayouts` | Magento layout rendering | +| 5100 | `SupportMagentoFlashMessages` | Magento session flash messages | +| 5200 | `SupportMagewireLoaders` | Loader state management | +| 5200 | `SupportMagewireNotifications` | Toast notification system | +| 99000 | `SupportLifecycleHooks` | Calls lifecycle methods on components | +| 99100 | `SupportMagewireBackwardsCompatibility` | Legacy API support | + +### Naming Conventions + +- `SupportMagewire` prefix — Magewire-specific features (e.g. `SupportMagewireLoaders`) +- `SupportMagento` prefix — Magento compatibility features (e.g. `SupportMagentoLayouts`) + +### The Hook System + +Features hook into component lifecycle events using `on()` / `trigger()`. A hook callback must return either another callback (which receives the result) or the original value unchanged. + +```php +use function Magewirephp\Magewire\on; + +class SupportMyFeature extends \Magewirephp\Magewire\ComponentHook +{ + public static function provide(): void + { + on('magewire:construct', function () { + // Return a callback — it fires AFTER the trigger resolves + return function (\Magento\Framework\View\Element\AbstractBlock $block) { + // Do something with the block + return $block; // Must return the value + }; + }); + } +} +``` + +Hooks are also observable via Magento's native event system through the `SupportMagentoObserverEvents` feature (sort order 99400). It maps 30+ lifecycle hooks to Magento events with the pattern `magewire_on_{eventname}` (special characters replaced with underscores). This means third-party modules can observe Magewire lifecycle events using standard Magento `events.xml` observers without needing to create a Feature. + +### Middleware-Style Hooks + +Some hooks support a middleware pattern: if your callback returns a callable, that callable runs **after** the operation completes. This is not available on all hooks — only those that support before/after semantics: + +- **`update`** — return a callback that runs after the property is updated (used for `updated*` hooks) +- **`call`** — return a callback that runs after method execution +- **`render`** — return a callback that runs after rendering completes (used for `rendered` hook) + +```php +on('update', function ($propertyName, $fullPath, $newValue) { + // Runs BEFORE the property update + + return function () use ($fullPath) { + // Runs AFTER the property update + }; +}); +``` + +Hooks that do **not** support middleware (e.g., `mount`, `hydrate`, `dehydrate`, `exception`) simply run their callback inline — returning a callable from these has no effect. + +### Adding a Custom Feature + +1. Create a class extending `Magewirephp\Magewire\ComponentHook`: + +```php +namespace Vendor\Module\Magewire\Features; + +use Magewirephp\Magewire\ComponentHook; +use function Magewirephp\Magewire\on; + +class SupportMyFeature extends ComponentHook +{ + public static function provide(): void + { + on('mount', function ($params) { + // Runs during mount — no middleware support + }); + + on('hydrate', function ($memo) { }); + + on('dehydrate', function ($context) { }); + + on('update', function ($propertyName, $fullPath, $newValue) { + // Before update + return function () { + // After update (middleware pattern) + }; + }); + } +} +``` + +2. Register in `etc/frontend/di.xml` **and** `etc/adminhtml/di.xml` as needed — never in the global `etc/di.xml`. See the note on area-scoped DI below. + +```xml + + + + + Vendor\Module\Magewire\Features\SupportMyFeature + 5050 + + + + +``` + +Place feature PHP code in `src/Features/SupportMyFeature/` and any JS in its corresponding `view/` subfolder. + +**Feature templates live at:** `view/{area}/templates/magewire-features/{feature-name}/` — the same convention applies to core framework features (in the Magewire module) and to theme compatibility features (in theme modules like Hyvä). Keeping JS, UI, and other feature-related PHTMLs co-located in a single folder per feature makes ownership and overrides easy to reason about. + +--- + +## Area-Scoped DI: Why `frontend/di.xml` and `adminhtml/di.xml` + +Features and Mechanisms **must** be registered in area-specific DI files (`etc/frontend/di.xml`, `etc/adminhtml/di.xml`) rather than the global `etc/di.xml`. + +**Why:** Magento's DI system merges global config into every area. If a Feature or Mechanism were declared in the global `di.xml`, it would be impossible for another module to register a different set for frontend vs adminhtml — the global declaration would apply everywhere and couldn't be selectively overridden per area. + +By keeping registrations area-scoped: +- A feature active on the frontend can be absent in the admin (or replaced with a different implementation) +- Third-party compatibility modules (e.g. `MagewireCompatibilityWithHyva`) can add their own features to `frontend/di.xml` without affecting the admin +- The admin panel (`adminhtml/di.xml`) can carry a different, leaner or admin-tailored set of features and mechanisms + +**Rule:** When adding a Feature or Mechanism to your own module, always register it in `etc/frontend/di.xml`, `etc/adminhtml/di.xml`, or both — depending on which areas it should be active in. Never use `etc/di.xml` for this. + +--- + +## Component Resolvers & Arguments + +The `ResolveComponents` mechanism is Magewire-specific (not ported from Livewire) and is one of the most powerful parts of the framework. It determines **how** a Magento block becomes a Magewire component — and it's designed to be extensible, so that components can come from layout XML, widgets, API calls, or any custom source. + +### How Resolution Works + +When Magewire encounters a block during rendering, the `ComponentResolverManager` iterates registered resolvers (sorted by DI `sortOrder`) and calls `complies()` on each until one matches. The winning resolver then handles construction (initial page load) and reconstruction (AJAX updates). + +Resolvers are registered in area-scoped DI: + +```xml + + + + + + Magewirephp\Magewire\Mechanisms\ResolveComponents\ComponentResolver\LayoutResolver + + + + +``` + +The resolver's DI item name must match its `$accessor` property exactly. The accessor is stored in the component's snapshot memo, so subsequent requests can find the right resolver to reconstruct the component. + +### The Resolver Contract + +Every resolver extends `ComponentResolver` and implements: + +| Method | Purpose | +|--------|---------| +| `complies($block, $magewire)` | Lightweight check: does this block belong to this resolver? | +| `construct($block)` | Initial page load: bind a `Component` instance to the block's `magewire` data key, set `name` and `id` | +| `reconstruct($request)` | AJAX update: rebuild the block + component from snapshot data (typically calls `construct()` internally) | +| `arguments()` | Return a `MagewireArguments` subclass for this resolver | +| `assemble($block, $component)` | Final assembly after construct/reconstruct — sets name, id, alias on the component | +| `remember()` | Whether this resolver should be cached (default `true`; set `false` for dynamic resolution) | + +```php +namespace Vendor\Module\Mechanisms\ResolveComponents\ComponentResolver; + +use Magewirephp\Magewire\Mechanisms\ResolveComponents\ComponentResolver\ComponentResolver; + +class WidgetResolver extends ComponentResolver +{ + protected string $accessor = 'widget'; + + public function complies(AbstractBlock $block, mixed $magewire = null): bool + { + // Check for widget-specific data + $this->conditions()->if( + fn () => $block->getData('widget_instance_id') !== null, + 'is-widget' + ); + + return parent::complies($block, $magewire); + } + + public function construct(AbstractBlock $block): AbstractBlock + { + // Instantiate component and bind to block + $component = Factory::create(MyWidgetComponent::class); + $block->setData('magewire', $component); + return $block; + } + + public function reconstruct(ComponentRequestContext $request): AbstractBlock + { + // Rebuild from snapshot — get widget ID from memo, recreate block + $widgetId = $request->snapshot->memo['widget_id'] ?? null; + $block = $this->widgetRepository->getBlockById($widgetId); + return $this->construct($block); + } + + public function arguments(): MagewireArguments + { + return $this->widgetBlockArgumentsFactory->create(); + } +} +``` + +### ComponentArguments: Structured Block Arguments + +Magewire introduces a structured argument system for passing data to components through layout XML. Arguments are extracted from block data during the assembly phase. + +**Public arguments** — prefixed with `magewire.`, become component properties: + +```xml + + + Vendor\Module\Magewire\MyComponent + 42 + price + + +``` + +The `magewire.` prefix is stripped and kebab-case is converted to camelCase: +- `magewire.product-id` → `productId` +- `magewire.sort-order` → `sortOrder` + +**Group arguments** — prefixed with `magewire:{group}:{key}`, organized into named groups: + +```xml + + + Vendor\Module\Magewire\MyComponent + 10 + 20 + 3600 + + +``` + +Group arguments are accessed via the `MagewireArguments` API: + +```php +// In your component's mount() — receives the 'mount' group +public function mount(int $categoryId = 0, int $pageSize = 10): void +{ + // $categoryId = 10, $pageSize = 20 (from magewire:mount:*) +} + +// In a resolver or feature — access any group +$arguments->forMount(); // ['categoryId' => 10, 'pageSize' => 20] +$arguments->forGroup('config'); // ['cacheTtl' => 3600] +$arguments->toParams(); // All public arguments as array +``` + +**Resolver-specific arguments** — the `magewire:resolver` key can force a specific resolver: + +```xml +widget +``` + +**Alias** — the `magewire:alias` key sets a component alias for lookup: + +```xml +shipping-form +``` + +### Argument Class Hierarchy + +Each resolver provides its own `MagewireArguments` subclass: + +``` +MagewireArguments (abstract, extends DataObject) + └── BlockMagewireArguments + └── LayoutBlockArguments (used by LayoutResolver, marked shared="false" in DI) +``` + +Custom resolvers create their own subclass for specialized argument handling (e.g., `WidgetBlockArguments` could extract widget configuration parameters). + +### The Layout Resolver + +The built-in `LayoutResolver` (accessor: `layout`) handles the most common case — components declared in layout XML. It supports three binding formats: + +```xml + +Vendor\Module\Magewire\MyComponent + + + + Vendor\Module\Magewire\MyComponent + + + + + true + +``` + +On dehydrate, the `LayoutResolver` stores the active layout handles in the snapshot memo. On reconstruction (AJAX), it regenerates the layout from those handles to recover the block. + +### Writing a Custom Resolver + +To add a new component source (e.g., Magento widgets): + +1. Create a resolver class extending `ComponentResolver` +2. Create a `MagewireArguments` subclass for your argument format +3. Register in area-scoped DI with a unique accessor name and sort order + +```xml + + + + + Vendor\Module\Mechanisms\ResolveComponents\ComponentResolver\WidgetResolver + + + + +``` + +Lower sort orders are evaluated first. The `LayoutResolver` intentionally uses a high sort order (99900) so it acts as a fallback — custom resolvers should use lower numbers to take priority when their `complies()` check matches. + +--- + +## Snapshot & State Flow + +The `Snapshot` is the serialized state of a component, round-tripped between frontend and backend as JSON embedded in the HTML. + +``` +Snapshot = { + data: { /* public properties */ }, + memo: { /* metadata: resolver class, children, bindings, etc. */ }, + checksum: /* HMAC of data+memo */ +} +``` + +The checksum prevents tampering. On each AJAX request, Magewire validates it before processing. + +`Memo` carries non-property metadata. The `ResolveComponents` mechanism writes the component resolver class name into memo so the component can be reconstructed on subsequent requests without needing layout context. + +`Effects` accumulates side effects during the request (redirect, events, method return values) and is sent back to the frontend alongside the new snapshot. + +### Synthesizers + +Synthesizers (in `dist/Mechanisms/HandleComponents/Synthesizers/` for ported ones, `lib/Magewire/Mechanisms/HandleComponents/Synthesizers/` for Magento-specific ones) handle serialization of non-scalar property types (e.g. `DataObject`, PHP enums, collections). To support a custom type in properties, create a synthesizer and register it in DI. + +--- + +## Request Flow (AJAX Update) + +``` +POST magewire/update + → Update controller (src/Controller/Magewire/Update.php) + → HandleRequestFacade::update() + → HandleRequests mechanism + 1. Reconstruct component from snapshot memo (resolver class) + 2. boot() + initialize() hooks + 3. hydrate() hooks + 4. booted() hook + 5. Apply property updates → updating/updated hooks + 6. Execute method calls + 7. rendering() hook → render template → rendered() hook + 8. dehydrate() hooks + 9. Generate new snapshot + checksum + 10. Return { snapshot, effects, html } + ← Alpine.js morphs DOM, stores new snapshot +``` + +--- + +## Layout & Template Integration + +Magewire's own layout is defined in `src/view/base/layout/default.xml`. It creates a `magewire` root block that outputs all setup scripts (Alpine.js init, utilities, addons, directives, feature scripts). + +Magewire can be bound to any block class — not just `Magewirephp\Magewire\Block\Magewire`. The `ResolveComponents` mechanism discovers all blocks with a `magewire` data argument during layout render, uses the appropriate `ComponentResolver` to instantiate the PHP component class, mounts it, and embeds the snapshot as a `wire:snapshot` attribute on the root element. + +### Layout Containers + +All Magewire layout output is organized in named containers. Extend via standard Magento layout XML: + +```xml + + + +``` + +Full layout reference (from `src/view/base/layout/default.xml`). Elements marked **(block)** have templates; **(container)** are injection points. + +| Name | Type | Purpose | +|------|------|---------| +| `magewire` | block | Root block (`root.phtml`) | +| `magewire.global` | block | Global JS setup (`js/magewire/global.phtml`) | +| `magewire.global.before` | container | Before global — holds Alpine load, Alpine code, Alpine components | +| `magewire.alpinejs.load` | container | Alpine JS loading | +| `magewire.alpinejs` | container | Custom Alpine JS code (before Magewire Alpine) | +| `magewire.alpinejs.components` | container | Alpine `Alpine.data()` registrations | +| `magewire.utilities` | block | Utility parent (`js/magewire/utilities.phtml`) — renders child utilities | +| `magewire.utilities.after` | container | Inject custom utilities after core ones | +| `magewire.addons` | block | Addon parent (`js/magewire/addons.phtml`) — renders child addons | +| `magewire.addons.after` | container | Inject custom addons after core ones | +| `magewire.global.after` | container | Custom extensions after the global block | +| `magewire.before` | container | Holds Alpine directives, UI components, Alpine after | +| `magewire.alpinejs.directives` | container | Custom Alpine directives | +| `magewire.ui-components` | container | Alpine UI components (notifier lives here) | +| `magewire.alpinejs.after` | container | Custom Alpine code after Magewire Alpine code | +| `magewire.before.internal` | container | Debug state blocks | +| `magewire.internal` | block | Non-overridable core block — do not inject here | +| `magewire.internal.backwards-compatibility` | container | BC injection point inside internal block | +| `magewire.directives` | block | Directive parent (`js/magewire/directives.phtml`) — renders child directives | +| `magewire.features` | block | Feature parent (`js/magewire/features.phtml`) — renders child feature scripts | +| `magewire.after.internal` | container | Inject content after the internal block | +| `magewire.disabled` | container | Renders only when Magewire is inactive | +| `magewire.after` | container | Everything else (debug tools, HTML blocks) | +| `magewire.legacy` | container | V1 backwards compatibility — do not use for new code | + +Note: `magewire.utilities`, `magewire.addons`, `magewire.directives`, and `magewire.features` are **blocks** (not containers). They have parent templates that call `$block->getChildHtml()` to render their children. Add custom children as blocks inside them. + +--- + +## JavaScript Architecture + +**All JavaScript must be CSP-compliant.** Never use raw ` +end() ?> +``` + +## Facades for Public APIs + +When a Feature or Mechanism needs a public API that other modules consume, register a Facade via DI. + +```xml + + Vendor\Module\Features\SupportMyFeature + Vendor\Module\Features\SupportMyFeature\MyFeatureFacade + 5050 + +``` + +Access via the service provider: + +```php +$facade = $magewireServiceProvider->getMyFeatureFacade(); +``` + +Facades live in their feature's subdirectory. This pattern is experimental — check the `magewire-architecture` skill for current status. diff --git a/.claude/skills/magewire-best-practices/references/javascript.md b/.claude/skills/magewire-best-practices/references/javascript.md new file mode 100644 index 00000000..b122a3f3 --- /dev/null +++ b/.claude/skills/magewire-best-practices/references/javascript.md @@ -0,0 +1,101 @@ +# JavaScript Integration + +## CSP-Compatible Scripts Only + +All inline JavaScript must use the fragment utility (`$magewireFragment->make()->script()->start()/end()`). Never write raw ` +end() ?> +``` + +- `$escaper` is always imported even when not used directly in this file, so subblocks can rely on it. +- The `@internal` docblock signals that this file is not intended for theme override. +- Never omit `$script->end()`. The fragment mechanism is only complete when `end()` is called. + +**PHP values in JS strings** — always escape through `$escaper->escapeJs()`: + +```php +'escapeJs(__('Too many requests! Please wait.')) ?>' +``` + +**PHP comments inside ` +end() ?> +``` + +**Layout block** in `src/view/base/layout/default.xml`, inside `magewire.utilities`: + +```xml + +``` + +--- + +## Addon + +An addon is a stateful, framework-agnostic API. It is Alpine-reactive when the third argument to +`register()` is `true`, allowing its state to drive the DOM directly. + +**`src/view/base/templates/js/magewire/addons/poll.phtml`** + +```php +getData('view_model'); +$magewireFragment = $magewireViewModel->utils()->fragment(); +?> +make()->script()->start() ?> + +end() ?> +``` + +**Layout block** in `src/view/base/layout/default.xml`, inside `magewire.addons`: + +```xml + +``` + +--- + +## Alpine component + +An Alpine component is a thin wrapper that exposes addon state and a limited set of methods to HTML templates. +All logic lives in the addon. The component only provides what the template needs. + +Magewire ships `magewire.csp.min.js`, an unmodified copy of Livewire's JavaScript bundle, which includes +the Alpine CSP build. HTML attribute expressions can only access properties and methods defined on the +component's returned data object. Closure variables like `const poll = window.MagewireAddons.poll` are +not in scope from HTML — they must be wrapped in component methods. That is the sole reason for the +`START/END` comment markers. + +**`src/view/base/templates/js/alpinejs/components/magewire-poll.phtml`** + +```php +getData('view_model'); +$magewireFragment = $magewireViewModel->utils()->fragment(); +?> +make()->script()->start() ?> + +end() ?> +``` + +**Usage in a theme template:** + +```html +
+ + + +
+``` + +**Layout block** in `src/view/base/layout/default.xml`, inside `magewire.alpinejs.components`: + +```xml + +``` + +--- + +## Directive + +A directive adds a declarative HTML attribute. It is self-contained and exports nothing. + +**`src/view/base/templates/js/magewire/directives/copy.phtml`** + +```php +getData('view_model'); +$magewireFragment = $magewireViewModel->utils()->fragment(); +?> +make()->script()->start() ?> + +end() ?> +``` + +**Usage:** + +```html + + + + +npm install magewire + +``` + +**Layout block** in `src/view/base/layout/default.xml`, inside `magewire.directives`: + +```xml + +``` + +--- + +## Feature + +A feature bridges a Magewire PHP Feature's effects to an addon or utility. It reads effects pushed by PHP +and calls the appropriate addon methods. + +The PHP side pushes a custom effect in `dehydrate()`: + +```php +public function dehydrate(ComponentContext $context): void +{ + $context->pushEffect('poll', ['reset' => true]); +} +``` + +**`src/view/base/templates/magewire-features/support-magewire-poll/support-magewire-poll.phtml`** + +```php +getData('view_model'); +$magewireFragment = $magewireViewModel->utils()->fragment(); +?> +make()->script()->start() ?> + +end() ?> +``` + +**Layout block** in `src/view/base/layout/default.xml`, inside `magewire.features`: + +```xml + +``` + +--- + +## Frontend-only component + +When a component uses frontend-specific APIs (e.g., Hyva's `hyva.getFormKey()`), it goes in `frontend/` instead of `base/`. +The structure under `js/` is identical — only the view area changes. + +**`src/view/frontend/templates/js/alpinejs/components/magewire-form.phtml`** + +```php +getData('view_model'); +$magewireFragment = $magewireViewModel->utils()->fragment(); +?> +make()->script()->start() ?> + +end() ?> +``` + +**Layout block** in `src/view/frontend/layout/default.xml`: + +```xml + +``` diff --git a/.claude/skills/magewire-javascript/references/reference.md b/.claude/skills/magewire-javascript/references/reference.md new file mode 100644 index 00000000..add97b6f --- /dev/null +++ b/.claude/skills/magewire-javascript/references/reference.md @@ -0,0 +1,372 @@ +# Reference + +--- + +## Naming conventions + +| Type | Function name | Example | +|---|---|---| +| Utility | `magewire{Name}Utility` | `magewireClipboardUtility` | +| Addon | `magewire{Name}Addon` | `magewirePollAddon` | +| Alpine component | `magewire{Name}` | `magewirePoll` | +| Alpine bindings | `magewire{Name}Bindings` | `magewirePollBindings` | +| Directive | — (IIFE, no export) | — | +| Feature | — (event listener, no export) | — | + +Registration keys are the camelCase name without prefix or suffix: `'clipboard'`, `'poll'`, `'notifier'`. + +--- + +## Registration calls + +```javascript +// Utility — waits for alpine:init +document.addEventListener('alpine:init', () => window.MagewireUtilities.register('name', factoryFn), { once: true }); + +// Addon — immediate; MagewireResource queues reactive items if Alpine is not yet ready +window.MagewireAddons.register('name', factoryFn, true); // true = Alpine.reactive +window.MagewireAddons.register('name', factoryFn, false); // false = plain object + +// Alpine component +document.addEventListener('alpine:init', () => Alpine.data('magewire{Name}', factoryFn), { once: true }); + +// Alpine bindings (optional companion) +document.addEventListener('alpine:init', () => Alpine.bind('magewire{Name}Bindings', bindingsFn), { once: true }); + +// Alpine store +document.addEventListener('alpine:init', () => Alpine.store('name', { ... })); + +// Directive +document.addEventListener('magewire:initialized', event => Magewire.directive('mage:name', handler)); +``` + +--- + +## Event timing + +| Event | Fires when | Register here | +|---|---|---| +| `alpine:init` | Alpine starts, before DOM walk | `Alpine.data`, `Alpine.store`, `Alpine.bind`, utilities, addons (if not immediate) | +| `magewire:init` | Magewire runtime ready | `Magewire.hook('commit')`, `Magewire.hook('request')`, feature init | +| `magewire:initialized` | Full init complete | `Magewire.directive()` | + +Always add `{ once: true }` to registration listeners. + +--- + +## PHP boilerplate + +**Minimal (no PHP values in JS):** + +```php +getData('view_model'); +$magewireFragment = $magewireViewModel->utils()->fragment(); +?> +make()->script()->start() ?> + +end() ?> +``` + +**With PHP enum values:** + +```php +use Magewirephp\Magewire\Model\Magewire\Notifier\NotificationStateEnum; + +$state = NotificationStateEnum::IDLE->getState(); // used inside escapeJs() +``` + +```javascript +const state = 'escapeJs(NotificationStateEnum::IDLE->getState()) ?>'; +const levels = JSON.parse(''); +``` + +**PHP comment inside ` + + getChildHtml() ?> + +``` + +See `themes/Hyva/view/frontend/templates/script.phtml` for the production version. + +## 5. Registering a Feature-scoped bridge script + +Use when your theme Feature needs a companion JS file rendered into `magewire.features`. + +**`themes/MyTheme/view/frontend/layout/default_mytheme.xml`** +```xml + + + +``` + +**`themes/MyTheme/view/frontend/templates/magewire-features/support-mytheme/bridge.phtml`** +```php +getData('magewire_utility'); +$magewireFragment = $magewireUtility->utils()->fragment(); +$script = $magewireFragment->make()->script()->start(); +?> + +end(); ?> +``` + +The fragment-based script pattern is non-negotiable: it rewrites inline scripts into CSP-compliant form. Raw ` + HTML; + } + public static function scriptConfig($options = []) + { + app(static::class)->hasRenderedScripts = true; + $nonce = static::nonce($options); + $progressBar = config('livewire.navigate.show_progress_bar', true) ? '' : 'data-no-progress-bar'; + $attributes = json_encode(['csrf' => app()->has('session.store') ? csrf_token() : '', 'uri' => app('livewire')->getUpdateUri(), 'progressBar' => $progressBar, 'nonce' => isset($options['nonce']) ? $options['nonce'] : '']); + return <<window.livewireScriptConfig = {$attributes}; + HTML; + } + protected static function usePublishedAssetsIfAvailable($url, $manifest, $nonce) + { + $assetWarning = null; + // Check to see if static assets have been published... + if (!file_exists(public_path('vendor/livewire/manifest.json'))) { + return [$url, $assetWarning]; + } + $publishedManifest = json_decode(file_get_contents(public_path('vendor/livewire/manifest.json')), true); + $versionedFileName = $publishedManifest['/livewire.js']; + $fileName = config('app.debug') ? '/livewire.js' : '/livewire.min.js'; + $versionedFileName = "{$fileName}?id={$versionedFileName}"; + $assertUrl = config('livewire.asset_url') ?? (app('livewire')->isRunningServerless() ? rtrim(config('app.asset_url'), '/') . "/vendor/livewire{$versionedFileName}" : url("vendor/livewire{$versionedFileName}")); + $url = $assertUrl; + if ($manifest !== $publishedManifest) { + $assetWarning = << + console.warn('Livewire: The published Livewire assets are out of date\\n See: https://livewire.laravel.com/docs/installation#publishing-livewires-frontend-assets') + + + HTML; + } + return [$url, $assetWarning]; + } + protected static function minify($subject) + { + return preg_replace('~(\v|\t|\s{2,})~m', '', $subject); + } + protected static function nonce($options = []) + { + $nonce = $options['nonce'] ?? Vite::cspNonce(); + return $nonce ? "nonce=\"{$nonce}\"" : ''; + } + function __construct(private readonly AssetsRepository $assetsRepository, private readonly RequestInterface $request) + { + // + } +} \ No newline at end of file diff --git a/dist/Mechanisms/HandleComponents/Checksum.php b/dist/Mechanisms/HandleComponents/Checksum.php new file mode 100644 index 00000000..b5dbc069 --- /dev/null +++ b/dist/Mechanisms/HandleComponents/Checksum.php @@ -0,0 +1,50 @@ +generate($snapshot)) { + trigger('checksum.fail', $checksum, $comparator, $snapshot); + throw new CorruptComponentPayloadException(); + } + } + /** + * @throws FileSystemException + * @throws RuntimeException + */ + function generate(array $snapshot): string + { + $hashKey = $this->deploymentConfig->get('crypt/key'); + $checksum = hash_hmac('sha256', $this->serializer->serialize($snapshot), $hashKey); + trigger('checksum.generate', $checksum, $snapshot); + return $checksum; + } + function __construct(private readonly DeploymentConfig $deploymentConfig, private readonly SerializerInterface $serializer) + { + // + } +} \ No newline at end of file diff --git a/dist/Mechanisms/HandleComponents/ComponentContext.php b/dist/Mechanisms/HandleComponents/ComponentContext.php new file mode 100644 index 00000000..6ddcb744 --- /dev/null +++ b/dist/Mechanisms/HandleComponents/ComponentContext.php @@ -0,0 +1,118 @@ +effects = $effects instanceof Effects ? $effects : null ?? ObjectManager::getInstance()->create(Effects::class); + $this->memo = $memo instanceof Memo ? $memo : null ?? ObjectManager::getInstance()->create(Memo::class); + } + public function isMounting() + { + return $this->mounting; + } + public function addEffect($key, $value) + { + $this->getEffects()->setData($key, $value); + } + public function pushEffect($key, $value, $iKey = null) + { + $effects = $this->getEffects()->getData(); + if (!is_array($effects)) { + $effects = []; + } + if ($iKey) { + $effects[$key][$iKey] = $value; + } else { + $effects[$key][] = $value; + } + $this->getEffects()->setData($effects); + return $this; + } + public function addMemo($key, $value) + { + $this->getMemo()->setData($key, $value); + } + public function pushMemo($key, $value, $iKey = null) + { + $memo = $this->getMemo()->getData(); + if (!is_array($memo)) { + $memo = []; + } + if ($iKey) { + $memo[$key][$iKey] = $value; + } else { + $memo[$key][] = $value; + } + $this->getMemo()->setData($memo); + return $this; + } + public function setEffects(Effects $effects) + { + $this->effects = $effects; + return $this; + } + public function setMemo(Memo $memo) + { + $this->memo = $memo; + return $this; + } + public function getEffects(): Effects + { + return $this->effects; + } + public function getMemo(): Memo + { + return $this->memo; + } + public function getComponent(): Component + { + return $this->component; + } + public function getBlock(): AbstractBlock + { + return $this->block; + } + public function hasEffect($key, $iKey = null): bool + { + $has = $this->getEffects()->hasData($key); + if ($has && $iKey) { + $data = $this->getEffects()->getData($key); + if (is_array($data)) { + return isset($data[$iKey]); + } + return false; + } + return $has; + } + public function hasMemo($key, $iKey = null) + { + $has = $this->getMemo()->hasData($key); + if ($has && $iKey) { + $data = $this->getMemo()->getData($key); + if (is_array($data)) { + return isset($data[$iKey]); + } + return false; + } + return $has; + } +} \ No newline at end of file diff --git a/dist/Mechanisms/HandleComponents/HandleComponents.php b/dist/Mechanisms/HandleComponents/HandleComponents.php new file mode 100644 index 00000000..0c0bdd62 --- /dev/null +++ b/dist/Mechanisms/HandleComponents/HandleComponents.php @@ -0,0 +1,507 @@ +propertySynthesizers, $class); + } + } + public function mount($name, $params = [], $key = null, AbstractBlock|null $block = null, Component|null $component = null) + { + $parent = last(self::$componentStack); + if ($html = $this->shortCircuitMount($name, $params, $key, $parent)) { + $shortCircuitHtml = $html; + return function (AbstractBlock $block, string $html) use ($shortCircuitHtml) { + return [$block, $shortCircuitHtml]; + }; + } + if ($block === null) { + throw new InvalidArgumentException('Argument $block can not be null.'); + } + $context = $this->componentContextFactory->create(['block' => $block, 'component' => $component, 'mounting' => true]); + if (config('app.debug')) { + $start = microtime(true); + } + $finish = trigger('mount', $component, $params, $key, $parent); + if (config('app.debug')) { + trigger('profile', 'mount', $component->getId(), [$start ?? 0, microtime(true)]); + } + $this->pushOntoComponentStack($component); + $start = config('app.debug') ? microtime(true) : null; + /* + * This function is divided based on the original design due to the presence of both pre and post-render + * mechanisms within Magento. It serves as a form of middleware where the Magewire mechanism is embedded + * around it, instead of being directly responsible for rendering the component. The standard Block render + * mechanism within Magewire remains responsible for the actual rendering process and does return HTML. + */ + return function (AbstractBlock $block, string $html) use ($component, $context, $start, $finish) { + if (config('app.debug')) { + $start = microtime(true); + } + $html = $this->render($component, '
', $html); + if (config('app.debug')) { + trigger('profile', 'render', $component->getId(), [$start, microtime(true)]); + } + if (config('app.debug')) { + $start = microtime(true); + } + trigger('dehydrate', $component, $context); + $snapshot = $this->snapshot($component, $context); + if (config('app.debug')) { + trigger('profile', 'dehydrate', $component->getId(), [$start, microtime(true)]); + } + trigger('destroy', $component, $context); + $html = Utils::insertAttributesIntoHtmlRoot($html, ['wire:snapshot' => $snapshot, 'wire:effects' => $context->getEffects()->toArray()]); + $this->popOffComponentStack(); + return [$block, $finish($html, $snapshot)]; + }; + } + protected function shortCircuitMount($name, $params, $key, $parent) + { + $newHtml = null; + trigger('pre-mount', $name, $params, $key, $parent, function ($html) use (&$newHtml) { + $newHtml = $html; + }); + return $newHtml; + } + /** + * @throws FileSystemException + * @throws ComponentNotFoundException + * @throws MethodNotFoundException + * @throws RuntimeException + */ + public function update($snapshot, $updates, $calls, AbstractBlock|null $block = null) + { + if ($block === null) { + throw new InvalidArgumentException('Argument $block can not be of type null.'); + } + $data = $snapshot['data']; + $memo = $snapshot['memo']; + if (config('app.debug')) { + $start = microtime(true); + } + [$component, $context] = $this->fromSnapshot($snapshot, $block); + $this->pushOntoComponentStack($component); + trigger('hydrate', $component, $memo, $context); + $this->updateProperties($component, $updates, $data, $context); + if (config('app.debug')) { + trigger('profile', 'hydrate', $component->getId(), [$start ?? 0, microtime(true)]); + } + $this->callMethods($component, $calls, $context); + /* + * This function is divided based on the original design due to the presence of both pre and post-render + * mechanisms within Magento. It serves as a form of middleware where the Magewire mechanism is embedded + * around it, instead of being directly responsible for rendering the component. The standard Block render + * mechanism within Magewire remains responsible for the actual rendering process. + */ + return function (AbstractBlock $block, string $html) use ($snapshot, $context, $component) { + if (config('app.debug')) { + $start = microtime(true); + } + if ($html = $this->render($component, '', $html)) { + $context->addEffect('html', $html); + if (config('app.debug')) { + trigger('profile', 'render', $component->getId(), [$start ?? 0, microtime(true)]); + } + } + if (config('app.debug')) { + $start = microtime(true); + } + trigger('dehydrate', $component, $context); + $snapshot = $this->snapshot($component, $context); + if (config('app.debug')) { + trigger('profile', 'dehydrate', $component->getId(), [$start ?? 0, microtime(true)]); + } + trigger('destroy', $component, $context); + $this->popOffComponentStack(); + return [$snapshot, $context->effects]; + }; + } + /** + * @throws ComponentNotFoundException + * @throws FileSystemException + * @throws RuntimeException + * @throws Exception + */ + public function fromSnapshot($snapshot, AbstractBlock|null $block = null) + { + if (!$block instanceof AbstractBlock) { + throw new Exception(sprintf('Invalid block type: %s', is_object($block) ? get_class($block) : 'Unknown')); + } elseif (!$block->getData('magewire') instanceof Component) { + throw new ComponentNotFoundException(sprintf('Unable to find component: [%s]', $block->getNameInLayout())); + } + /** @var Component $component */ + $component = $block->getData('magewire'); + $context = $this->componentContextFactory->create(['component' => $component, 'block' => $block]); + $this->hydrateProperties($context->getComponent(), $snapshot['data'], $context); + return [$component, $context]; + } + public function snapshot($component, $context = null) + { + $context ??= $this->componentContextFactory->create(['component' => $component]); + $data = $this->dehydrateProperties($component, $context); + $snapshot = ['data' => $data, 'memo' => ['id' => $component->getId(), 'name' => $component->getName(), ...$context->getMemo()->toArray()]]; + $snapshot['checksum'] = $this->checksum->generate($snapshot); + return $snapshot; + } + protected function dehydrateProperties($component, $context) + { + $data = Utils::getPublicPropertiesDefinedOnSubclass($component); + foreach ($data as $key => $value) { + $data[$key] = $this->dehydrate($value, $context, $key); + } + return $data; + } + protected function dehydrate($target, $context, $path) + { + if (Utils::isAPrimitive($target)) { + return $target; + } + $synth = $this->propertySynth($target, $context, $path); + [$data, $meta] = $synth->dehydrate($target, function ($name, $child) use ($context, $path) { + return $this->dehydrate($child, $context, "{$path}.{$name}"); + }); + $meta['s'] = $synth::getKey(); + return [$data, $meta]; + } + protected function hydrateProperties($component, $data, $context) + { + foreach ($data as $key => $value) { + if (!property_exists($component, $key)) { + continue; + } + $child = $this->hydrate($value, $context, $key); + // Typed properties shouldn't be set back to "null". It will throw an error... + if ((new \ReflectionProperty($component, $key))->getType() && is_null($child)) { + continue; + } + $component->{$key} = $child; + } + } + protected function hydrate($valueOrTuple, $context, $path) + { + if (!Utils::isSyntheticTuple($value = $tuple = $valueOrTuple)) { + return $value; + } + [$value, $meta] = $tuple; + if ($this->isRemoval($value) && str($path)->contains('.')) { + return $value; + } + $synth = $this->propertySynth($meta['s'], $context, $path); + return $synth->hydrate($value, $meta, function ($name, $child) use ($context, $path) { + return $this->hydrate($child, $context, "{$path}.{$name}"); + }); + } + protected function hydratePropertyUpdate($valueOrTuple, $context, $path) + { + if (!Utils::isSyntheticTuple($value = $tuple = $valueOrTuple)) { + return $value; + } + [$value, $meta] = $tuple; + // Nested properties get set as `__rm__` when they are removed. We don't want to hydrate these. + if ($this->isRemoval($value) && str($path)->contains('.')) { + return $value; + } + $synth = $this->propertySynth($meta['s'], $context, $path); + return $synth->hydrate($value, $meta, function ($name, $child) { + return $child; + }); + } + protected function render($component, $default = null, string|null $html = null) + { + $replace = store($component)->get('skipRender', false); + if ($replace) { + $replace = value(is_string($html) ? $html : $default); + if (!$replace) { + return ''; + } + return Utils::insertAttributesIntoHtmlRoot($html, ['wire:id' => $component->getId()]); + } + [$block, $properties] = $this->getView($component); + return $this->trackInRenderStack($component, function () use ($component, $block, $properties, $html) { + $finish = trigger('render', $component, $block, $properties); + $html = Utils::insertAttributesIntoHtmlRoot($html, ['wire:id' => $component->getId()]); + return $finish($html, function ($newHtml) use (&$html) { + $html = $newHtml; + }, $component->block()); + }); + } + protected function getView($component) + { + // WIP + // if (method_exists($component, 'render')) { + // $block = wrap($component)->render(); + // + // if ($block instanceof AbstractBlock) { + // $block->setData($component->getBlock()->getData()); + // $block->setNameInLayout($component->getBlock()->getNameInLayout()); + // + // $component->getResolver()->construct($block); + // } + // } + return [$component->block(), Utils::getPublicPropertiesDefinedOnSubclass($component)]; + } + protected function trackInRenderStack($component, $callback) + { + static::$renderStack[] = $component; + return tap($callback(), function () { + array_pop(static::$renderStack); + }); + } + protected function updateProperties($component, $updates, $data, $context) + { + $finishes = []; + foreach ($updates as $path => $value) { + $value = $this->hydrateForUpdate($data, $path, $value, $context); + // We only want to run "updated" hooks after all properties have + // been updated so that each individual hook has the ability + // to overwrite the updated states of other properties... + $finishes[] = $this->updateProperty($component, $path, $value, $context); + } + foreach ($finishes as $finish) { + $finish(); + } + } + public function updateProperty($component, $path, $value, $context) + { + $segments = explode('.', $path); + $property = array_shift($segments); + $finish = trigger('update', $component, $path, $value); + // Ensure that it's a public property, not on the base class first... + if (!in_array($property, array_keys(Utils::getPublicPropertiesDefinedOnSubclass($component)))) { + throw new PublicPropertyNotFoundException($property, $component->getName()); + } + // If this isn't a "deep" set, set it directly, otherwise we have to + // recursively get up and set down the value through the synths... + if (empty($segments)) { + $this->setComponentPropertyAwareOfTypes($component, $property, $value); + } else { + $propertyValue = $component->{$property}; + $this->setComponentPropertyAwareOfTypes($component, $property, $this->recursivelySetValue($property, $propertyValue, $value, $segments, 0, $context)); + } + return $finish; + } + protected function hydrateForUpdate($raw, $path, $value, $context) + { + $meta = $this->getMetaForPath($raw, $path); + // If we have meta data already for this property, let's use that to get a synth... + if ($meta) { + return $this->hydratePropertyUpdate([$value, $meta], $context, $path, $raw); + } + // If we don't, let's check to see if it's a typed property and fetch the synth that way... + $parent = str($path)->contains('.') ? data_get($context->component, str($path)->beforeLast('.')->toString()) : $context->component; + $childKey = str($path)->afterLast('.'); + if ($parent && is_object($parent) && property_exists($parent, $childKey) && Utils::propertyIsTyped($parent, $childKey)) { + $type = Utils::getProperty($parent, $childKey)->getType(); + $types = $type instanceof ReflectionUnionType ? $type->getTypes() : [$type]; + foreach ($types as $type) { + $synth = $this->getSynthesizerByType($type->getName(), $context, $path); + if ($synth) { + return $synth->hydrateFromType($type->getName(), $value); + } + } + } + return $value; + } + protected function getMetaForPath($raw, $path) + { + $segments = explode('.', $path); + $first = array_shift($segments); + [$data, $meta] = Utils::isSyntheticTuple($raw) ? $raw : [$raw, null]; + if ($path !== '') { + $value = $data[$first] ?? null; + return $this->getMetaForPath($value, implode('.', $segments)); + } + return $meta; + } + protected function recursivelySetValue($baseProperty, $target, $leafValue, $segments, $index = 0, $context = null) + { + $isLastSegment = count($segments) === $index + 1; + $property = $segments[$index]; + $path = implode('.', array_slice($segments, 0, $index + 1)); + $synth = $this->propertySynth($target, $context, $path); + if ($isLastSegment) { + $toSet = $leafValue; + } else { + $propertyTarget = $synth->get($target, $property); + // "$path" is a dot-notated key. This means we may need to drill + // down and set a value on a deeply nested object. That object + // may not exist, so let's find the first one that does... + // Here's we've determined we're trying to set a deeply nested + // value on an object/array that doesn't exist, so we need + // to build up that non-existant nesting structure first. + if ($propertyTarget === null) { + $propertyTarget = []; + } + $toSet = $this->recursivelySetValue($baseProperty, $propertyTarget, $leafValue, $segments, $index + 1, $context); + } + $method = $this->isRemoval($leafValue) && $isLastSegment ? 'unset' : 'set'; + $pathThusFar = collect([$baseProperty, ...$segments])->slice(0, $index + 1)->join('.'); + $fullPath = collect([$baseProperty, ...$segments])->join('.'); + $synth->{$method}($target, $property, $toSet, $pathThusFar, $fullPath); + return $target; + } + protected function setComponentPropertyAwareOfTypes($component, $property, $value) + { + try { + $component->{$property} = $value; + } catch (\TypeError $e) { + // If an "int" is being set to empty string, unset the property (making it null). + // This is common in the case of `wire:model`ing an int to a text field... + // If a value is being set to "null", do the same... + if ($value === '' || $value === null) { + unset($component->{$property}); + } else { + throw $e; + } + } + } + /** + * @throws MethodNotFoundException + */ + protected function callMethods($root, $calls, $context) + { + $returns = []; + foreach ($calls as $idx => $call) { + $method = $call['method']; + $params = $call['params']; + $earlyReturnCalled = false; + $earlyReturn = null; + $returnEarly = function ($return = null) use (&$earlyReturnCalled, &$earlyReturn) { + $earlyReturnCalled = true; + $earlyReturn = $return; + }; + $finish = trigger('call', $root, $method, $params, $context, $returnEarly); + if ($earlyReturnCalled) { + $returns[] = $finish($earlyReturn); + continue; + } + $methods = Utils::getPublicMethodsDefinedBySubClass($root); + // Also remove "render" from the list... + $methods = array_values(array_diff($methods, ['render'])); + // @todo: put this in a better place: + $methods[] = '__dispatch'; + if (!in_array($method, $methods)) { + throw new MethodNotFoundException($method); + } + if (config('app.debug')) { + $start = microtime(true); + } + $return = wrap($root)->{$method}(...$params); + if (config('app.debug')) { + trigger('profile', 'call' . $idx, $root->getId(), [$start, microtime(true)]); + } + $returns[] = $finish($return); + } + $context->addEffect('returns', $returns); + } + public function findSynth($keyOrTarget, $component): ?Synth + { + $context = new ComponentContext($component); + try { + return $this->propertySynth($keyOrTarget, $context, null); + } catch (\Exception $e) { + return null; + } + } + public function propertySynth($keyOrTarget, $context, $path): Synth + { + return is_string($keyOrTarget) ? $this->getSynthesizerByKey($keyOrTarget, $context, $path) : $this->getSynthesizerByTarget($keyOrTarget, $context, $path); + } + /** + * @throws Exception + */ + protected function getSynthesizerByKey($key, $context, $path) + { + foreach ($this->propertySynthesizers as $synth) { + if ($synth::getKey() === $key) { + return ObjectManager::getInstance()->create($synth, ['context' => $context, 'path' => $path]); + } + } + throw new Exception('No synthesizer found for key: "' . $key . '"'); + } + /** + * @throws Exception + */ + protected function getSynthesizerByTarget($target, $context, $path) + { + foreach ($this->propertySynthesizers as $synth) { + if ($synth::match($target)) { + return ObjectManager::getInstance()->create($synth, ['context' => $context, 'path' => $path]); + } + } + throw new Exception('Property type not supported in Magewire for property: [' . json_encode($target) . ']'); + } + protected function getSynthesizerByType($type, $context, $path) + { + foreach ($this->propertySynthesizers as $synth) { + if ($synth::matchByType($type)) { + return new $synth($context, $path); + } + } + return null; + } + protected function pushOntoComponentStack($component) + { + array_push($this::$componentStack, $component); + } + protected function popOffComponentStack() + { + array_pop($this::$componentStack); + } + protected function isRemoval($value) + { + return $value === '__rm__'; + } + public function __construct(private readonly ComponentContextFactory $componentContextFactory, private readonly Checksum $checksum, protected array $synthesizers = []) + { + // Improve modularity by injecting property synthesizers via dependency injection. + $this->propertySynthesizers = $synthesizers; + } + public static function provide() + { + on('pre-mount', function ($target, $view) { + return function ($html) use ($view, $target) { + if (method_exists($target, 'render')) { + return $target->render(); + } + return $html; + }; + }); + } +} \ No newline at end of file diff --git a/dist/Mechanisms/HandleComponents/Synthesizers/ArraySynth.php b/dist/Mechanisms/HandleComponents/Synthesizers/ArraySynth.php new file mode 100644 index 00000000..3f963151 --- /dev/null +++ b/dist/Mechanisms/HandleComponents/Synthesizers/ArraySynth.php @@ -0,0 +1,48 @@ + $child) { + $target[$key] = $dehydrateChild($key, $child); + } + return [$target, []]; + } + function hydrate($value, $meta, $hydrateChild) + { + // If we are "hydrating" a value about to be used in an update, + // Let's make sure it's actually an array before try to set it. + // This is most common in the case of "__rm__" values, but also + // applies to any non-array value... + if (!is_array($value)) { + return $value; + } + foreach ($value as $key => $child) { + $value[$key] = $hydrateChild($key, $child); + } + return $value; + } + function set(&$target, $key, $value) + { + $target[$key] = $value; + } + function unset(&$target, $key) + { + unset($target[$key]); + } +} \ No newline at end of file diff --git a/dist/Mechanisms/HandleComponents/Synthesizers/EnumSynth.php b/dist/Mechanisms/HandleComponents/Synthesizers/EnumSynth.php new file mode 100644 index 00000000..c67dbfed --- /dev/null +++ b/dist/Mechanisms/HandleComponents/Synthesizers/EnumSynth.php @@ -0,0 +1,42 @@ +value, ['class' => get_class($target)]]; + } + function hydrate($value, $meta) + { + if ($value === null || $value === '') { + return null; + } + $class = $meta['class']; + return $class::from($value); + } +} \ No newline at end of file diff --git a/dist/Mechanisms/HandleComponents/Synthesizers/FloatSynth.php b/dist/Mechanisms/HandleComponents/Synthesizers/FloatSynth.php new file mode 100644 index 00000000..cc2bf729 --- /dev/null +++ b/dist/Mechanisms/HandleComponents/Synthesizers/FloatSynth.php @@ -0,0 +1,34 @@ + $child) { + $data[$key] = $dehydrateChild($key, $child); + } + return [$data, []]; + } + function hydrate($value, $meta, $hydrateChild) + { + $obj = new stdClass(); + foreach ($value as $key => $child) { + $obj->{$key} = $hydrateChild($key, $child); + } + return $obj; + } + function set(&$target, $key, $value) + { + $target->{$key} = $value; + } +} \ No newline at end of file diff --git a/dist/Mechanisms/HandleComponents/Synthesizers/Synth.php b/dist/Mechanisms/HandleComponents/Synthesizers/Synth.php new file mode 100644 index 00000000..36e1b5aa --- /dev/null +++ b/dist/Mechanisms/HandleComponents/Synthesizers/Synth.php @@ -0,0 +1,59 @@ +{$key}; + } + function __call($method, $params) + { + if ($method === 'dehydrate') { + throw new \Exception('You must define a "dehydrate" method'); + } + if ($method === 'hydrate') { + throw new \Exception('You must define a "hydrate" method'); + } + if ($method === 'hydrateFromType') { + throw new \Exception('You must define a "hydrateFromType" method'); + } + if ($method === 'get') { + throw new \Exception('This synth doesn\'t support getting properties: ' . get_class($this)); + } + if ($method === 'set') { + throw new \Exception('This synth doesn\'t support setting properties: ' . get_class($this)); + } + if ($method === 'unset') { + throw new \Exception('This synth doesn\'t support unsetting properties: ' . get_class($this)); + } + if ($method === 'call') { + throw new \Exception('This synth doesn\'t support calling methods: ' . get_class($this)); + } + } +} \ No newline at end of file diff --git a/dist/Mechanisms/HandleRequests/HandleRequests.php b/dist/Mechanisms/HandleRequests/HandleRequests.php new file mode 100644 index 00000000..94b3d04e --- /dev/null +++ b/dist/Mechanisms/HandleRequests/HandleRequests.php @@ -0,0 +1,150 @@ +findUpdateRoute() !== null; + } + function getUpdateUri() + { + // When routes are cached, $this->updateRoute may be null because + // setUpdateRoute() was never called (the route already existed). + // In this case, find the route from the router. + $route = $this->updateRoute ?? $this->findUpdateRoute(); + return (string) str(route($route->getName(), [], false))->start('/'); + } + protected function findUpdateRoute() + { + // Find the route with name ending in 'livewire.update'. + // Custom routes can have prefixes (e.g., 'tenant.livewire.update') + // so we check for routes ending with 'livewire.update', not just exact matches. + // Prioritise custom routes over the default route. + $defaultRoute = null; + foreach (Route::getRoutes()->getRoutes() as $route) { + if (str($route->getName())->endsWith('livewire.update')) { + // If it's the default route, save it but keep looking for a custom one + if ($route->getName() === 'default.livewire.update') { + $defaultRoute = $route; + continue; + } + // Found a custom route, return it immediately + return $route; + } + } + return $defaultRoute; + } + function skipRequestPayloadTamperingMiddleware() + { + \Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::skipWhen(function () { + return $this->isLivewireRequest(); + }); + \Illuminate\Foundation\Http\Middleware\TrimStrings::skipWhen(function () { + return $this->isLivewireRequest(); + }); + } + function setUpdateRoute($callback) + { + $route = $callback([self::class, 'handleUpdate']); + // Append `livewire.update` to the existing name, if any. + if (!str($route->getName())->endsWith('livewire.update')) { + $route->name('livewire.update'); + } + $this->updateRoute = $route; + } + public function isLivewireRequest() + { + return $this->isMagewireRequest(); + } + function isLivewireRoute() + { + // @todo: Rename this back to `isLivewireRequest` once the need for it in tests has been fixed. + $route = request()->route(); + if (!$route) { + return false; + } + /* + * Check to see if route name ends with `livewire.update`, as if + * a custom update route is used and they add a name, then when + * we call `->name('livewire.update')` on the route it will + * suffix the existing name with `livewire.update`. + */ + return $route->named('*livewire.update'); + } + /** + * @return MagewireUpdateResult|mixed|null + * @throws ComponentNotFoundException + * @throws NoSuchEntityException + */ + public function handleUpdate() + { + /** @var ComponentRequestContext[] $updates */ + $requestPayload = $this->request->getParam('components'); + $finish = trigger('request', $requestPayload); + $requestPayload = $finish($requestPayload); + $componentResponses = []; + foreach ($requestPayload as $componentPayload) { + $reconstruct = trigger('magewire:component:reconstruct', $componentPayload); + $block = $reconstruct(); + $component = $block->getData('magewire'); + if (!$component instanceof Component) { + throw new ComponentNotFoundException('Something went wrong during block reconstruction'); + } + /* + * Marks the component to indicate that it is being updated, distinguishing it from a preceding page load + * or refresh. This notification is crucial for informing other systems about the context of the operation. + */ + store($component)->set('magewire:update', $componentPayload); + /* + * When the 'toHtml' method is invoked on any block with the 'magewire' argument, it initiates the + * rendering lifecycle. During initial (in other words: preceding) page renders, this process is + * automatically managed by the framework. However, on subsequent requests, it becomes necessary to + * manually trigger this lifecycle for the targeted block. + */ + [$snapshot, $effects] = $this->magewireManager->render($block, $block->toHtml()); + $componentResponses[] = ['effects' => $effects->toArray(), 'snapshot' => $this->serializer->serialize($snapshot)]; + } + $responsePayload = ['components' => $componentResponses ?? [], 'assets' => []]; + $finish = trigger('response', $responsePayload); + return $finish($responsePayload); + } + public function __construct(private readonly Http $request, private readonly MagewireManager $magewireManager, private readonly SerializerInterface $serializer, private readonly MagewireServiceProvider $magewireServiceProvider) + { + // + } + public function isMagewireRequest() + { + return $this->magewireServiceProvider->runtime()->mode()->isSubsequent(); + } + public function request(): Http + { + return $this->request; + } +} \ No newline at end of file diff --git a/dist/Mechanisms/Mechanism.php b/dist/Mechanisms/Mechanism.php new file mode 100644 index 00000000..a63fe663 --- /dev/null +++ b/dist/Mechanisms/Mechanism.php @@ -0,0 +1,22 @@ +instance(static::class, $this); + } + function boot() + { + // + } +} \ No newline at end of file diff --git a/dist/Mechanisms/PersistentMiddleware/PersistentMiddleware.php b/dist/Mechanisms/PersistentMiddleware/PersistentMiddleware.php new file mode 100644 index 00000000..5bd48298 --- /dev/null +++ b/dist/Mechanisms/PersistentMiddleware/PersistentMiddleware.php @@ -0,0 +1,146 @@ +extractPathAndMethodFromRequest(); + $context->addMemo('path', $path); + // Although it's a POST request (stored in $method), Livewire still requires a GET value. + $context->addMemo('method', 'GET'); + }); + on('flush-state', function () { + // Only flush these at the end of a full request, so that child components have access to this data. + $this->path = null; + $this->method = null; + }); + } + function addPersistentMiddleware($middleware) + { + static::$persistentMiddleware = Router::uniqueMiddleware(array_merge(static::$persistentMiddleware, (array) $middleware)); + } + function setPersistentMiddleware($middleware) + { + static::$persistentMiddleware = Router::uniqueMiddleware((array) $middleware); + } + function getPersistentMiddleware() + { + return static::$persistentMiddleware; + } + protected function extractPathAndMethodFromRequest() + { + return [$this->request->getBasePath(), $this->request->getMethod()]; + } + protected function extractPathAndMethodFromSnapshot($snapshot) + { + if (!isset($snapshot['memo']['path']) || !isset($snapshot['memo']['method'])) { + return; + } + // Store these locally, so dynamically added child components can use this data. + $this->path = $snapshot['memo']['path']; + $this->method = $snapshot['memo']['method']; + } + protected function applyPersistentMiddleware() + { + $request = $this->makeFakeRequest(); + $middleware = $this->getApplicablePersistentMiddleware($request); + // Only send through pipeline if there are middleware found + if (is_null($middleware)) { + return; + } + Utils::applyMiddleware($request, $middleware); + } + protected function makeFakeRequest() + { + $originalPath = $this->formatPath($this->path); + $originalMethod = $this->method; + $currentPath = $this->formatPath(request()->path()); + // Clone server bag to ensure changes below don't overwrite the original. + $serverBag = clone request()->server; + // Replace the Livewire endpoint path with the path from the original request. + $serverBag->set('REQUEST_URI', str_replace($currentPath, $originalPath, $serverBag->get('REQUEST_URI'))); + $serverBag->set('REQUEST_METHOD', $originalMethod); + /** + * Make the fake request from the current request with path and method changed so + * all other request data, such as headers, are available in the fake request, + * but merge in the new server bag with the updated `REQUEST_URI`. + */ + $request = request()->duplicate(server: $serverBag->all()); + return $request; + } + protected function formatPath($path) + { + return '/' . ltrim($path, '/'); + } + protected function getApplicablePersistentMiddleware($request) + { + $route = $this->getRouteFromRequest($request); + if (!$route) { + return []; + } + $middleware = app('router')->gatherRouteMiddleware($route); + return $this->filterMiddlewareByPersistentMiddleware($middleware); + } + protected function getRouteFromRequest($request) + { + try { + $route = app('router')->getRoutes()->match($request); + $request->setRouteResolver(fn() => $route); + } catch (NotFoundHttpException $e) { + return null; + } + return $route; + } + protected function filterMiddlewareByPersistentMiddleware($middleware) + { + $middleware = collect($middleware); + $persistentMiddleware = collect(app(PersistentMiddleware::class)->getPersistentMiddleware()); + return $middleware->filter(function ($value, $key) use ($persistentMiddleware) { + return $persistentMiddleware->contains(function ($iValue, $iKey) use ($value) { + // Some middlewares can be closures. + if (!is_string($value)) { + return false; + } + // Ensure any middleware arguments aren't included in the comparison + return Str::before($value, ':') == $iValue; + }); + })->values()->all(); + } + function __construct(private readonly Request $request) + { + // + } +} \ No newline at end of file diff --git a/dist/Pipe.php b/dist/Pipe.php new file mode 100644 index 00000000..f8d46c48 --- /dev/null +++ b/dist/Pipe.php @@ -0,0 +1,40 @@ +target = $target; + } + function __invoke(...$params) + { + if (empty($params)) { + return $this->target; + } + [$before, $through, $after] = [[], null, []]; + foreach ($params as $key => $param) { + if (!$through) { + if (is_callable($param)) { + $through = $param; + } else { + $before[$key] = $param; + } + } else { + $after[$key] = $param; + } + } + $params = [...$before, $this->target, ...$after]; + $this->target = $through(...$params); + return $this; + } +} \ No newline at end of file diff --git a/dist/Transparency.php b/dist/Transparency.php new file mode 100644 index 00000000..77be5485 --- /dev/null +++ b/dist/Transparency.php @@ -0,0 +1,44 @@ +target; + } + function offsetExists(mixed $offset): bool + { + return isset($this->target[$offset]); + } + function offsetGet(mixed $offset): mixed + { + return $this->target[$offset]; + } + function offsetSet(mixed $offset, mixed $value): void + { + $this->target[$offset] = $value; + } + function offsetUnset(mixed $offset): void + { + unset($this->target[$offset]); + } + function getIterator(): Traversable + { + return (function () { + foreach ($this->target as $key => $value) { + yield $key => $value; + } + })(); + } +} \ No newline at end of file diff --git a/dist/Wrapped.php b/dist/Wrapped.php new file mode 100644 index 00000000..319c6e26 --- /dev/null +++ b/dist/Wrapped.php @@ -0,0 +1,47 @@ +fallback = $fallback; + return $this; + } + function __call($method, $params) + { + if (!method_exists($this->target, $method)) { + return value($this->fallback); + } + try { + $arguments = $this->methodsMap->getMethodParams($this->target::class, $method); + if (count($params) !== 0 && isset($params[0])) { + $params = array_combine(array_column($arguments, 'name'), array_intersect_key($params, $arguments)); + } + // Remove parameters that are not required by the method. + $params = array_intersect_key($params, array_flip(array_column($arguments, 'name'))); + return $this->target->{$method}(...$params); + } catch (\Throwable $e) { + $shouldPropagate = true; + $stopPropagation = function () use (&$shouldPropagate) { + $shouldPropagate = false; + }; + trigger('exception', $this->target, $e, $stopPropagation); + $shouldPropagate && throw $e; + } + } +} \ No newline at end of file diff --git a/docs/Compatibility.md b/docs/Compatibility.md deleted file mode 100644 index 11325565..00000000 --- a/docs/Compatibility.md +++ /dev/null @@ -1,38 +0,0 @@ -# Magewire - Compatibility -> Please keep in mind that Magewire is currently in a Beta-phase. Therefore not all architectural choices are set in -> concrete. So make sure you are aware of the risks of building on top of the platform in it's current state. - -Magewire is **not** compatible with any specific Laravel Livewire backend version. The Livewire JS package does have a -matching version as this is a identical copy of it's origin. The goal is to make as much practible front- & backend -features compatible. - -## Magento - 2.3.x -As it's core, Magewire is build primarily for Magento 2.4.x to get rid of the deprated App\Action controller extend. -I've gave it a headspin to still be able to use Magewire in Magento 2.3.x. I call it the "vintage" concept of handling -HTTP subsequent requests. Therefore 2.3.x version will make Magewire HTTP requests to the ```Vintage``` instead of the -default ```Post``` controller. Just install the extension, no extra configuration required. - -``` -composer require magewirephp/magewire -``` - -## Magento - RequireJS -Magewire by default is meant as a Hyva Themes first extension. This means it will only work on Hyva based themes out of -the box and **will not** work on e.g. Luma or Blank based Magento 2 themes (RequireJS dependend themes). - -Because most developers want to work with a more modern and fun tech-stack doesn't mean we forgot all those die-hards -who still work with the original Magento frontend. Magewire is made compatible via a so-called compatibility -extension. - -Simply install the original Magewire extension and require the compatibility extension alongside. You should be good to -go after you're done with your default workflow when installed a new extension. - -``` -composer require magewirephp/magewire-requirejs -``` - -## Magento - Custom XHR requests -You can walk into a situations where you load HTML via a custom XHR request where the layout gets loaded, the block HTML -gets rendered and returned with a child block _(A)_ inside of it. When this child _(B)_ block is wired, it will use the -'magewire_post_livewire' handle for it's subsequent requests. Therefore it's required to define the child (B) block -inside the 'hyva_default.xml' (Hyvä Themes theme) or 'default.xml' (Luma/Blank theme) layout. diff --git a/docs/Component.md b/docs/Component.md deleted file mode 100644 index e369dbdf..00000000 --- a/docs/Component.md +++ /dev/null @@ -1,86 +0,0 @@ -# Magewire - Component -> Please keep in mind that Magewire is currently in a Beta-phase. Therefore not all architectural choices are set in -> concrete. So make sure you are aware of the risks of building on top of the platform in it's current state. - -## Making components - -**My/Module/Magewire/Explanation.php** -```php -getMagewire(); - -?> -
- hasFoo()): ?> - getFoo() ?> - -
-``` -You will have $magewire at your disposal as long as you're inside a Magewire component. You're able to validate if a public property is set with the ```has``` prefix. Use a ```get``` prefix to get a public property value. - -## Layout -The idea behind Magewire is that, like Hyva, it uses the strengths of the Magento layout structure. A block can be converted to a Magewire component in an instant just by assigning a Magewire component class. It's the exact same concept as done with ViewModels. - -### Register components -You're not obliged to set a template if the block has a Magewire component. When it's parent block doesn't have a template, Magewire will set the templates based on the component class. - -```xml - - - - - - - \My\Module\Magewire\Explanation - - - - - -``` - -### Data -Public properties can be set on page load by an optional ```mount()``` method or via the layout XML. When you want to set it via the layout, you have to use a specific structure. - -```xml - - - - - - - - \My\Module\Magewire\Explanation - bar - - - - - - -``` -**Note**: The public ```$foo``` property will be set if it exists inside you component. diff --git a/docs/Component/Form.md b/docs/Component/Form.md deleted file mode 100644 index 2c3fa25f..00000000 --- a/docs/Component/Form.md +++ /dev/null @@ -1,6 +0,0 @@ -# Magewire - Form Component -> Please keep in mind that Magewire is currently in a Beta-phase. Therefore not all architectural choices are set in -> concrete. So make sure you are aware of the risks of building on top of the platform in it's current state. - -## Concept -Soon more... diff --git a/docs/Component/Pagination.md b/docs/Component/Pagination.md deleted file mode 100644 index b52abf8b..00000000 --- a/docs/Component/Pagination.md +++ /dev/null @@ -1,8 +0,0 @@ -# Magewire - Pagination Component -> Please keep in mind that Magewire is currently in a Beta-phase. Therefore not all architectural choices are set in -> concrete. So make sure you are aware of the risks of building on top of the platform in it's current state. - -## Concept -Pagination in its current state is designed to give developers complete freedom on how data is retrieved and served. -A number of obligations are established through the Pagination interface. Its possible there will be an extension of -this concept in the future that is the roughest of the implementation, taking into account e.g. the use of repositories. diff --git a/docs/Features.md b/docs/Features.md deleted file mode 100644 index 37f102fa..00000000 --- a/docs/Features.md +++ /dev/null @@ -1,801 +0,0 @@ -# Magewire - Features -> Please keep in mind that Magewire is currently in a Beta-phase. Therefore not all architectural choices are set in -> concrete. So make sure you are aware of the risks of building on top of the platform in it's current state. - -## Best Practices -1. Use the Magewire naming conventions and structures. -2. Use Hydrators to manipulate data before or after a method gets called. -3. Contribute or create an issue when you found bugs or sucurity issues. -4. Keep components small and clean -5. Use the ```My/Module/Magewire/Component``` folder when you introduce a new component type. - -## Folder Structure -| Folder | Description | -|--------|----------------------------------------------------------------------------------------------------| -| /src | Root folder inside your module | -| /src/Magewire | All Magewire components live here (subfolder allowed) | -| /src/view/frontend/templates/magewire | All Magewire component template files live here (subfolder allowed) | - -## JavaScript -The ```Magewire``` (alias for ```Livewire```) object is globally available on the page after DomContentLoaded. More -information can be found inside the [Livewire docs](https://laravel-livewire.com/docs/2.x/reference#global-livewire-js). - -### Document Events -- magewire:load -- magewire:update -- magewire:available -- magewire:loader:start ```(event) => {}``` -- magewire:loader:stop ```(event) => {}``` - -### Lifecycle Hooks -[Read all about hooks](https://laravel-livewire.com/docs/2.x/reference#js-hooks) - -## Block Structure -```xml - - - \My\Module\Magewire\Explanation - - - - - - - - - \My\Module\Magewire\Explanation - - - - - - - - - \My\Module\Magewire\Explanation - - -``` - -## Templates -Options within your block template. -```php -getMagewire(); - -// Check if property exists -$magewire->hasFoo(); -// Get property value -$magewire->getFoo(); -// Call a custom method inside your component -$magewire->myCustomMethod(); -// Overwrite a property after the (optional) mount method was executed -$magewire->assign('foo', 'barbar'); -?> -``` - -### Switch Template -Switch to another template during a subsequent request. -```html -
- -
-``` - -```php -public function login() -{ - $this->switchTemplate('My_Module::customer/account/dashboard.phtml'); -} -``` -> **Tip**: Use the power of the layout xml to assign a "switch" template path as a data param assigned to the component. -> This way your component becomes more dynamic and extensible for other developers. - -### Skip Rendering -Set some data but prevent a frontend DOM replacement while on a subsequent request. -```php -public function setSomeProperties(string $value = 'bar') -{ - // Will set the data but will return null as effects html value. - $this->assign('foo', $value)->skipRender(); -} -``` - -## Component Types -The base idea behind de default component is to keep things as simple and clean as possible without any constructor -dependencies. Therefore I've decided to create multiple component to inherit from, who give you the option to use stuff -like for instance property validation. - -```php -class Explanation extends Magewirephp\Magewire\Component {} -// OR -class Explanation extends \Magewirephp\Magewire\Component\Form {} -// OR -class Explanation extends \Magewirephp\Magewire\Component\Pagination {} -``` - -## Magic Actions & Properties -Toggle, set or emit without writing any PHP. -```html - - - - - - - - - - -``` -## Flash Messages -Show a flash message on the page without a reload. -```php -public function myCustomMessageMethod(string $message) -{ - $this->dispatchErrorMessage($message); - $this->dispatchWarningMessage($message); - $this->dispatchNoticeMessage($message); - $this->dispatchSuccessMessage($message); -} -``` -> **Translations**: Messages will automatically be transformed into a translatable phrase. - -## Redirects -Redirect your customer with or without additional parameters. -```php -public function myCustomRedirectMethod() -{ - $this->redirect('/some/custom/path'); -} -``` - -```html - - - - -
- Thanks for your purchase! You will be redirected after 5 seconds. -
-``` - -## Listeners & Emits -Emit functionality in targeted or non-targeted components based on event listeners. -```php -/** - * Component A. - * @block my.custom.block.name - */ -class A extends \Magewirephp\Magewire\Component -{ - protected $listeners = ['youCanCallMe']; - public function youCanCallMe($value) {} - - // OR - - protected $listeners = ['youCanCallMe' => 'toDoSomething']; - public function toDoSomething($value) {} -} - -/** - * Component B. - */ -class B extends \Magewirephp\Magewire\Component -{ - public function letsCallSomeone() - { - // Emit to every component who listens to 'youCanCallMe'. - $this->emit('youCanCallMe', ['value' => 'hi there']); - // Emit only to the 'my.custom.block.name' component. - $this->emitTo('my.custom.block.name', 'youCanCallMe', ['value' => 'hi there']); - } -} -``` - -### JavaScript -```js -// Emit to every component who listens to 'youCanCallMe'. -Magewire.emit('youCanCallMe', {value: 'hi there'}) -// Emit only to the 'my.custom.block.name' component. -Magewire.emitTo('my.custom.block.name', 'youCanCallMe', {value: 'hi there'}) -``` - -### Magic Actions Compatibility -You are able to use magic methods within your emits if this is required. Thanks to this feature you are able to for example refresh a component or set data without having to write this functionality inside your targeted component. -```php -/** - * Component A. - * @block my.custom.block.name - */ -class A extends \Magewirephp\Magewire\Component -{ - public $stringProperty; - public $boolProperty = false; - - protected $listeners = ['$refresh']; // OR ['myEventName' => '$refresh'] - // OR - protected $listeners = ['$set']; // OR ['myEventName' => '$set'] - // OR - protected $listeners = ['$toggle']; // OR ['myEventName' => '$toggle'] -} - -/** - * Component B. - */ -class B extends \Magewirephp\Magewire\Component -{ - public function setSomeProperties() - { - // Force refresh for a separate component who is listening. - $this->emit('$refresh', []); - // Set a public property value for a separate component who is listening. - $this->emitTo('layout.block.name', '$set', ['stringProperty', 'hello world']); - // Toggle a property value for a separate component who is listening. - $this->emitTo('layout.block.name', '$toggle', ['boolProperty']); - } -} -``` -> **Note**: Emits only work during subsequent requests. They won't be dispatched on page load when you emit them -> in for example the ```mount()``` method. Use ```wire:init``` to dispatch a method on page load where an emit could -> take place. - -### Global Refresh Listener -Each Magewire component has by default no ```$listeners``` attached to itself. Still, you're able to refresh a -component from within another component thanks to a global ```refresh``` listener who get's injected during a preceding -request. - -Thanks to this global listener, Magewire introduces the emitToRefresh method. This gives the option to refresh any -component on the page from within your own component. -```php -public function refreshSomeOtherComponent() -{ - $this->emitToRefresh('layout.block.name'); -} -``` -OR -```html - -hasMyContent()): echo $magewire->getMyContent(); endif; ?> -``` - -## Lazy Updating -Prevent sending out requests for every press of a button. -```php -class Explanation extends \Magewirephp\Magewire\Component -{ - public $lazyProperty; -} -``` - -```html - -``` - -## Keydown Modifiers -Perform actions on keydown -```php -public function keyUp() -{ - $this->assign('random', random_int(100, 999)); -} - -public function keyDown() -{ - $this->assign('random', random_int(10, 99)); -} -``` - -```html - -``` -> You can also use vanilla JS instead of a PHP class method. -> -> **Quick List** -> - backspace -> - escape -> - shift -> - tab -> - arrow- right / left / up / down - -## Restricted Public Methods -Public methods can be restricted from subsequent request executions. Prevent method executions who are meant for -inside the phtml template only. It's not a best practice and you should use a ViewModel in most cases. - -### Global -```xml - - - - myCustomSetMethod - - - -``` -**File**: etc/frontend/di.xml - -### Component -```php -class Explanation extends \Magewirephp\Magewire\Component -{ - private $uncallables = ['myCustomSetMethod']; -} -``` - -## Pagination -Render a pagination pager inside your Component's view. -```php -class Explanation extends \Magewirephp\Magewire\Component\Pagination -{ - public $page = 1; - public $pageSize = 20; -} -``` - -```html -
- renderPagination() ?> - - renderPagination('My_Module::html/pagination/custom_pager') ?> -
-``` - -# Error Handling -An error will automatically bind a Phrase with it property name as key. Every single property can have one error per -round-trip. The build in validation also uses the Component's errors bag on failure. -```php -class MySearchForm extends \Magewirephp\Magewire\Component\Form -{ - public $searchText; - - protected $rules = [ - 'searchText' => 'required|min:6' - ]; - - public function search() - { - // Won't continue on failure. - $this->validate(); - - $this->someSearchProvider->search($this->getSearchText()); - - // OR - - if (strlen($this->getSearchText()) < 6) { - $this->error('searchText', 'Minimal length is 6'); - } - } -} -``` -Render all errors on top as a list. -```html -
- hasErrors()): ?> -
    - getErrors() as $error): ?> -
  • - -
- - -
- -
-
-``` - -Render each error underneath a specific input field. -```html -
-
- - hasError('searchText')): ?> -

getError('searchText') ?>

- -
-
-``` - -## Query String -> **Note**: The query string feature is currently incomplete compared to the original Laravel Livewire implementation. -> At the moment you can only set public properties values via the URL when loading a page. - -Define public properties via URL params on a page load -```php -class MySearchForm extends \Magewirephp\Magewire\Component\Form -{ - public $searchText; - - /** - * @url https://your.domain/customer/account/dashboard?searchText=foo - */ - protected $queryString = [ - 'searchText' - ]; - - // OR - - /** - * @url https://your.domain/customer/account/dashboard?q=foo - */ - protected $queryString = [ - 'searchText' => [ - 'alias' => 'q' // map 'searchText' as 'q' - ] - ]; -} -``` - -## Set A Predictable wire:id -Magewire generates a SHA1 hash wire:id attribute value by default. This is based on the component's layout block name. -```php -class Explanation extends \Magewirephp\Magewire\Component -{ - public $id = 'my-predictable-wire-id'; - - public $bar; - - public function foo(string $bar) - { - $this->assign('bar', $bar); - } -} -``` - -Will output as: -```html -
-``` - -> **Note**: SHA1 hashing the wire:id value is an idea which can change in the future. I'm still tumbling around the -> acceptance of just using the block name which has to be unique which is the most important part. I need to look into -> the security aspect when switching to an un-hashed version of the wire:id attribute. -> -> On of the benefits would be that Magewire components are more predictable when it comes to trying to find them with -> for example Livewire.find(). - -Find the component and trigger the ```foo``` method: -```js -document.addEventListener('livewire:load', function () { - Magewire.find(['my-predictable-wire-id']).foo('Some Value') -}); -``` - -## Display Loading State -Display a loading state only when performing a (targeted) subsequent method call. -```php -class Explanation extends \Magewirephp\Magewire\Component -{ - `// Show a loading state for all methods - protected $loader = true; - // Show a loading state for specific methods - protected $loader = ['foo'];` - - public function foo() { - - // Loading state will stay active until the listener has run - // Loading state will disapear when there are no active listeners - $this->emit('some_event') - - } - - public function bar() { - // - } -} -``` -> **Note:** Keep in mind that the ```$loader``` mapping only understands subsequent executable methods. - -```html - - -``` - -### Indicator Customization -```xml - - - -``` -**File**: view/frontend/layout/default_hyva.xml -```html - -``` -**File**: html/magewire/loader.phtml - -### Indicator Removal -In some cases you want to implement your own loader because you have a global one in place or your just don't need to -notify your customer. Whatever the case, I centered all frontend logic into two phtml files to let you do whatever's -needed for the project. -```xml - - - - - - -``` -**File**: view/frontend/layout/default_hyva.xml - -### Custom Example -Ofcourse you're able to build it more custom for your wired component only. -```html -
- -
-``` - -```php -public function start(int $seconds) -{ - // Let's take a nap. - sleep($seconds); - // Unlock the disabled state. - $this->dispatchBrowserEvent('switch-state'); -} -``` -> **Note**: This is just an example. For a disabled state you should or could use the ```wire:loading``` directive. - -## Plugins -> **Important**: This is still a proof of concept. It's possible this won't make it into the first official release. - -It's a best practice to add your custom additions to Magewire inside the designated ```magewire.plugin``` container. -This can come in handy when you need to check if a plugin gives any trouble after installation to just temporary remove -it. - -```xml - -``` - -### Intersect Directive Plugin -> **Under construction**: This features is still under construction. Don't use this feature in any project until a -> first and final release. - -Magewire has an (unique) ```intersect``` directive. This is a custom plugin can be compared with the ```init``` directive but -only when the Magewire block is inside the viewport. This can really speed up the page when for instance it's -dealing with a large dataset on the bottom of the page. - -```html -
- -
- - - -
- -
-``` - -```php -class Explanation extends \Magewirephp\Magewire\Component -{ - public $fooPropertyValue; - - public function foo() - { - $this->assign('fooPropertyValue', 'bar'); - } - - public function bar(string $textOne, string $textTwo) - { - $this->assign('barPropertyValue', $textOne . ' & ' . $textTwo); - } -} -``` diff --git a/docs/Hydrators.md b/docs/Hydrators.md deleted file mode 100644 index 4399259e..00000000 --- a/docs/Hydrators.md +++ /dev/null @@ -1,99 +0,0 @@ -# Magewire - Hydrators -> Please keep in mind that Magewire is currently in a Beta-phase. Therefore not all architectural choices are set in -> concrete. So make sure you are aware of the risks of building on top of the platform in it's current state. - -By default Magewire come's with a stack of core hydrators who can not be overwritten by default. These take precedence -over the rest to ensure the bootstrap lifecycle. - -| Order | Hydration | Dehydration | -| ----- | --------------| --------------| -| 1 | Security | Redirect | -| 2 | Browser Event | Emit | -| 3 | Flash Message | Loader | -| 4 | Error | Listener | -| 5 | Hash | Property | -| 6 | Component | QueryString | -| 7 | QueryString | Component | -| 8 | Property | Hash | -| 9 | Listener | Error | -| 10 | Loader | Flash Message | -| 11 | Emit | Browser Event | -| 10 | Redirect | Security | - -## Make Your Own -As a developer you can manipulate the Request and/or Response going back and forth. The core concept op hydration is -that it acts as a shell where the core hydrators will encapsulate all extended. This is done to ensure the core always -has precedent on its attendants. - -All core hydrators are pluggable which means in theory you should be able to write Plugins on top of all core hydrators. -This is not best practice and we always encourage you to write your own to avoid problems on future updates. - -### Example -**My/Module/etc/frontend/di.xml** -```xml - - - - - - - - My\Module\Model\Magewire\Hydrator\MyAwesomeHydrator - - 50 - - - - - -``` - -**My/Module/Model/Magewire/Hydrator/MyAwesomeHydrator.php** -```php -isSubsequent()) {} - - // Initial page load hydration request - if ($request->isPreceding()) {} -} - -public function dehydrate(Component $component, ResponseInterface $response): void -{ - // Subsequent hydration request - if ($response->getRequest()->isSubsequent()) {} - - // Initial page load hydration request - if ($response->getRequest()->isPreceding()) {} -} -``` diff --git a/docs/Notes.md b/docs/Notes.md deleted file mode 100644 index cf0b9faa..00000000 --- a/docs/Notes.md +++ /dev/null @@ -1,13 +0,0 @@ -# Magewire - Notes -> Please keep in mind that Magewire is currently in a Beta-phase. Therefore not all architectural choices are set in -> concrete. So make sure you are aware of the risks of building on top of the platform in it's current state. - -## Assign Method Usage -- **Date**: 07/10/2021 -- **Category**: Property Binding - -So this assign method is here because we need a system where we can trigger methods like ```updating``` & ```updated```. -Thanks to the ```assign``` method, this will trigger immediately after the public property get a new value assigned. - -Now, what if this was replaced with a Hydrator? It would make it a lot more simple because developers don't have to -write extra code to assign a value onto a property. In that case you would be able to just use ```$this->foo = 'bar'```. diff --git a/lib/Magento/App/Cache/MagewireCacheSection.php b/lib/Magento/App/Cache/MagewireCacheSection.php new file mode 100644 index 00000000..d9269df6 --- /dev/null +++ b/lib/Magento/App/Cache/MagewireCacheSection.php @@ -0,0 +1,45 @@ +tags)) { + $this->tags[] = $this->identifier; + } + + return $this->magewireCacheType->save($this->serializer->serialize($data), $this->identifier, $this->tags, $ttl ?? $this->ttl); + } + + public function fetch(): array + { + $data = $this->magewireCacheType->load($this->identifier); + + return ! $data ? [] : $data; + } +} diff --git a/lib/Magento/App/Cache/Type/Magewire.php b/lib/Magento/App/Cache/Type/Magewire.php new file mode 100644 index 00000000..0f38875b --- /dev/null +++ b/lib/Magento/App/Cache/Type/Magewire.php @@ -0,0 +1,36 @@ +get(self::TYPE_IDENTIFIER), self::CACHE_TAG); + } + + public function load($identifier) + { + $data = parent::load($identifier); + + return is_string($data) ? $this->serializer->unserialize($data) : $data; + } +} diff --git a/lib/Magento/App/Router/MagewireRouteValidator.php b/lib/Magento/App/Router/MagewireRouteValidator.php new file mode 100644 index 00000000..357fffec --- /dev/null +++ b/lib/Magento/App/Router/MagewireRouteValidator.php @@ -0,0 +1,58 @@ +isAdminRequest($request)) { + $path = '/' . $this->frontNameResolver->getFrontName() . $path; + } + + return $this->isMagewireUri($path, $request); + } + + private function isAdminRequest(RequestInterface $request): bool + { + if (preg_match('#/([^/]+)/magewire/update#', $request->getPathInfo(), $matches)) { + return $matches[1] === $this->frontNameResolver->getFrontName(); + } + + return false; + } + + private function isMagewireUri(string $path, RequestInterface $request): bool + { + return str_contains($request->getPathInfo(), $path); + } +} diff --git a/lib/Magento/Controller/MagewireUpdateResult.php b/lib/Magento/Controller/MagewireUpdateResult.php new file mode 100644 index 00000000..d59c506a --- /dev/null +++ b/lib/Magento/Controller/MagewireUpdateResult.php @@ -0,0 +1,73 @@ +rendered) { + return $this; + } + + $this->renderer = $renderer; + + return $this; + } + + public function getComponents(): array + { + return $this->components; + } + + public function getAssets(): array + { + return $this->assets; + } + + public function render(HttpResponseInterface $response): self + { + $this->renderer ??= function (HttpResponseInterface $response, MagewireUpdateResult $result): HttpResponseInterface { + return $response->setBody($this->jsonSerializer->serialize([ + 'components' => $result->getComponents(), + 'assets' => $result->getAssets() + ])); + }; + + call_user_func($this->renderer, $response, $this); + $this->rendered = true; + + trigger('magewire:response.render', $response); + + $response->setHeader('Content-Type', 'application/json', true); + $response->setHeader('X-Built-With', 'Magewire', true); + + return $this; + } +} diff --git a/lib/Magento/Framework/Reflection/TypeProcessor.php b/lib/Magento/Framework/Reflection/TypeProcessor.php new file mode 100644 index 00000000..2280aa2a --- /dev/null +++ b/lib/Magento/Framework/Reflection/TypeProcessor.php @@ -0,0 +1,22 @@ +layout->getUpdate()->getHandles(); + + sort($handles); + $hash = hash('xxh3', json_encode($handles)); + + // Early return when a version with the identical handles already exists. + if ($force === false && array_key_exists($hash, $this->builds)) { + return $this->builds[$hash]; + } + + // Register build so it only run once to avoid running infinitely. + $this->builds[$hash] = $this->layout; + + $this->layout->getUpdate()->load($handles); + $this->layout->generateXml(); + $this->layout->generateElements(); + + return $this->builds[$hash]; + } + + public function reset(): static + { + $this->builds = []; + + return $this; + } + + /** + * @throws LocalizedException + */ + public function rebuild(): LayoutInterface + { + return $this->build(true); + } +} diff --git a/lib/Magento/Framework/View/DynamicLayoutDecorator.php b/lib/Magento/Framework/View/DynamicLayoutDecorator.php new file mode 100644 index 00000000..2e675cf4 --- /dev/null +++ b/lib/Magento/Framework/View/DynamicLayoutDecorator.php @@ -0,0 +1,34 @@ +dynamicLayoutBuilderFactory->create(['layout' => $layout]); + + $layout->setGeneratorPool($this->magewireGeneratorPool); + $layout->setBuilder($builder); + + return $layout; + } +} diff --git a/lib/Magento/Framework/View/Layout/GeneratorPool.php b/lib/Magento/Framework/View/Layout/GeneratorPool.php new file mode 100644 index 00000000..5f72a883 --- /dev/null +++ b/lib/Magento/Framework/View/Layout/GeneratorPool.php @@ -0,0 +1,46 @@ +helper = $magewireLayoutScheduledStructureHelper; + } + + protected function addGenerators(array $generators) + { + // Limit the generators to just blocks and containers. + parent::addGenerators(array_filter($generators, static fn ($generator) => in_array($generator, ['block', 'container']), ARRAY_FILTER_USE_KEY)); + } +} diff --git a/lib/Magento/Framework/View/Layout/ScheduledStructure/Helper.php b/lib/Magento/Framework/View/Layout/ScheduledStructure/Helper.php new file mode 100644 index 00000000..0dca25fe --- /dev/null +++ b/lib/Magento/Framework/View/Layout/ScheduledStructure/Helper.php @@ -0,0 +1,71 @@ +getStructureElement($key); + $data = $scheduledStructure->getStructureElementData($key); + + if (! isset($row[self::SCHEDULED_STRUCTURE_INDEX_TYPE])) { + $this->logger->critical('Broken reference: missing declaration of the element "{$key}".'); + + $scheduledStructure->unsetPathElement($key); + $scheduledStructure->unsetStructureElement($key); + + return; + } + + list($type, $alias, $parentName, $siblingName, $isAfter) = $row; + + $name = $this->_createStructuralElement($structure, $key, $type, $parentName . $alias); + + if ($parentName) { + if ($scheduledStructure->hasStructureElement($parentName)) { + $this->scheduleElement($scheduledStructure, $structure, $parentName); + } + + if (! $structure->hasElement($parentName)) { + /* + * Without a fully loaded page, there won't be a wrapping element acting as the root content container. + * This leads to a problem where the starting parent element isn't available, causing an error. + * Emulation becomes necessary to attach their respective children when this is the case. + */ + $structure->createElement($parentName, [ + 'attributes' => [ + 'label' => ucfirst($parentName) + ], + 'type' => \Magento\Framework\View\Layout\Element::TYPE_CONTAINER + ]); + } + + $structure->setAsChild($name, $parentName, $alias); + } + + // Transforming a scheduledStructure into a scheduledElement. + $scheduledStructure->unsetStructureElement($key); + $scheduledStructure->setElement($name, [$type, $data]); + + /* + * Some elements provide info "after" or "before" which sibling they are supposed to go + * Add an element into a list of sorting + */ + if ($siblingName) { + $scheduledStructure->setElementToSortList($parentName, $name, $siblingName, $isAfter); + } + } +} diff --git a/lib/Magento/Framework/View/TemplateEngine/Php/TemplateRenderDataTransferObject.php b/lib/Magento/Framework/View/TemplateEngine/Php/TemplateRenderDataTransferObject.php new file mode 100644 index 00000000..82679533 --- /dev/null +++ b/lib/Magento/Framework/View/TemplateEngine/Php/TemplateRenderDataTransferObject.php @@ -0,0 +1,56 @@ +block; + } + + public function filename(string|null $filename = null): string + { + if ($filename !== null) { + $this->filename = $filename; + } + + return $this->filename; + } + + public function dictionary(array|null $dictionary = null): array + { + if ($dictionary !== null) { + // Deliberately not using an array merge for performance reasons. + foreach ($dictionary as $key => $value) { + $this->dictionary[$key] = $value; + } + } + + return $this->dictionary; + } + + public function existsInDictionary(string $key): bool + { + return isset($this->dictionary[$key]); + } +} diff --git a/lib/Magewire/Concerns/WithTagging.php b/lib/Magewire/Concerns/WithTagging.php new file mode 100644 index 00000000..1ef9cb96 --- /dev/null +++ b/lib/Magewire/Concerns/WithTagging.php @@ -0,0 +1,81 @@ + */ + protected array $withTags = []; + + /** + * Tag a fragment with a recognizable name. + */ + public function withTag(string $tag): static + { + $sanitized = preg_replace('/[^a-z0-9]/', '', strtolower($tag)); + + if ($sanitized !== '') { + $this->withTags[] = $sanitized; + } + + return $this; + } + + /** + * Clears all tags either completely or by a provided filter callback. + */ + public function clearTags(callable|null $filter = null): static + { + $this->withTags = $filter ? array_filter($this->withTags, $filter) : []; + + return $this; + } + + /** + * Define multiple tags. + */ + public function withTags(array $tags): static + { + foreach ($tags as $tag) { + if ($this->hasTags([$tag])) { + continue; + } + + $this->withTag($tag); + } + + return $this; + } + + /** + * Retrieve the fragment alias (if set). + */ + public function getWithTags(): array + { + return $this->withTags; + } + + /** + * Retrieve if the fragment possesses an alias. + */ + public function hasTags(array $tags = [], bool $strict = false): bool + { + if (empty($tags)) { + return false; + } + if ($strict) { + return empty(array_diff($tags, $this->withTags)); + } + + return ! empty(array_intersect($this->withTags, $tags)); + } +} diff --git a/lib/Magewire/Config.php b/lib/Magewire/Config.php new file mode 100644 index 00000000..5eef4c87 --- /dev/null +++ b/lib/Magewire/Config.php @@ -0,0 +1,46 @@ +paths)) { + $path = $this->paths[$path]; + } + + return $this->systemConfig->getValue($path, $scope, $scopeCode) ?? ( $this->environmentConfig->isAvailable() ? $this->environmentConfig->get($path) : null ); + } +} diff --git a/lib/Magewire/Containers.php b/lib/Magewire/Containers.php new file mode 100644 index 00000000..54dcdc91 --- /dev/null +++ b/lib/Magewire/Containers.php @@ -0,0 +1,27 @@ + true; + } + + protected function getBootModeFallback(): ServiceTypeItemBootMode + { + return ServiceTypeItemBootMode::ALWAYS; + } +} diff --git a/lib/Magewire/Containers/Livewire.php b/lib/Magewire/Containers/Livewire.php new file mode 100644 index 00000000..46b2462a --- /dev/null +++ b/lib/Magewire/Containers/Livewire.php @@ -0,0 +1,25 @@ +component = $component; + + return $this; + } + + public function response($to) + { + } +} diff --git a/lib/Magewire/Enums/RequestMode.php b/lib/Magewire/Enums/RequestMode.php new file mode 100644 index 00000000..992e83c9 --- /dev/null +++ b/lib/Magewire/Enums/RequestMode.php @@ -0,0 +1,34 @@ +value > $state->value; + } + + /** + * Check if this state is below the given state. + */ + public function isBelow(RuntimeState $state): bool + { + return $this->value < $state->value; + } + + public function isMinimally(RuntimeState $state): bool + { + return $this->value >= $state->value; + } + + public function isMaximally(RuntimeState $state): bool + { + return $this->value <= $state->value; + } +} diff --git a/lib/Magewire/Enums/ServiceTypeItemBootMode.php b/lib/Magewire/Enums/ServiceTypeItemBootMode.php new file mode 100644 index 00000000..2e0349be --- /dev/null +++ b/lib/Magewire/Enums/ServiceTypeItemBootMode.php @@ -0,0 +1,120 @@ +is(self::PERSISTENT); + } + + /** + * Returns true when the boot mode is lazy. + */ + public function isLazy(): bool + { + return $this->is(self::LAZY); + } + + public function isAlways(): bool + { + return $this->is(self::ALWAYS); + } + + /** + * Returns true when the current case matches the given case. + */ + public function is(self $case): bool + { + return $this === $case; + } + + public function isHigherThan(self $case): bool + { + return $this->value > $case->value; + } + + public function isHigherThanOrEqual(self $case): bool + { + return $this->value >= $case->value; + } + + public function isLowerThan(self $case): bool + { + return $this->value < $case->value; + } + + public function isLowerThanOrEqual(self $case): bool + { + return $this->value <= $case->value; + } + + /** + * Returns the lowercase name of the current case. + */ + public function name(): string + { + return strtolower($this->name); + } + + /** + * Returns the default boot mode for any service type item. + */ + public static function default(): self + { + return self::ALWAYS; + } + + /** + * Returns the given value if it exists or returns the default when it doesn't. + */ + public static function try(mixed $value = null, self|null $fallback = null): self + { + if (is_numeric($value)) { + $value = (int) $value; + } + + return ( self::exists($value) ? self::tryFrom($value) : null ) ?? $fallback ?? self::default(); + } + + public static function exists(mixed $value): bool + { + return is_int($value) && self::tryFrom($value) !== null; + } + + /** + * Returns the case with the highest integer value. + */ + public static function highest(): self + { + return self::from(max(array_column(self::cases(), 'value'))); + } + + /** + * Returns the case with the lowest integer value. + */ + public static function lowest(): self + { + return self::from(min(array_column(self::cases(), 'value'))); + } +} diff --git a/lib/Magewire/Exceptions/ComponentNotFoundException.php b/lib/Magewire/Exceptions/ComponentNotFoundException.php new file mode 100644 index 00000000..f6f6ff54 --- /dev/null +++ b/lib/Magewire/Exceptions/ComponentNotFoundException.php @@ -0,0 +1,20 @@ +componentHookRegistry::boot(); + } + + return $booted; + } + + protected function callback(): callable + { + return function (object $type) { + $this->componentHookRegistry::register($type); + }; + } + + protected function getBootModeFallback(): ServiceTypeItemBootMode + { + return ServiceTypeItemBootMode::LAZY; + } +} diff --git a/lib/Magewire/Features/SupportMagentoFlashMessages/FlashMessage.php b/lib/Magewire/Features/SupportMagentoFlashMessages/FlashMessage.php new file mode 100644 index 00000000..fca14495 --- /dev/null +++ b/lib/Magewire/Features/SupportMagentoFlashMessages/FlashMessage.php @@ -0,0 +1,88 @@ +message = $message; + return $this; + } + + public function asSuccess(): static + { + return $this->as(FlashMessageType::Success); + } + + public function asError(): static + { + return $this->as(FlashMessageType::Error); + } + + public function asWarning(): static + { + return $this->as(FlashMessageType::Warning); + } + + public function asNotice(): static + { + return $this->as(FlashMessageType::Notice); + } + + public function as(FlashMessageType $type): static + { + $this->type = $type; + return $this; + } + + public function message(): Phrase + { + return $this->message; + } + + public function type(): FlashMessageType + { + return $this->type; + } + + /** + * @deprecated Use the message() method instead. + * @see static::message() + */ + function getMessage(): Phrase + { + return $this->message; + } + + /** + * @deprecated Use the type() method instead. + * @see static::type() + */ + function getType(): FlashMessageType + { + return $this->type; + } +} diff --git a/lib/Magewire/Features/SupportMagentoFlashMessages/FlashMessageType.php b/lib/Magewire/Features/SupportMagentoFlashMessages/FlashMessageType.php new file mode 100644 index 00000000..97bcf7f4 --- /dev/null +++ b/lib/Magewire/Features/SupportMagentoFlashMessages/FlashMessageType.php @@ -0,0 +1,20 @@ +magewireFlashMessages ??= Factory::create(MagewireFlashMessages::class); + } + + /** + * @deprecated Flash Messages have been moved into their own object. Chain the magewireFlashMessages method instead. + * @see static::magewireFlashMessages() + */ + public function dispatchErrorMessage($message): FlashMessageElement + { + return $this->magewireFlashMessages()->make($message, FlashMessageType::Error); + } + + /** + * @deprecated Flash Messages have been moved into their own object. Chain the magewireFlashMessages method instead. + * @see static::magewireFlashMessages() + */ + public function dispatchWarningMessage($message): FlashMessageElement + { + return $this->magewireFlashMessages()->make($message, FlashMessageType::Warning); + } + + /** + * @deprecated Flash Messages have been moved into their own object. Chain the magewireFlashMessages method instead. + * @see static::magewireFlashMessages() + */ + public function dispatchNoticeMessage($message): FlashMessageElement + { + return $this->magewireFlashMessages()->make($message, FlashMessageType::Notice); + } + + /** + * @deprecated Flash Messages have been moved into their own object. Chain the magewireFlashMessages method instead. + * @see static::magewireFlashMessages() + */ + public function dispatchSuccessMessage($message): FlashMessageElement + { + return $this->magewireFlashMessages()->make($message, FlashMessageType::Success); + } + + /** + * @deprecated Flash Messages have been moved into their own object. Chain the magewireFlashMessages method instead. + * @see static::magewireFlashMessages() + */ + public function dispatchMessage(string $type, $message): FlashMessageElement + { + $type = match ($type) { + FlashMessageType::Error->value => FlashMessageType::Error, + FlashMessageType::Warning->value => FlashMessageType::Warning, + FlashMessageType::Success->value => FlashMessageType::Success, + + default => FlashMessageType::Notice + }; + + return $this->magewireFlashMessages()->make($message, $type); + } + + /** + * @deprecated Flash Messages have been moved into their own object. Chain the magewireFlashMessages method instead. + * @see static::magewireFlashMessages() + */ + public function hasFlashMessages(): bool + { + return $this->magewireFlashMessages()->count() > 0; + } + + /** + * @deprecated Flash Messages have been moved into their own object. Chain the magewireFlashMessages method instead. + * @see static::magewireFlashMessages() + */ + public function getFlashMessages(): array + { + return $this->magewireFlashMessages()->fetch(); + } + + /** + * @deprecated Clearing all messages at once is not advisable. Use the unset method instead. + * @see MagewireFlashMessages::unset() + */ + public function clearFlashMessages(): void + { + $this->magewireFlashMessages()->clear(); + } +} diff --git a/lib/Magewire/Features/SupportMagentoFlashMessages/MagewireFlashMessages.php b/lib/Magewire/Features/SupportMagentoFlashMessages/MagewireFlashMessages.php new file mode 100644 index 00000000..d5354b0c --- /dev/null +++ b/lib/Magewire/Features/SupportMagentoFlashMessages/MagewireFlashMessages.php @@ -0,0 +1,63 @@ +messages[$name ?? Random::string()] ??= Factory::create(FlashMessage::class, [ + 'message' => is_string($message) ? __($message) : $message, + 'type' => $type + ]); + } + + public function unset(string $name): static + { + if (isset($this->messages[$name])) { + unset($this->messages[$name]); + } + + return $this; + } + + public function fetch(): array + { + return $this->messages; + } + + public function count(): int + { + return count($this->messages); + } + + /** + * @deprecated clearing all flash messages shouldn't be something you use. Instead, use unset to remove + * a single flash message by its name. + * @see static::unset() + */ + public function clear(): static + { + $this->messages = []; + return $this; + } +} diff --git a/lib/Magewire/Features/SupportMagentoFlashMessages/SupportMagentoFlashMessages.php b/lib/Magewire/Features/SupportMagentoFlashMessages/SupportMagentoFlashMessages.php new file mode 100644 index 00000000..17bf3b6a --- /dev/null +++ b/lib/Magewire/Features/SupportMagentoFlashMessages/SupportMagentoFlashMessages.php @@ -0,0 +1,40 @@ +component(); + + if ($component && $component->magewireFlashMessages()->count() > 0) { + $context->pushEffect('dispatches', [ + 'name' => 'magewire:flash-messages:dispatch', + 'params' => $this->mapFlashMessages($component->magewireFlashMessages()) + ]); + } + } + + private function mapFlashMessages(MagewireFlashMessages $messages): array + { + return array_map(static function (FlashMessage $message) { + return [ + 'text' => $message->message()->render(), + 'type' => $message->type() + ]; + }, $messages->fetch()); + } +} diff --git a/lib/Magewire/Features/SupportMagentoLayouts/HandlesMagentoLayout.php b/lib/Magewire/Features/SupportMagentoLayouts/HandlesMagentoLayout.php new file mode 100644 index 00000000..c73a3205 --- /dev/null +++ b/lib/Magewire/Features/SupportMagentoLayouts/HandlesMagentoLayout.php @@ -0,0 +1,89 @@ +magewireResolver = $resolver; + } + + return $this->magewireResolver; + } + + public function magewireBlock(AbstractBlock|null $block = null): AbstractBlock|null + { + if ($block) { + $this->magewireBlock = $block; + } + + return $this->magewireBlock; + } + + public function magewireLayoutLifecycle(LayoutLifecycle|null $lifecycle = null): LayoutLifecycle|null + { + if ($lifecycle) { + $this->magewireLayoutLifecycle = $lifecycle; + } + + return $this->magewireLayoutLifecycle; + } + + /** + * @deprecated has been replaced with block() + * @see static::magewireBlock() + */ + public function setParent(Template|null $parent): static + { + $this->magewireBlock($parent); + + return $this; + } + + /** + * @deprecated has been replaced with block() + * @see static::magewireBlock() + */ + public function getParent(): AbstractBlock|null + { + return $this->magewireBlock(); + } + + /** + * @deprecated has been replaced with magewireBlock() + * @see static::magewireBlock() + */ + public function block(AbstractBlock|null $block = null): AbstractBlock|null + { + return $this->magewireBlock($block); + } + + /** + * @deprecated has been replaced with magewireResolver() + * @see static::magewireResolver() + */ + public function resolver(AbstractBlock|null $block = null): AbstractBlock|null + { + return $this->magewireBlock($block); + } +} diff --git a/lib/Magewire/Features/SupportMagentoLayouts/SupportMagentoLayouts.php b/lib/Magewire/Features/SupportMagentoLayouts/SupportMagentoLayouts.php new file mode 100644 index 00000000..30986540 --- /dev/null +++ b/lib/Magewire/Features/SupportMagentoLayouts/SupportMagentoLayouts.php @@ -0,0 +1,18 @@ + */ + private array $listeners = []; + + public function with(callable $callback): static + { + $this->listeners[] = $callback; + + return $this; + } + + public function listeners(): array + { + return $this->listeners; + } +} diff --git a/lib/Magewire/Features/SupportMagentoObserverEvents/SupportMagentoObserverEvents.php b/lib/Magewire/Features/SupportMagentoObserverEvents/SupportMagentoObserverEvents.php new file mode 100644 index 00000000..913f5160 --- /dev/null +++ b/lib/Magewire/Features/SupportMagentoObserverEvents/SupportMagentoObserverEvents.php @@ -0,0 +1,133 @@ + $this->dispatch('magewire_on_' . preg_replace('/[^a-zA-Z0-9_]/', '_', $event), ...$args)); + } + } + + private function dispatch(string $event, ...$arguments): callable + { + $listener = $this->listenerDataTransferObjectFactory->create(); + $this->eventManager->dispatch($event, ['listener' => $listener]); + + $afters = []; + + foreach ($listener->listeners() as $listener) { + if (! is_callable($listener)) { + continue; + } + + $result = $listener(...$arguments); + + if (is_callable($result)) { + $afters[] = $result; + } + } + + return function (...$args) use ($afters, $event) { + $pipeline = $this->pipelineFactory->create(); + + if ($afters) { + foreach ($afters as $after) { + $pipeline->pipe(static fn (array $args, callable $next) => $next($after(...$args))); + } + + return $pipeline->run($args); + } + + // Can only have a single return item, just like a regular class method. + return $args[0] ?? null; + }; + } +} diff --git a/lib/Magewire/Features/SupportMagewireCompiling/Exception/DirectiveExpressionParseException.php b/lib/Magewire/Features/SupportMagewireCompiling/Exception/DirectiveExpressionParseException.php new file mode 100644 index 00000000..7ad9c8aa --- /dev/null +++ b/lib/Magewire/Features/SupportMagewireCompiling/Exception/DirectiveExpressionParseException.php @@ -0,0 +1,18 @@ +magewireCompiler = $compiler; + } + + return $this->magewireCompiler; + } +} diff --git a/lib/Magewire/Features/SupportMagewireCompiling/MagewireUnderscoreViewModel.php b/lib/Magewire/Features/SupportMagewireCompiling/MagewireUnderscoreViewModel.php new file mode 100644 index 00000000..531e7611 --- /dev/null +++ b/lib/Magewire/Features/SupportMagewireCompiling/MagewireUnderscoreViewModel.php @@ -0,0 +1,49 @@ +actionManager->load($class); + } + + public function arguments(): DataScope + { + return $this->arguments; + } + + public function utils(): Utils + { + return $this->utils; + } + + public function factory(): ViewFactory + { + return $this->viewFactory; + } +} diff --git a/lib/Magewire/Features/SupportMagewireCompiling/SupportMagewireCompiling.php b/lib/Magewire/Features/SupportMagewireCompiling/SupportMagewireCompiling.php new file mode 100644 index 00000000..79b74d78 --- /dev/null +++ b/lib/Magewire/Features/SupportMagewireCompiling/SupportMagewireCompiling.php @@ -0,0 +1,157 @@ +block() instanceof DataObject) { + return; + } + + $component = $dto->block()->getData('magewire'); + + if (! $component instanceof Component) { + return; + } + + $compiler = $component->magewireCompiler() ?? $component->magewireCompiler( + $this->compilerManager->factory()->newCompilerInstance() + ); + + $dto->dictionary(['magewire' => $component]); + + if ($component->magewireCompiler()->canCompile()) { + $compiledPath = $compiler->management()->file()->generateFilePath($dto->filename()); + + if ($compiler->requiresRecompile($dto->filename())) { + trigger('magewire:view:compile', $compiler, $component, $dto->block()); + $compiler->compile($dto->filename(), $compiledPath); + } + + $dto->filename($compiledPath); + } + + // Concept: Include the Magewire underscore object optionally required by compiled views. + $dto->dictionary(['__magewire' => $dto->dictionary()['__magewire'] ?? $this->underscoreViewModelFactory->create()]); + + // Currently only for dev-purposes, will change over time and shouldn't be used. + if ($this->slotsRegistry->hasAreas()) { + $dto->dictionary([ + '__slot' => $dto->dictionary()['__slot'] ?? $this->slotsRegistry->snapshot(), + '__el' => $this->slotsRegistry->element(), + ]); + } + }); + + before('magewire:view:compile', static function (Compiler $compiler) { + $runs['html'] = 0; + + $compiler + ->pipelines() + ->html() + ->middleware() + ->group('first-line', 2) + ->pipe(static function (string $throughput, callable $next) use (&$runs, $compiler) { + $runs['html']++; + + if ($runs['html'] === 1) { + return '@template()' . PHP_EOL . $next($throughput); + } + + return $next($throughput); + }); + + $compiler + ->pipelines() + ->template() + ->middleware() + ->group('last') + ->pipe(static function (string $throughput, callable $next): string { + return $next($throughput) . '@endtemplate'; + }); + + $compiler + ->pipelines() + ->template() + ->middleware() + ->group('last') + ->pipe(static function (string $throughput, callable $next): string { + $result = $next($throughput); + $date = new DateTime(); + + return $result . sprintf('' . PHP_EOL, $date->format('Y-m-d H:i:s.u')); + }) + ->pipe(static function (string $throughput, callable $next) use ($compiler): string { + $result = $next($throughput); + + return $result . sprintf('' . PHP_EOL, $compiler->basePath()); + }) + ->pipe(static function (string $throughput, callable $next) use ($compiler): string { + $start = $compiler->compileStartTime(); + $result = $next($throughput); + + $durationMs = round(( microtime(true) - $start ) * 1000, 2); + $durationSec = round($durationMs / 1000, 4); + + return $result . sprintf('' . PHP_EOL, $durationMs, $durationSec); + }); + }); + } + + /** + * WIP + * + * Generate a readable file structure for generated files. + * + * Instead of unreadable filenames, create organized directories that map to source files, + * making it easier for developers to locate and debug generated output. + * + * TODO: Consider enabling this only in development mode. + */ + private function transformToViewPath(Template $block): string + { + $template = $block->getTemplate(); + $parts = explode('::', $template); + + if (count($parts) === 2) { + $parts[0] = str_replace('_', '/', $parts[0]); + + return implode('/', $parts); + } + + return $block->getTemplateFile(); + } +} diff --git a/lib/Magewire/Features/SupportMagewireCompiling/View/Action/Magento/Auth.php b/lib/Magewire/Features/SupportMagewireCompiling/View/Action/Magento/Auth.php new file mode 100644 index 00000000..1891b30d --- /dev/null +++ b/lib/Magewire/Features/SupportMagewireCompiling/View/Action/Magento/Auth.php @@ -0,0 +1,34 @@ +httpContext->getValue(Context::CONTEXT_AUTH); + } + + public function isGuest(): bool + { + return ! $this->isCustomer(); + } +} diff --git a/lib/Magewire/Features/SupportMagewireCompiling/View/Action/Magewire.php b/lib/Magewire/Features/SupportMagewireCompiling/View/Action/Magewire.php new file mode 100644 index 00000000..54179c70 --- /dev/null +++ b/lib/Magewire/Features/SupportMagewireCompiling/View/Action/Magewire.php @@ -0,0 +1,18 @@ +basePath = $basePath; + $this->targetPath = $targetPath; + $this->compileStartTime = microtime(true); + + $filesystem = $this->management()->file()->system(); + $content = $this->compiler()->run($filesystem->read($basePath)); + + $filesystem->ensureDirectoryExists($targetPath); + $filesystem->write($content, $targetPath); + + return $filesystem->exists($targetPath); + } + + public function basePath(): string + { + return $this->basePath; + } + + public function targetPath(): string + { + return $this->targetPath; + } + + public function compileStartTime(): float + { + return $this->compileStartTime; + } + + /** + * Compiler management entry point. + */ + public function management(): CompilerManager + { + return $this->manager; + } + + /** + * @return CompilerPipelines + */ + public function pipelines(): CompilerPipelines + { + return $this->compilerPipelines ??= $this->newCompilerPipelineDistributorInstance(); + } + + /** + * Gets or sets the compile flag. + * + * When called without arguments, this method returns the current compile state. + * When a boolean value is provided, it updates the compile flag and returns + * the current instance for method chaining. + */ + public function canCompile(bool|null $choice = null): bool|static + { + if ($choice) { + $this->compile = $choice; + return $this; + } + + return $this->compile; + } + + /** + * Determine if the view at the given path is expired. + * + * @throws FileSystemException + */ + public function requiresRecompile(string $path): bool + { + $filesystem = $this->management()->file()->system(); + $compiled = $this->management()->file()->generateFilePath($path); + + if ($filesystem->exists($compiled)) { + return $filesystem->lastModified($path) >= $filesystem->lastModified($compiled); + } + + return true; + } + + /** + * Returns the template pipeline. + */ + protected function compiler(): Pipeline + { + return $this->pipelines()->template(); + } + + /** + * Compiles a PHP template string by iterating through its tokens. + * @throws Throwable + */ + protected function compileTokens(string $input): string + { + $output = ''; + + foreach (token_get_all($input) as $token) { + $output .= is_array($token) ? $this->parseToken($token) : $token; + } + + return $output; + } + + /** + * Parses and compiles a single token from the tokenized template. + * @throws Throwable + */ + protected function parseToken(#[\SensitiveParameter] array $token): string + { + [$id, $content] = $token; + + if ($id == T_INLINE_HTML) { + return $this->pipelines()->html()->run($content); + } + + return $content; + } + + /** + * Compile directives starting with "@". + */ + protected function compileDirectives(string $template): string + { + preg_match_all('/\B@(@?\w+(?:::\w+)?)([ \t]*)(\( ( [\S\s]*? ) \))?/x', $template, $matches); + + $offset = 0; + + for ($i = 0; isset($matches[0][$i]); $i++) { + $match = [ + $matches[0][$i], + $matches[1][$i], + $matches[2][$i], + $matches[3][$i] ?: null, + $matches[4][$i] ?: null + ]; + + while (isset($match[4]) && str_ends_with($match[0], ')') && ! $this->management()->utils()->hasEvenNumberOfParentheses($match[0])) { + $after = strstr($template, $match[0]); + + if ($after === false) { + break; + } + + $after = substr($after, strlen($match[0])); + $pos = strpos($after, ')'); + + if ($pos === false) { + break; + } + + $rest = substr($after, 0, $pos); + + if (isset($matches[0][$i + 1]) && str_contains($rest . ')', $matches[0][$i + 1])) { + unset($matches[0][$i + 1]); + $i++; + } + + $match[0] .= $rest . ')'; + $match[3] .= $rest . ')'; + $match[4] .= $rest; + } + + [$template, $offset] = $this->replaceFirstStatement($match[0], $this->compileDirective($match), $template, $offset); + } + + return $template; + } + + /** + * Compile a single "@" directive. + */ + protected function compileDirective(array $match): string + { + [$area, $directive] = $this->management()->directives()->tryToLocateArea($match[1]); + + if (str_contains($match[1], '@')) { + $match[0] = isset($match[3]) ? $match[1] . $match[3] : $match[1]; + } elseif ($area instanceof DirectiveArea && is_string($directive)) { + if ($area->responsibilities()->has($directive)) { + $match[0] = $area->responsibilities()->pop($directive)->compile($match[4] ?? '', $directive); + } elseif ($area->has($directive)) { + $match[0] = $area->get($directive)->compile($match[4] ?? '', $directive); + } + } elseif ($directive = $this->management()->directives()->area()->get($directive)) { + $match[0] = $directive->compile($match[4] ?? '', $match[1]); + } else { + return $match[0]; + } + + return isset($match[3]) ? $match[0] : $match[0] . $match[2]; + } + + /** + * Replace the first match for a statement compilation operation. + */ + protected function replaceFirstStatement(string $search, string $replace, string $subject, int $offset): array|string + { + if ($search === '') { + return $subject; + } + + $position = strpos($subject, $search, $offset); + + if ($position !== false) { + return [ + substr_replace($subject, $replace, $position, strlen($search)), + $position + strlen($replace) + ]; + } + + return [$subject, 0]; + } + + protected function newCompilerPipelineDistributorInstance(): CompilerPipelines + { + $distributor = $this->compilerPipelinesFactory->create(['type' => Pipeline::class]); + + $distributor + ->template() + ->pipe(function (string $throughput, callable $next) { + return $next($this->compileTokens($throughput)); + }); + + // Reserves the 'security' groups at the very earliest position + // so security-related pipes always run before everything else. + $distributor->template()->middleware()->group('security', 0); + $distributor->template()->middleware()->group('last', 900); + $distributor->html()->middleware()->group('security', 0); + + $distributor + ->html() + ->pipe(function (string $throughput, callable $next) { + return $next($this->compileDirectives($throughput)); + }); + + return $distributor; + } +} diff --git a/lib/Magewire/Features/SupportMagewireCompiling/View/Compiler/MagentoTemplateCompiler.php b/lib/Magewire/Features/SupportMagewireCompiling/View/Compiler/MagentoTemplateCompiler.php new file mode 100644 index 00000000..3602ff8a --- /dev/null +++ b/lib/Magewire/Features/SupportMagewireCompiling/View/Compiler/MagentoTemplateCompiler.php @@ -0,0 +1,45 @@ +template() + ->middleware() + ->group('blade', 25) + ->pipe(function (string $throughput, callable $next) { + return $next($this->bladeMiddleware->compile($throughput)); + }); + + return $pipeline; + } +} diff --git a/lib/Magewire/Features/SupportMagewireCompiling/View/Compiler/Middleware/Blade.php b/lib/Magewire/Features/SupportMagewireCompiling/View/Compiler/Middleware/Blade.php new file mode 100644 index 00000000..2cfaf133 --- /dev/null +++ b/lib/Magewire/Features/SupportMagewireCompiling/View/Compiler/Middleware/Blade.php @@ -0,0 +1,304 @@ +compileSelfClosingTags($value); + $value = $this->compileSlots($value); + $value = $this->compileOpeningTags($value); + return $this->compileClosingTags($value); + } + + /** + * Compile the opening tags within the given string. + */ + protected function compileOpeningTags(string $value): string + { + $pattern = "/ + <\s* + magewire- + (?[\w\-:]+) + : + (?[\w\-]+) + (? + (?: + \s+ + (?: + @(?:class)\( (?: (?>[^()]+) | (?-1) )* \) + | + @(?:style)\( (?: (?>[^()]+) | (?-1) )* \) + | + \{\{\s*\\\$attributes(?:[^}]+?)?\s*\}\} + | + (:\\\$)(\w+) + | + [\w\-:.@%]+ + ( + = + (?: + \\\"[^\\\"]*\\\" + | + \'[^\']*\' + | + [^\'\\\"=<>]+ + ) + )? + ) + )* + \s* + ) + (?/x"; + + return preg_replace_callback( + $pattern, + function (array $matches) { + $attributes = $this->parseParams($matches['attributes']); + + return $this->componentString($matches['component'], $attributes, $matches['variant'] ?? null); + }, + $value + ); + } + + /** + * Compile the self-closing tags within the given string. + */ + protected function compileSelfClosingTags(string $value): string + { + $pattern = "/ + <\s* + magewire- + (?[\w\-:]+) + : + (?[\w\-]+) + (? + (?: + \s+ + (?: + @(?:class)\( (?: (?>[^()]+) | (?-1) )* \) + | + @(?:style)\( (?: (?>[^()]+) | (?-1) )* \) + | + \{\{\s*\\\$attributes(?:[^}]+?)?\s*\}\} + | + (:\\\$)(\w+) + | + [\w\-:.@%]+ + ( + = + (?: + \\\"[^\\\"]*\\\" + | + \'[^\']*\' + | + [^\'\\\"=<>]+ + ) + )? + ) + )* + \s* + ) + (?/x"; + + return preg_replace_callback( + $pattern, + function (array $matches) { + $attributes = $this->parseParams($matches['attributes']); + + return $this->componentString($matches['component'], $attributes, $matches['variant']) . "\n@endComponent"; + }, + $value + ); + } + + protected function componentString(string $component, string $attributes = '[]', string|null $variant = 'default'): string + { + $variant ??= 'default'; + $var = 'component' . ucfirst(strtolower($variant)) . ucfirst(Random::alphabetical(5, true)); + + return "@magewireComponent(type: '{$component}', id: '{$var}', variant: '{$variant}') + + dictionary()->fill(get_defined_vars()) ?> + data()->distribute({$attributes}) ?> + start() ?> + "; + } + + /** + * Compile the closing tags within the given string. + */ + protected function compileClosingTags(string $value): string + { + return preg_replace("/<\/\s*magewire-[\:]?[\w\-\:\.]*\s*>/", ' @magewireEndComponent', $value); + } + + /** + * Compile the slot tags within the given string. + */ + public function compileSlots(string $value): string + { + $pattern = "/ + < + \s* + magewire-slot + (?:\:(?\w+(?:-\w+)*))? + (?:\s+name=(?(\"[^\"]+\"|\\\'[^\\\']+\\\'|[^\s>]+)))? + (?:\s+\:name=(?(\"[^\"]+\"|\\\'[^\\\']+\\\'|[^\s>]+)))? + (? + (?: + \s+ + (?: + (?: + @(?:class)(\( (?: (?>[^()]+) | (?-1) )* \)) + ) + | + (?: + @(?:style)(\( (?: (?>[^()]+) | (?-1) )* \)) + ) + | + (?: + \{\{\s*\\\$attributes(?:[^}]+?)?\s*\}\} + ) + | + (?: + [\w\-:.@]+ + ( + = + (?: + \\\"[^\\\"]*\\\" + | + \'[^\']*\' + | + [^\'\\\"=<>]+ + ) + )? + ) + ) + )* + \s* + ) + (? + /x"; + + $value = preg_replace_callback( + $pattern, + function ($matches) { + $name = $this->stripQuotes($matches['inlineName'] ?: $matches['name'] ?: $matches['boundName']) ?: "'slot'"; + + $attributes = $this->parseParams($matches['attributes']); + $var = 'slot' . ucfirst(strtolower($name)) . ucfirst(Random::alphabetical(5, true)); + + return "@magewireSlot(target: '{$name}', id: '{$var}') + + dictionary()->fill(get_defined_vars()) ?> + data()->distribute({$attributes}) ?> + start() ?> + "; + }, + $value + ); + + return preg_replace('/<\/\s*magewire-slot[^>]*>/', ' @magewireEndSlot', $value); + } + + /** + * Strip any quotes from the given string. + */ + public function stripQuotes(string $value): string + { + return str_starts_with($value, '"') || str_starts_with($value, "'") ? substr($value, 1, -1) : $value; + } + + /** + * @tddo Minimal implementation, needs a lot more work to make edge cases work + * For example, Dynamic classes based on a template variable. + */ + protected function parseParams($params): string + { + preg_match_all('/([a-zA-Z0-9:-]*?)\s*?=\s*?(.+?)(\s|$)/ms', $params, $matches); + $params = []; + + foreach ($matches[1] as $i => $key) { + $value = str_replace('"', '', $matches[2][$i]); + + $value = match ($value) { + 'false' => false, + 'true' => true, + default => $value + }; + + if (str_starts_with($key, ':magewire:')) { + $params['magewire'][] = '"' . substr($key, 1) . '" => ' . $value; + continue; + } + if (str_starts_with($key, 'bind:magewire:')) { + $params['magewire'][] = '"' . substr($key, 5) . '" => ' . $value; + continue; + } + if (str_starts_with($key, 'magewire:')) { + $params['magewire'][] = '"' . $key . '" => "' . $value . '"'; + continue; + } + if (str_starts_with($key, '::')) { + $params['attributes'][] = '"' . substr($key, 2) . '" => "' . $value . '"'; + continue; + } + if (str_starts_with($key, ':')) { + $params['properties'][] = '"' . substr($key, 1) . '" => ' . $value; + continue; + } + if (str_starts_with($key, 'bind:')) { + $params['properties'][] = '"' . substr($key, 5) . '" => ' . $value; + continue; + } + + $filling = is_bool($value) ? ( $value ? 'true' : 'false' ) : '"' . $value . '"'; + $params['properties'][] = '"' . $key . '"' . ' => ' . $filling; + } + + $result = '['; + + foreach ($params as $key => $value) { + $result .= '"' . $key . '" => [' . implode(', ', $value) . '], '; + } + + $result = rtrim($result, ', '); + $result .= ']'; + + return $result; + } +} diff --git a/lib/Magewire/Features/SupportMagewireCompiling/View/CompilerFactory.php b/lib/Magewire/Features/SupportMagewireCompiling/View/CompilerFactory.php new file mode 100644 index 00000000..00183216 --- /dev/null +++ b/lib/Magewire/Features/SupportMagewireCompiling/View/CompilerFactory.php @@ -0,0 +1,35 @@ +newInstance($arguments); + } + + private function newInstanceType(): string + { + return MagentoTemplateCompiler::class; + } +} diff --git a/lib/Magewire/Features/SupportMagewireCompiling/View/CompilerPipelines.php b/lib/Magewire/Features/SupportMagewireCompiling/View/CompilerPipelines.php new file mode 100644 index 00000000..a8b04611 --- /dev/null +++ b/lib/Magewire/Features/SupportMagewireCompiling/View/CompilerPipelines.php @@ -0,0 +1,27 @@ +getExpressionParserFor($directive) )) { + $parser = $this->parser($type)->parse($expression); + + $allArgs = $parser->arguments()->all(); + $method = new ReflectionMethod($this, $directive); + $args = []; + + foreach ($method->getParameters() as $param) { + $paramName = $param->getName(); + + if (array_key_exists($paramName, $allArgs)) { + $args[$paramName] = $allArgs[$paramName]; + } + } + + return $this->{$directive}(...$args); + } + + return $this->{$directive}(); + } + + protected function parser(ExpressionParserType $parser, array $arguments = []): ExpressionParser + { + return $parser->create($arguments); + } + + protected function getExpressionParserFor(string $directive): ExpressionParserType|null + { + $directive = implode('::', [static::class, $directive]); + + if (! ( $this->expressionParsers[$directive] ?? null )) { + $reflection = new ReflectionClass($this); + + foreach ($reflection->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { + $attributes = $method->getAttributes(ScopeDirectiveParser::class); + $attribute = $attributes[0] ?? null ? $attributes[0]->newInstance() : null; + + if ($attribute) { + $this->expressionParsers[implode('::', [static::class, $method->getName()])] = $attribute->expressionParserType; + } + } + } + + return $this->expressionParsers[$directive] ?? null; + } + + protected function var(string $name, bool $pop = false): string + { + $var = $this->variables[$name] ??= Random::alphabetical(10, true); + + if ($pop) { + unset($this->variables[$name]); + } + + return $var; + } +} diff --git a/lib/Magewire/Features/SupportMagewireCompiling/View/Directive/Base.php b/lib/Magewire/Features/SupportMagewireCompiling/View/Directive/Base.php new file mode 100644 index 00000000..9a34fd16 --- /dev/null +++ b/lib/Magewire/Features/SupportMagewireCompiling/View/Directive/Base.php @@ -0,0 +1,35 @@ +escapeHtml(__('{$value}')) ?>"; + } + + return ""; + } + + #[ScopeDirectiveParser(ExpressionParserType::FUNCTION_ARGUMENTS)] + public function child(string $alias): string + { + return "getChildHtml('{$alias}') ?>"; + } +} diff --git a/lib/Magewire/Features/SupportMagewireCompiling/View/Directive/Fragment.php b/lib/Magewire/Features/SupportMagewireCompiling/View/Directive/Fragment.php new file mode 100644 index 00000000..fdd74926 --- /dev/null +++ b/lib/Magewire/Features/SupportMagewireCompiling/View/Directive/Fragment.php @@ -0,0 +1,32 @@ +var('fragment')} = \$__magewire->utils()->fragment()->make({$type})->start() ?>"; + } + + public function endfragment(): string + { + return "var('fragment')}->end() ?>"; + } +} diff --git a/lib/Magewire/Features/SupportMagewireCompiling/View/Directive/Json.php b/lib/Magewire/Features/SupportMagewireCompiling/View/Directive/Json.php new file mode 100644 index 00000000..ebb323a0 --- /dev/null +++ b/lib/Magewire/Features/SupportMagewireCompiling/View/Directive/Json.php @@ -0,0 +1,33 @@ +parser(Directive\Parser\ExpressionParserType::FUNCTION_ARGUMENTS)->parse($expression)->arguments(); + + $value = $arguments->get('value', $arguments->get('default', [])); + $flags = $arguments->get('flags', $this->encodingOptions); + $depth = $arguments->get('depth', 512); + + return ""; + } +} diff --git a/lib/Magewire/Features/SupportMagewireCompiling/View/Directive/Magento/Auth.php b/lib/Magewire/Features/SupportMagewireCompiling/View/Directive/Magento/Auth.php new file mode 100644 index 00000000..504cb495 --- /dev/null +++ b/lib/Magewire/Features/SupportMagewireCompiling/View/Directive/Magento/Auth.php @@ -0,0 +1,40 @@ +action('magento.auth')->execute('is_customer')): ?>"; + } + + public function endauth(): string + { + return parent::endif(); + } + + #[ScopeDirectiveChain(methods: ['endguest'])] + public function guest(): string + { + return "action('magento.auth')->execute('is_guest')): ?>"; + } + + public function endguest(): string + { + return parent::endif(); + } +} diff --git a/lib/Magewire/Features/SupportMagewireCompiling/View/Directive/Magento/Block.php b/lib/Magewire/Features/SupportMagewireCompiling/View/Directive/Magento/Block.php new file mode 100644 index 00000000..ffd4224f --- /dev/null +++ b/lib/Magewire/Features/SupportMagewireCompiling/View/Directive/Magento/Block.php @@ -0,0 +1,25 @@ +getChildBlock('{$alias}') ? \$block->getChildBlock('{$alias}')->toHtml() : '' ?>"; + } +} diff --git a/lib/Magewire/Features/SupportMagewireCompiling/View/Directive/Magento/Escape.php b/lib/Magewire/Features/SupportMagewireCompiling/View/Directive/Magento/Escape.php new file mode 100644 index 00000000..487107a9 --- /dev/null +++ b/lib/Magewire/Features/SupportMagewireCompiling/View/Directive/Magento/Escape.php @@ -0,0 +1,25 @@ +escapeUrl({$url}) ?>"; + } +} diff --git a/lib/Magewire/Features/SupportMagewireCompiling/View/Directive/Magewire/Component.php b/lib/Magewire/Features/SupportMagewireCompiling/View/Directive/Magewire/Component.php new file mode 100644 index 00000000..247cfe2c --- /dev/null +++ b/lib/Magewire/Features/SupportMagewireCompiling/View/Directive/Magewire/Component.php @@ -0,0 +1,37 @@ +variableScopeStart($id); + $variant ??= 'default'; + + return "factory()->elements()->element('{$type}', \$block, '{$variant}')->track() ?>"; + } + + public function endComponent(): string + { + $var = $this->variableScopeEnd(); + + return "end()->untrack() ?>"; + } +} diff --git a/lib/Magewire/Features/SupportMagewireCompiling/View/Directive/Magewire/Slot.php b/lib/Magewire/Features/SupportMagewireCompiling/View/Directive/Magewire/Slot.php new file mode 100644 index 00000000..75026709 --- /dev/null +++ b/lib/Magewire/Features/SupportMagewireCompiling/View/Directive/Magewire/Slot.php @@ -0,0 +1,36 @@ +variableScopeStart($id); + + return "factory()->elements()->slot('{$target}', \$block) ?>"; + } + + public function endSlot(): string + { + $var = $this->variableScopeEnd(); + + return "end() ?>"; + } +} diff --git a/lib/Magewire/Features/SupportMagewireCompiling/View/Directive/Parser/Arguments.php b/lib/Magewire/Features/SupportMagewireCompiling/View/Directive/Parser/Arguments.php new file mode 100644 index 00000000..608715c5 --- /dev/null +++ b/lib/Magewire/Features/SupportMagewireCompiling/View/Directive/Parser/Arguments.php @@ -0,0 +1,49 @@ +values() + ); + + return implode(', ', array_map(static fn ($k, $v) => str_replace(['$key', '$value'], [$k, var_export($v, true)], $format), $this->keys(), $values)); + } + + public function renderAsNamed(): string + { + return $this->render('$key: $value'); + } + + public function renderAsCondition(): string + { + return ''; // wip + } + + public function renderAsIterationClause(): string + { + return ''; // wip + } +} diff --git a/lib/Magewire/Features/SupportMagewireCompiling/View/Directive/Parser/ConditionExpressionParser.php b/lib/Magewire/Features/SupportMagewireCompiling/View/Directive/Parser/ConditionExpressionParser.php new file mode 100644 index 00000000..e3771a87 --- /dev/null +++ b/lib/Magewire/Features/SupportMagewireCompiling/View/Directive/Parser/ConditionExpressionParser.php @@ -0,0 +1,20 @@ + $expression]; + } +} diff --git a/lib/Magewire/Features/SupportMagewireCompiling/View/Directive/Parser/ExpressionParser.php b/lib/Magewire/Features/SupportMagewireCompiling/View/Directive/Parser/ExpressionParser.php new file mode 100644 index 00000000..fb745a22 --- /dev/null +++ b/lib/Magewire/Features/SupportMagewireCompiling/View/Directive/Parser/ExpressionParser.php @@ -0,0 +1,39 @@ +parseArguments($content); + + $this->arguments()->merge($content)->snapshot(); + return $this; + } + + abstract protected function parseArguments(string $expression): array; + + public function arguments(): Arguments + { + return $this->arguments ??= $this->argumentsFactory->create(); + } +} diff --git a/lib/Magewire/Features/SupportMagewireCompiling/View/Directive/Parser/ExpressionParserType.php b/lib/Magewire/Features/SupportMagewireCompiling/View/Directive/Parser/ExpressionParserType.php new file mode 100644 index 00000000..900d3abc --- /dev/null +++ b/lib/Magewire/Features/SupportMagewireCompiling/View/Directive/Parser/ExpressionParserType.php @@ -0,0 +1,50 @@ + ObjectManager::getInstance()->create($this->getTypeClass(), $arguments) + }; + } + + public function getTypeClass(): string + { + return match ($this) { + self::CONDITION => ConditionExpressionParser::class, + self::FUNCTION_ARGUMENTS => FunctionExpressionParser::class, + self::ITERATION_CLAUSE => IterationClauseExpressionParser::class + }; + } +} diff --git a/lib/Magewire/Features/SupportMagewireCompiling/View/Directive/Parser/FunctionExpressionParser.php b/lib/Magewire/Features/SupportMagewireCompiling/View/Directive/Parser/FunctionExpressionParser.php new file mode 100644 index 00000000..d50d1e88 --- /dev/null +++ b/lib/Magewire/Features/SupportMagewireCompiling/View/Directive/Parser/FunctionExpressionParser.php @@ -0,0 +1,413 @@ + 'b', "x-data" => "init()"] + */ + public function parseArguments(string $expression): array + { + $expression = trim($expression); + + if ($expression === '') { + return []; + } + + // Fast path: entire expression is a valid JSON object. + if ($this->isJsonString($expression)) { + return $this->parseJsonArguments($expression); + } + + // Main path: parse named arguments with complex values (including PHP arrays). + return $this->parseNamedArguments($expression); + } + + /** + * Parse named arguments: key: value, key2: value2, ... + */ + private function parseNamedArguments(string $expression): array + { + $arguments = []; + $pos = 0; + $length = strlen($expression); + + while ($pos < $length) { + $pos = $this->skipWhitespace($expression, $pos); + + if ($pos >= $length) { + break; + } + + // Extract key (unquoted identifier: name, attributes, etc.). + [$key, $pos] = $this->extractKey($expression, $pos); + + if ($key === null) { + throw new InvalidArgumentException("Invalid or missing key near position {$pos}"); + } + + $pos = $this->skipWhitespace($expression, $pos); + + if ($pos >= $length || $expression[$pos] !== ':') { + throw new InvalidArgumentException("Expected ':' after key '{$key}' near position {$pos}"); + } + + $pos++; // skip ':'. + $pos = $this->skipWhitespace($expression, $pos); + + if ($pos >= $length) { + throw new InvalidArgumentException("Missing value for key '{$key}'"); + } + + // Extract value — supports strings, JSON, and full PHP arrays. + [$value, $pos] = $this->extractComplexValue($expression, $pos); + + $arguments[$key] = $value; + + $pos = $this->skipWhitespace($expression, $pos); + + // Optional comma separator. + if ($pos < $length && $expression[$pos] === ',') { + $pos++; + } + } + + return $arguments; + } + + /** + * Extract an identifier key (e.g. name, attributes) + */ + private function extractKey(string $expression, int $pos): array + { + $start = $pos; + while ($pos < strlen($expression) && preg_match('/[a-zA-Z0-9_]/', $expression[$pos])) { + $pos++; + } + + if ($pos === $start) { + return [null, $pos]; + } + + $key = substr($expression, $start, $pos - $start); + + // Keep top-level keys strict (valid PHP identifiers). + if (! preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $key)) { + return [null, $pos]; + } + + return [$key, $pos]; + } + + /** + * Extract complex value: quoted string, JSON {}, JSON [], or PHP array [] + */ + private function extractComplexValue(string $expression, int $pos): array + { + if ($pos >= strlen($expression)) { + throw new InvalidArgumentException('Unexpected end of expression'); + } + + $char = $expression[$pos]; + + if ($char === '"' || $char === "'") { + return [$this->extractQuotedString($expression, $pos), $pos]; + } + + if ($char === '{' || $char === '[') { + // Could be JSON or PHP array — try PHP array first if starts with [. + if ($char === '[') { + // Peek ahead to see if it looks like PHP array (contains =>). + $peekPos = $pos + 1; + $peekPos = $this->skipWhitespace($expression, $peekPos); + if ($peekPos < strlen($expression) && ( $expression[$peekPos] === '"' || $expression[$peekPos] === "'" || $expression[$peekPos] === '$' )) { + // Likely PHP array — try to parse it. + try { + $tempPos = $pos; + $array = $this->extractPhpArray($expression, $tempPos); + return [$array, $tempPos]; + } catch (Exception) { + // Fall through to JSON array. + } + } + } + + // JSON object/array. + return [$this->extractJsonStructure($expression, $pos), $pos]; + } + + // Unquoted value (true, false, null, number, variable, unquoted string). + return [$this->extractUnquotedValue($expression, $pos), $pos]; + } + + /** + * Extract and evaluate a full PHP array literal: ['class' => 'btn', "x-data" => "init()"] + */ + private function extractPhpArray(string $expression, int &$pos): array + { + $start = $pos; + $depth = 0; + $inString = false; + $escape = false; + $quoteChar = null; + + $pos++; // skip opening [. + + while ($pos < strlen($expression)) { + $char = $expression[$pos]; + + if ($escape) { + $escape = false; + $pos++; + continue; + } + + if ($char === '\\') { + $escape = true; + $pos++; + continue; + } + + if ($char === '"' || $char === "'") { + if (! $inString) { + $inString = true; + $quoteChar = $char; + } elseif ($quoteChar === $char) { + $inString = false; + $quoteChar = null; + } + $pos++; + continue; + } + + if (! $inString) { + if ($char === '[') { + $depth++; + } elseif ($char === ']') { + if ($depth === 0) { + $pos++; // include closing ]. + $arrayStr = substr($expression, $start, $pos - $start); + return $this->evaluatePhpArray($arrayStr); + } + $depth--; + } + } + + $pos++; + } + + throw new InvalidArgumentException("Unclosed PHP array starting at position {$start}"); + } + + /** + * Safely evaluate a PHP array literal using eval in a controlled way + */ + private function evaluatePhpArray(string $arrayStr): array + { + $code = "return {$arrayStr};"; + + $result = @eval($code); + + if ($result === false && $arrayStr !== '[]') { + throw new InvalidArgumentException("Failed to parse PHP array: syntax error in {$arrayStr}"); + } + + if (! is_array($result)) { + throw new InvalidArgumentException("Expression did not evaluate to an array: {$arrayStr}"); + } + + return $result; + } + + /** + * Extract JSON object { ... } or array [ ... ] + */ + private function extractJsonStructure(string $expression, int &$pos): array + { + $start = $pos; + $opening = $expression[$pos]; + $closing = $opening === '{' ? '}' : ']'; + $depth = 0; + $inString = false; + $escape = false; + + $pos++; + + while ($pos < strlen($expression)) { + $char = $expression[$pos]; + + if ($escape) { + $escape = false; + } elseif ($char === '\\') { + $escape = true; + } elseif ($char === '"') { + $inString = ! $inString; + } elseif (! $inString) { + if ($char === $opening) { + $depth++; + } elseif ($char === $closing) { + if ($depth === 0) { + $pos++; + $json = substr($expression, $start, $pos - $start); + $decoded = json_decode($json, true); + if (json_last_error() !== JSON_ERROR_NONE) { + throw new InvalidArgumentException('Invalid JSON: ' . json_last_error_msg()); + } + return $decoded; + } + $depth--; + } + } + $pos++; + } + + throw new InvalidArgumentException("Unclosed JSON structure starting at position {$start}"); + } + + /** + * Extract quoted string + */ + private function extractQuotedString(string $expression, int &$pos): string + { + $quote = $expression[$pos]; + $pos++; + $start = $pos; + $escape = false; + + while ($pos < strlen($expression)) { + $char = $expression[$pos]; + + if ($escape) { + $escape = false; + $pos++; + continue; + } + + if ($char === '\\') { + $escape = true; + $pos++; + continue; + } + + if ($char === $quote) { + $value = substr($expression, $start, $pos - $start); + $pos++; + return str_replace(['\\' . $quote, '\\\\'], [$quote, '\\'], $value); + } + + $pos++; + } + + throw new InvalidArgumentException('Unclosed quoted string'); + } + + /** + * Extract unquoted value (true, false, null, number, variable, simple string) + */ + private function extractUnquotedValue(string $expression, int &$pos): mixed + { + $start = $pos; + + while ($pos < strlen($expression) && ! in_array($expression[$pos], [',', '}', ']', ')', ' '], true)) { + $pos++; + } + + $value = trim(substr($expression, $start, $pos - $start)); + + if ($value === '') { + throw new InvalidArgumentException('Empty unquoted value'); + } + + return match (strtolower($value)) { + 'null' => null, + 'true' => true, + 'false' => false, + default => $this->parseNumberOrVariableOrString($value) + }; + } + + private function parseNumberOrVariableOrString(string $value): mixed + { + if ($this->isNumber($value)) { + return $this->parseNumber($value); + } + + if ($this->isVariable($value)) { + return $this->parseVariable($value); + } + + return $value; // unquoted string. + } + + private function skipWhitespace(string $expression, int $pos): int + { + while ($pos < strlen($expression) && ctype_space($expression[$pos])) { + $pos++; + } + return $pos; + } + + private function isJsonString(string $expression): bool + { + $trimmed = trim($expression); + if (! str_starts_with($trimmed, '{') || ! str_ends_with($trimmed, '}')) { + return false; + } + + json_decode($trimmed); + return json_last_error() === JSON_ERROR_NONE; + } + + private function parseJsonArguments(string $jsonString): array + { + $decoded = json_decode(trim($jsonString), true); + + if (json_last_error() !== JSON_ERROR_NONE) { + throw new InvalidArgumentException('Invalid JSON: ' . json_last_error_msg()); + } + + if (! is_array($decoded)) { + throw new InvalidArgumentException('JSON must decode to an array'); + } + + // No more strict key validation — allows 'x-data', 'aria-*', etc. + return $decoded; + } + + private function isNumber(string $value): bool + { + return preg_match('/^-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?$/', $value) === 1; + } + + private function parseNumber(string $value): float|int + { + return str_contains($value, '.') || str_contains(strtolower($value), 'e') ? (float) $value : (int) $value; + } + + private function isVariable(string $value): bool + { + return preg_match('/^\$[a-zA-Z_][a-zA-Z0-9_]*$/', $value) === 1; + } + + private function parseVariable(string $value): array + { + return [ + 'type' => 'variable', + 'name' => substr($value, 1) + ]; + } +} diff --git a/lib/Magewire/Features/SupportMagewireCompiling/View/Directive/Parser/IterationClauseExpressionParser.php b/lib/Magewire/Features/SupportMagewireCompiling/View/Directive/Parser/IterationClauseExpressionParser.php new file mode 100644 index 00000000..0cd16293 --- /dev/null +++ b/lib/Magewire/Features/SupportMagewireCompiling/View/Directive/Parser/IterationClauseExpressionParser.php @@ -0,0 +1,20 @@ + $expression]; + } +} diff --git a/lib/Magewire/Features/SupportMagewireCompiling/View/Directive/Scope.php b/lib/Magewire/Features/SupportMagewireCompiling/View/Directive/Scope.php new file mode 100644 index 00000000..1b39f4f8 --- /dev/null +++ b/lib/Magewire/Features/SupportMagewireCompiling/View/Directive/Scope.php @@ -0,0 +1,55 @@ +"; + } + + #[ScopeDirectiveParser(ExpressionParserType::CONDITION)] + public function elseif(string $condition): string + { + return ""; + } + + public function else(): string + { + return ''; + } + + public function endif(): string + { + return ''; + } + + #[ScopeDirectiveChain(methods: ['endforeach'])] + #[ScopeDirectiveParser(ExpressionParserType::ITERATION_CLAUSE)] + public function foreach(string $iterationClause): string + { + return ""; + } + + public function endforeach(): string + { + return ''; + } +} diff --git a/lib/Magewire/Features/SupportMagewireCompiling/View/Directive/Script.php b/lib/Magewire/Features/SupportMagewireCompiling/View/Directive/Script.php new file mode 100644 index 00000000..a6015249 --- /dev/null +++ b/lib/Magewire/Features/SupportMagewireCompiling/View/Directive/Script.php @@ -0,0 +1,29 @@ +var('fragment')} = \$__magewire->utils()->fragment()->make()->script()->start() ?>"; + } + + public function endscript(): string + { + return "var('fragment')}->end() ?>"; + } +} diff --git a/lib/Magewire/Features/SupportMagewireCompiling/View/Directive/Template.php b/lib/Magewire/Features/SupportMagewireCompiling/View/Directive/Template.php new file mode 100644 index 00000000..29352213 --- /dev/null +++ b/lib/Magewire/Features/SupportMagewireCompiling/View/Directive/Template.php @@ -0,0 +1,36 @@ +variableScopeStart(); + + return "utils()->fragment()->make()->component(\$block) ?>"; + } + + public function endtemplate(): string + { + $var = $this->variableScopeEnd(); + + return "end() ?>"; + } +} diff --git a/lib/Magewire/Features/SupportMagewireCompiling/View/DirectiveArea.php b/lib/Magewire/Features/SupportMagewireCompiling/View/DirectiveArea.php new file mode 100644 index 00000000..c0738f94 --- /dev/null +++ b/lib/Magewire/Features/SupportMagewireCompiling/View/DirectiveArea.php @@ -0,0 +1,91 @@ + $directives + */ + public function __construct( + private array $directives = [] + ) { + } + + public function set(string $name, Directive $directive, bool $force = false): Directive + { + if ($this->has($name) && $force === false) { + return $this->get($name); + } + + return $this->directives[$name] = $directive; + } + + public function unset(string $name): static + { + if ($this->has($name)) { + unset($this->directives[$name]); + } + + return $this; + } + + public function has(string $directive): bool + { + return array_key_exists($directive, $this->directives); + } + + public function responsibilities(): DirectiveResponsibilities + { + return $this->responsibilities ??= Factory::create(DirectiveResponsibilities::class); + } + + public function get(string $directive): Directive|null + { + $type = $this->directives[$directive] ?? null; + $standalone = is_string($type); + + if ($standalone) { + $type = Factory::create($type); + } + + /* + * On a scoped directive, we need to make sure it closing or chaining responsibilities are + * being memorized for when a directive + */ + if ($type instanceof ScopeDirective) { + $type = $standalone ? $type : Factory::create($type::class); + + foreach ($type->getResponsibilitiesFor($directive) as $responsibility) { + if ($responsibility === $directive) { + continue; + } + + $this->responsibilities()->push($responsibility, $type); + } + } + + return $type; + } +} diff --git a/lib/Magewire/Features/SupportMagewireCompiling/View/DirectiveResponsibilities.php b/lib/Magewire/Features/SupportMagewireCompiling/View/DirectiveResponsibilities.php new file mode 100644 index 00000000..5adf93b4 --- /dev/null +++ b/lib/Magewire/Features/SupportMagewireCompiling/View/DirectiveResponsibilities.php @@ -0,0 +1,38 @@ +items[$directive][] = $predecessor; + + return $this; + } + + public function pop(string $directive): Directive|null + { + if (is_array($this->items[$directive] ?? null)) { + return array_pop($this->items[$directive]); + } + + return null; + } + + public function has(string $directive): bool + { + return count($this->items[$directive] ?? []) > 0; + } +} diff --git a/lib/Magewire/Features/SupportMagewireCompiling/View/DomElementData.php b/lib/Magewire/Features/SupportMagewireCompiling/View/DomElementData.php new file mode 100644 index 00000000..2e202e09 --- /dev/null +++ b/lib/Magewire/Features/SupportMagewireCompiling/View/DomElementData.php @@ -0,0 +1,86 @@ + Attributes::class, + 'properties' => Properties::class + ], $mapping); + + parent::__construct($type ?? DataArray::class, $mapping); + } + + public function distribution(): DataArray + { + return $this->data(); + } + + /** + * Distribute keyed arrays into individual data types. + * + * Iterates through the provided data and creates a separate slot for each string key + * that contains an array value. Each slot is then populated with its corresponding array content. + * + * Non-string keys and non-array values are ignored during distribution. + * + * @example + * $slots->distribute([ + * 'header' => ['title' => 'Welcome', 'subtitle' => 'Hello World'], + * 'sidebar' => ['widgets' => [...]] + * ]); + */ + public function distribute(array $data): static + { + foreach ($data as $key => $value) { + if (! ( is_string($key) && is_array($value) )) { + continue; + } + + $this->create($key)->fill($value); + } + + return $this; + } + + protected function create(string|null $type = null, array $arguments = []): object + { + if ($type === 'data') { + return parent::create($type, $arguments); + } + + return $this->data()->subset($type, $this->resolve($type), $arguments); + } +} diff --git a/lib/Magewire/Features/SupportMagewireCompiling/View/File/StubCollector.php b/lib/Magewire/Features/SupportMagewireCompiling/View/File/StubCollector.php new file mode 100644 index 00000000..8b65856c --- /dev/null +++ b/lib/Magewire/Features/SupportMagewireCompiling/View/File/StubCollector.php @@ -0,0 +1,65 @@ +dirSearch, $this->fileFactory, 'stubs'); + } + + public function collect(): array + { + if ($this->collected) { + return $this->stubs; + } + + /** @var array $stubs */ + $stubs = $this->getFiles($this->design->getDesignTheme(), '*.stub'); + + foreach ($stubs as $stub) { + if ($stub instanceof File) { + $stub = $this->viewStubFactory->create($stub); + } + + $this->stubs[$stub->getNamespace()] = $stub; + } + + // Flag as collected to implement lazy loading and avoid redundant disk access. + $this->collected = true; + + return $this->stubs; + } + + public function recollect(): array + { + $this->collected = false; + + return $this->collect(); + } +} diff --git a/lib/Magewire/Features/SupportMagewireCompiling/View/FileSystem.php b/lib/Magewire/Features/SupportMagewireCompiling/View/FileSystem.php new file mode 100644 index 00000000..e7e440f6 --- /dev/null +++ b/lib/Magewire/Features/SupportMagewireCompiling/View/FileSystem.php @@ -0,0 +1,90 @@ +magentoFileSystemDriver->fileGetContents($from); + } + + /** + * @throws FileSystemException + */ + public function write(string $content, string $to, int|string $mode = 0): void + { + $this->magentoFileSystemDriver->filePutContents($to, $content, $mode); + } + + public function exists(string $path): bool + { + try { + return $this->magentoFileSystemDriver->isExists($path); + } catch (FileSystemException $exception) { + $this->logger->critical($exception->getMessage(), ['exception' => $exception]); + } + + return false; + } + + /** + * @throws FileSystemException + */ + public function stats(string $path): array + { + return $this->magentoFileSystemDriver->stat($path); + } + + /** + * @throws FileSystemException + */ + public function makeDirectory(string $path, int $mode = 0o755, bool $recursive = false): void + { + $this->magentoFileSystemDriver->createDirectory($path, $mode); + } + + /** + * @throws FileSystemException + */ + public function lastModified(string $path): int + { + $stat = $this->stats($path); + + return $stat['mtime'] ?? time(); + } + + /** + * @throws FileSystemException + */ + public function ensureDirectoryExists(string $path): void + { + if ($this->exists(dirname($path))) { + return; + } + + $this->makeDirectory(dirname($path), 0o777, true); + } +} diff --git a/lib/Magewire/Features/SupportMagewireCompiling/View/FunctionDirective.php b/lib/Magewire/Features/SupportMagewireCompiling/View/FunctionDirective.php new file mode 100644 index 00000000..9b99a942 --- /dev/null +++ b/lib/Magewire/Features/SupportMagewireCompiling/View/FunctionDirective.php @@ -0,0 +1,27 @@ +parser(ExpressionParserType::FUNCTION_ARGUMENTS)->parse($expression); + + // TODO: should handle exceptions, logging them and return an empty string when so. + return method_exists($this, $directive) ? $this->$directive(...$parsed->arguments()->all()) : ''; + } +} diff --git a/lib/Magewire/Features/SupportMagewireCompiling/View/Management/ActionManager.php b/lib/Magewire/Features/SupportMagewireCompiling/View/Management/ActionManager.php new file mode 100644 index 00000000..e34905c7 --- /dev/null +++ b/lib/Magewire/Features/SupportMagewireCompiling/View/Management/ActionManager.php @@ -0,0 +1,70 @@ + $namespaces + */ + public function __construct( + private readonly ViewAction $action, + private readonly ActionManagerFactory $actionManagerFactory, + private readonly array $namespaces = [] + ) { + } + + public function execute(string $method, ...$arguments): mixed + { + $method = lcfirst(str_replace('_', '', ucwords($method, '_'))); + + return $this->action->$method(...$arguments); + } + + /** + * Load and create an ActionManager instance based on the provided namespace. + * + * This method attempts to resolve the namespace in the following order: + * 1. If the namespace is an existing class, creates an ActionManager with that class + * 2. If the namespace exists in the registered namespaces array: + * - Use the object directly if it's already instantiated + * - Creates the object via ObjectManager if it's a class name + * 3. Returns the current instance if no resolution is possible + * + * @param string $namespace The namespace/class name to load, or a key from registered namespaces + * @return ActionManager Returns a new ActionManager instance or the current instance as fallback + */ + public function load(string $namespace): ActionManager + { + if (class_exists($namespace)) { + return $this->actionManagerFactory->create([ + 'action' => $namespace + ]); + } elseif (array_key_exists($namespace, $this->namespaces)) { + if (is_object($this->namespaces[$namespace])) { + return $this->actionManagerFactory->create([ + 'action' => $this->namespaces[$namespace] + ]); + } + + return $this->actionManagerFactory->create([ + 'action' => Factory::create($this->namespaces[$namespace]) + ]); + } + + return $this; + } +} diff --git a/lib/Magewire/Features/SupportMagewireCompiling/View/Management/CompilerManager.php b/lib/Magewire/Features/SupportMagewireCompiling/View/Management/CompilerManager.php new file mode 100644 index 00000000..a77b5f33 --- /dev/null +++ b/lib/Magewire/Features/SupportMagewireCompiling/View/Management/CompilerManager.php @@ -0,0 +1,46 @@ +directiveManager; + } + + public function file(): FileManager + { + return $this->fileManager; + } + + public function factory(): CompilerFactory + { + return $this->compilerFactory; + } + + public function utils(): CompilerUtils + { + return $this->compilerUtils; + } +} diff --git a/lib/Magewire/Features/SupportMagewireCompiling/View/Management/DirectiveManager.php b/lib/Magewire/Features/SupportMagewireCompiling/View/Management/DirectiveManager.php new file mode 100644 index 00000000..4090710a --- /dev/null +++ b/lib/Magewire/Features/SupportMagewireCompiling/View/Management/DirectiveManager.php @@ -0,0 +1,56 @@ + $areas + */ + public function __construct( + private readonly DirectiveArea $directives, + private array $areas = [] + ) { + } + + public function area(string|null $name = null, DirectiveArea|null $area = null): DirectiveArea|null + { + if (is_string($name) && strlen($name) !== 0) { + if ($area) { + return $this->areas[$name] = $area; + } + + return $this->areas[$name] ?? null; + } + + return $this->directives; + } + + public function tryToLocateArea(string $subject): array + { + foreach ($this->areas as $area => $object) { + if (str_starts_with($subject, $area)) { + return [$object, lcfirst(substr($subject, strlen($area)))]; + } + } + + $object = $this->directives->responsibilities()->has($subject); + + if ($object) { + return [$this->directives, $subject]; + } + + return [null, $subject]; + } +} diff --git a/lib/Magewire/Features/SupportMagewireCompiling/View/Management/FileManager.php b/lib/Magewire/Features/SupportMagewireCompiling/View/Management/FileManager.php new file mode 100644 index 00000000..fcfef6af --- /dev/null +++ b/lib/Magewire/Features/SupportMagewireCompiling/View/Management/FileManager.php @@ -0,0 +1,55 @@ +filesystem; + } + + /** + * Get the path to the compiled version of a view. + */ + public function generateFilePath(string $path, bool $includeResourceDir = true): string + { + $resource = $this->getResourcePath(); + $path = sha1($path) . '.phtml'; + + return $includeResourceDir ? $resource . DIRECTORY_SEPARATOR . $path : $path; + } + + protected function getResourcePath(): string + { + return ( + $this->directoryList->getPath(\Magento\Framework\App\Filesystem\DirectoryList::GENERATED) + . DIRECTORY_SEPARATOR + . 'code' + . DIRECTORY_SEPARATOR + . 'Magewirephp' + . DIRECTORY_SEPARATOR + . 'Magewire' + . DIRECTORY_SEPARATOR + . 'views' + ); + } +} diff --git a/lib/Magewire/Features/SupportMagewireCompiling/View/Management/SlotsManager.php b/lib/Magewire/Features/SupportMagewireCompiling/View/Management/SlotsManager.php new file mode 100644 index 00000000..b0e7b85c --- /dev/null +++ b/lib/Magewire/Features/SupportMagewireCompiling/View/Management/SlotsManager.php @@ -0,0 +1,39 @@ +getNameInLayout() . '_' . $name; + + // if (isset($this->items[$id])) { + // throw new AlreadyExistsException(); + // } + + $this->items[$id] = $content; + + return $id; + } + + public function render(string $name, AbstractBlock $block): string + { + $id = $block->getNameInLayout() . '_' . $name; + + return $this->items[$id] ?? 'NONO'; + } +} diff --git a/lib/Magewire/Features/SupportMagewireCompiling/View/Management/StubsManager.php b/lib/Magewire/Features/SupportMagewireCompiling/View/Management/StubsManager.php new file mode 100644 index 00000000..f19282ff --- /dev/null +++ b/lib/Magewire/Features/SupportMagewireCompiling/View/Management/StubsManager.php @@ -0,0 +1,29 @@ +collector->collect(); + + return $stubs[$namespace] ?? null; + } +} diff --git a/lib/Magewire/Features/SupportMagewireCompiling/View/ScopeDirective.php b/lib/Magewire/Features/SupportMagewireCompiling/View/ScopeDirective.php new file mode 100644 index 00000000..8a3a4c1e --- /dev/null +++ b/lib/Magewire/Features/SupportMagewireCompiling/View/ScopeDirective.php @@ -0,0 +1,75 @@ +scopeResponsibilities[$directive] ?? null )) { + $reflection = new ReflectionClass($this); + + foreach ($reflection->getMethods() as $method) { + $attributes = $method->getAttributes(ScopeDirectiveChain::class); + $attribute = $attributes[0] ?? null ? $attributes[0]->newInstance() : null; + + if ($attribute) { + $this->scopeResponsibilities[$method->getName()] = $attribute->methods; + } + } + } + + return $this->scopeResponsibilities[$directive] ?? []; + } + + /** + * Start a new scoped block and return the generated variable name. + */ + protected function variableScopeStart(string|null $var = null): string + { + $var ??= Random::alphabetical(10); + + $this->scopeVariables[] = $var; + + return $var; + } + + /** + * End the most recent scope and return its variable name. + */ + protected function variableScopeEnd(): string + { + if (empty($this->scopeVariables)) { + throw new LogicException('Trying to end a scope without a matching start.'); + } + + return array_pop($this->scopeVariables); + } + + /** + * Get the current (most recent) scoped variable name without ending it. + */ + protected function variableScope(): string|null + { + return $this->scopeVariables[count($this->scopeVariables) - 1] ?? null; + } +} diff --git a/lib/Magewire/Features/SupportMagewireCompiling/View/ScopeDirectiveChain.php b/lib/Magewire/Features/SupportMagewireCompiling/View/ScopeDirectiveChain.php new file mode 100644 index 00000000..1e527066 --- /dev/null +++ b/lib/Magewire/Features/SupportMagewireCompiling/View/ScopeDirectiveChain.php @@ -0,0 +1,24 @@ +fragmentFactory; + } + + public function elements(): FragmentElementFactory + { + return $this->fragments()->elements(); + } +} diff --git a/lib/Magewire/Features/SupportMagewireCompiling/View/ViewStub.php b/lib/Magewire/Features/SupportMagewireCompiling/View/ViewStub.php new file mode 100644 index 00000000..d27a7523 --- /dev/null +++ b/lib/Magewire/Features/SupportMagewireCompiling/View/ViewStub.php @@ -0,0 +1,34 @@ +stub->getName(), '.stub')); + } + + public function getContent(): string + { + return $this->content ??= file_get_contents($this->stub->getFilename()); + } +} diff --git a/lib/Magewire/Features/SupportMagewireExceptionHandling/SupportMagewireExceptionHandling.php b/lib/Magewire/Features/SupportMagewireExceptionHandling/SupportMagewireExceptionHandling.php new file mode 100644 index 00000000..0b7a138b --- /dev/null +++ b/lib/Magewire/Features/SupportMagewireExceptionHandling/SupportMagewireExceptionHandling.php @@ -0,0 +1,28 @@ +type, $arguments); + } + + /** + * Create and returns a new instance of a named Flake block bound with + * a Magewire component and its resolver. + */ + public function createByName(string $name, array $data = []): AbstractBlock|false + { + $layout = $this->layoutManager->decorator()->decorateForPagelessBlockFetching($this->layoutManager->factory()->create()); + + $layout->getUpdate()->addHandle('magewire_flakes'); + $block = $layout->getBlock($name); + + if ($block instanceof AbstractBlock) { + $data['magewire'] ??= $this->create(); + $data['magewire:resolver'] ??= 'flake'; + $data['magewire:alias'] ??= $name; + + $block->addData($data); + } + + return $block; + } +} diff --git a/lib/Magewire/Features/SupportMagewireFlakes/Mechanisms/ResolveComponent/ComponentResolver/FlakeResolver.php b/lib/Magewire/Features/SupportMagewireFlakes/Mechanisms/ResolveComponent/ComponentResolver/FlakeResolver.php new file mode 100644 index 00000000..47a7b546 --- /dev/null +++ b/lib/Magewire/Features/SupportMagewireFlakes/Mechanisms/ResolveComponent/ComponentResolver/FlakeResolver.php @@ -0,0 +1,70 @@ +conditions, $this->layoutBlockArgumentsFactory, $this->layoutManager); + } + + public function complies(mixed $block, mixed $magewire = null): bool + { + $this->conditions()->if(static fn () => $block instanceof AbstractBlock); + /** @var AbstractBlock $block */ + $this->conditions()->if(static fn () => in_array(static::FLAKES_HANDLE, $block->getLayout()->getUpdate()->getHandles())); + + return $this->conditions()->evaluate($block, $magewire); + } + + public function construct(AbstractBlock $block): AbstractBlock + { + /* + * For the situations where a Flake exists, but is by layout not bound with a Magewire arguments, + * missing the actual object. Flakes do not always have to be a Magewire component, but when not, + * it will become an empty Magewire Flake component to at least give it all it's powers. + */ + if ($block->hasData('magewire:alias') && ! $block->hasData('magewire')) { + $block->setData('magewire', $this->flakeFactory->create()); + } + + return parent::construct($block); + } + + protected function canMemorizeLayoutHandles(): bool + { + return false; + } + + protected function recoverLayoutHandles(Snapshot $snapshot): array + { + return ['magewire_flakes']; + } +} diff --git a/lib/Magewire/Features/SupportMagewireFlakes/SupportMagewireFlakes.php b/lib/Magewire/Features/SupportMagewireFlakes/SupportMagewireFlakes.php new file mode 100644 index 00000000..88854ca9 --- /dev/null +++ b/lib/Magewire/Features/SupportMagewireFlakes/SupportMagewireFlakes.php @@ -0,0 +1,43 @@ +magewireBlock(); + + if (is_array($memo['flake'] ?? null)) { + $block->setData('magewire:flake', $memo['flake']); + } + }); + + on('dehydrate', static function (Component $component, ComponentContext $context) { + $metadata = $component->magewireBlock()->getData('magewire:flake'); + + if (is_array($metadata) && is_array($metadata['element'] ?? null)) { + $context->pushMemo('flake', $metadata['element'], 'element'); + } + }); + } +} diff --git a/lib/Magewire/Features/SupportMagewireFlakes/View/Action/Magewire/FlakeViewAction.php b/lib/Magewire/Features/SupportMagewireFlakes/View/Action/Magewire/FlakeViewAction.php new file mode 100644 index 00000000..80235cc8 --- /dev/null +++ b/lib/Magewire/Features/SupportMagewireFlakes/View/Action/Magewire/FlakeViewAction.php @@ -0,0 +1,61 @@ +attributesFactory->create()->fill($data); + + $data->each(static function (DataArray $array, $value, $key) use ($variables) { + if (str_starts_with($value, '$')) { + $value = trim($value, '$'); + + if (array_key_exists($value, $variables)) { + $array->put($key, $variables[$value]); + } + } + }); + + $data = $data->all(); + $block = $this->flakeFactory->createByName($flake, $data); + + if (! $block) { + throw new RuntimeException('Flake block can not be found.'); // @todo + } + + $block->setData('magewire:alias', $flake); + + $block->setNameInLayout($data['magewire:name']); + return $block; + } +} diff --git a/lib/Magewire/Features/SupportMagewireFlakes/View/Compiler/FlakeCompilerBu.php b/lib/Magewire/Features/SupportMagewireFlakes/View/Compiler/FlakeCompilerBu.php new file mode 100644 index 00000000..84a45d66 --- /dev/null +++ b/lib/Magewire/Features/SupportMagewireFlakes/View/Compiler/FlakeCompilerBu.php @@ -0,0 +1,195 @@ +parseTags($value, 0, 50)[0]; + } + + protected function parseTags(string $value, int $iterations, int $maxIterations): array + { + if ($iterations >= $maxIterations) { + return [$value, false]; + } + + $result = ''; + $pos = 0; + $length = strlen($value); + + while ($pos < $length) { + // Try to match self-closing tag: . + if (preg_match('/\/]|\/(?!>))*)\s*\/\s*>/', $value, $match, 0, $pos)) { + $tagStart = strpos($value, $match[0], $pos); + + // Check if there's an opening tag before this position. + $nextOpeningMatch = preg_match('/\/]|\/(?!>))*)\s*>/', $value, $openingMatch, 0, $pos); + $nextOpeningPos = $nextOpeningMatch ? strpos($value, $openingMatch[0], $pos) : PHP_INT_MAX; + + // If self-closing tag comes first, process it. + if ($tagStart <= $nextOpeningPos) { + $component = $match[1]; + $attributes = $match[2]; + + // Append content before the tag + $result .= substr($value, $pos, $tagStart - $pos); + + // Compile the self-closing tag. + $compiled = $this->compileFlake($component, $attributes, false); + + $result .= $compiled; + $pos = $tagStart + strlen($match[0]); + continue; + } + } + + // Try to match opening tag: . + if (preg_match('/\/]|\/(?!>))*)\s*>/', $value, $match, 0, $pos)) { + $tagStart = strpos($value, $match[0], $pos); + $component = $match[1]; + $attributes = $match[2]; + $openingTagEnd = $tagStart + strlen($match[0]); + + // Find the corresponding closing tag. + $closingTagPattern = '/<\/flake:' . preg_quote($component) . '\s*>/'; + + if (preg_match($closingTagPattern, $value, $closingMatch, 0, $openingTagEnd)) { + $closingTagStart = strpos($value, $closingMatch[0], $openingTagEnd); + $content = substr($value, $openingTagEnd, $closingTagStart - $openingTagEnd); + $closingTagEnd = $closingTagStart + strlen($closingMatch[0]); + + // Append content before the tag. + $result .= substr($value, $pos, $tagStart - $pos); + // Recursively parse nested magewire tags in content. + [$content, $changes] = $this->parseTags($content, $iterations + 1, $maxIterations); + + $compiled = $this->compileFlake($component, $attributes, empty($content) ? false : $content); + + $result .= $compiled; + $pos = $closingTagEnd; + continue; + } + } + + // No more magewire tags found, append the rest + $result .= substr($value, $pos); + break; + } + + return [$result, $result !== $value]; + } + + protected function compileFlake(string $component, string $attributes, string|false $content): string + { + $attributes = trim($attributes); + + $parse = $this->domElementParser + ->newInstance() + ->parse($attributes) + ->attributes() + // Set a random unique component id when none is provided. + ->default('magewire:id', Random::string(10)) + // Map the mw:id as the mw:name when it doesn't exist. + ->default('magewire:name', ':magewire:id') + // Use the component name as the name alias. + ->default('magewire:alias', $component) + // 1. Take care of all data groups (e.g., mount, prop) + ->each(function (DataArray $array, $value, $key) { + if ($to = $this->renameToMagewireAttributeMeta($key)) { + $array->rename($key, $to); + } + }) + ->each(static function (DataArray $array, $value, $key) { + $subject = 'data'; + + if ($key === $subject) { + $map = []; + foreach ($array->get($subject) as $name => $value) { + $map[$subject][substr($name, strlen(':'))] = $value; + } + + // Try and replace the subject value when already exists. + $array->put($subject, $map[$subject] ?? []); + } + }); + + // Fetch and transform DOM attributes. + $attributes = $parse->fetch(static fn ($value, $key) => str_starts_with($key, 'attr:')); + + $arguments = [ + 'flake' => $component, + // Accept everything as data, except those who start with attr:. + 'data' => $parse->fetch(static function ($value, $key) { + return ! str_starts_with($key, 'attr:'); + }), + 'metadata' => [ + 'attributes' => array_combine(array_map(static fn ($key) => substr($key, 5), array_keys($attributes)), $attributes) + ] + ]; + + if ($content) { + return '@flake(arguments: ' . json_encode($arguments) . ')' . $content . '@endFlake'; + } + + return '@flakeSingleLiner(arguments: ' . json_encode($arguments) . ')'; + } + + private function renameToMagewireAttributeMeta(string $attribute): string|null + { + $parts = explode(':', $attribute); + + // @todo Hardcoded for the time being until more specifics gets added. + $map = [ + 'name' => [ + 'prefix' => 'magewire:', + 'expects' => 1 + ], + 'prop' => [ + 'prefix' => 'magewire.', + 'expects' => 2 + ], + 'mount' => [ + 'prefix' => 'magewire:mount:', + 'expects' => 2 + ] + ]; + + // Magewire group indicator. + $group = $parts[0]; + // The number of parts it expects per attribute. + $expects = $map[$group]['expects'] ?? null; + + if (is_int($expects) && is_array($parts) && count($parts) === $expects) { + // What the new value should be prefixed with when found. + $prefix = $map[$group]['prefix']; + + if (in_array($group, array_keys($map), true)) { + return $prefix . $parts[$expects - 1]; + } + } + + return null; + } +} diff --git a/lib/Magewire/Features/SupportMagewireFlakes/View/Fragment/Element/Flake.php b/lib/Magewire/Features/SupportMagewireFlakes/View/Fragment/Element/Flake.php new file mode 100644 index 00000000..10e874ae --- /dev/null +++ b/lib/Magewire/Features/SupportMagewireFlakes/View/Fragment/Element/Flake.php @@ -0,0 +1,73 @@ +slotsRegistry->update('default', $this->output); + + try { + $flake = $this->flakeFactory->createByName($this->variant, [ + 'magewire:id' => Random::alphabetical(4), + 'magewire:name' => Random::alphabetical(4) + ]); + + if ($flake === false) { + throw new ComponentNotFoundException(sprintf('Magewire: Flake "%s" could not be found or doesnt exist.', $this->variant)); + } + + // Render the final Flake component. + echo $flake->toHtml(); + } catch (Throwable $exception) { + $this->logger->critical($exception->getMessage(), ['exception' => $exception]); + + if ($this->applicationState->getMode() !== ApplicationState::MODE_PRODUCTION) { + echo ''; + } + } + + return $this; + } +} diff --git a/lib/Magewire/Features/SupportMagewireLoaders/HandlesMagewireLoaders.php b/lib/Magewire/Features/SupportMagewireLoaders/HandlesMagewireLoaders.php new file mode 100644 index 00000000..594bea8f --- /dev/null +++ b/lib/Magewire/Features/SupportMagewireLoaders/HandlesMagewireLoaders.php @@ -0,0 +1,26 @@ +loader; + } +} diff --git a/lib/Magewire/Features/SupportMagewireLoaders/SupportMagewireLoaders.php b/lib/Magewire/Features/SupportMagewireLoaders/SupportMagewireLoaders.php new file mode 100644 index 00000000..fa4074f1 --- /dev/null +++ b/lib/Magewire/Features/SupportMagewireLoaders/SupportMagewireLoaders.php @@ -0,0 +1,44 @@ +component->getLoader(); + + if ($loader) { + if (is_array($loader)) { + $loader = map_with_keys(static function ($value, $key) { + if (is_string($value)) { + $value = [$value]; + } + if (is_array($value)) { + $value = array_map('__', array_filter($value, 'is_string')); + } + + return [$key => $value]; + }, $loader); + } elseif (is_string($loader)) { + $loader = __($loader); + } + + $context->pushEffect('loader', $loader); + } + } +} diff --git a/lib/Magewire/Features/SupportMagewireNestingComponents/SupportMagewireNestingComponents.php b/lib/Magewire/Features/SupportMagewireNestingComponents/SupportMagewireNestingComponents.php new file mode 100644 index 00000000..3426f691 --- /dev/null +++ b/lib/Magewire/Features/SupportMagewireNestingComponents/SupportMagewireNestingComponents.php @@ -0,0 +1,74 @@ +dictionary(); + + if (isset($dictionary['magewire'])) { + return; + } + + $closest = $this->renderLifecycleManager->target('magewire')->closestComponent($dto->block()); + + if ($closest) { + $dto->dictionary(['magewire' => $closest]); + } + + return static function ($html) { + return $html; + }; + }); + + on('magewire:component:construct', function () { + // Returns a callable that will execute after the component is constructed. + return function (AbstractBlock $block) { + $component = $block->getData('magewire'); + + if ($component instanceof Component) { + $this->renderLifecycleManager->target('magewire')->bind($component); + } + + return $block; + }; + }); + + on('magewire:component:reconstruct', function () { + // Returns a callable that will execute after the component is reconstructed. + return function (AbstractBlock $block) { + $component = $block->getData('magewire'); + + if ($component instanceof Component) { + $this->renderLifecycleManager->target('magewire')->bind($component); + } + + return $block; + }; + }); + } +} diff --git a/lib/Magewire/Features/SupportMagewireNotifications/HandlesMagewireNotifications.php b/lib/Magewire/Features/SupportMagewireNotifications/HandlesMagewireNotifications.php new file mode 100644 index 00000000..ddbc74e6 --- /dev/null +++ b/lib/Magewire/Features/SupportMagewireNotifications/HandlesMagewireNotifications.php @@ -0,0 +1,24 @@ +magewireNotifications ??= Factory::create(MagewireNotifications::class); + } +} diff --git a/lib/Magewire/Features/SupportMagewireNotifications/MagewireNotifications.php b/lib/Magewire/Features/SupportMagewireNotifications/MagewireNotifications.php new file mode 100644 index 00000000..e84b3266 --- /dev/null +++ b/lib/Magewire/Features/SupportMagewireNotifications/MagewireNotifications.php @@ -0,0 +1,48 @@ +notifications[$name ?? Random::string()] ??= Factory::create(Notification::class, [ + 'notification' => $notification + ]); + } + + public function unset(string $name): static + { + if (isset($this->notifications[$name])) { + unset($this->notifications[$name]); + } + + return $this; + } + + public function fetch(): array + { + return $this->notifications; + } + + public function count(): int + { + return count($this->notifications); + } +} diff --git a/lib/Magewire/Features/SupportMagewireNotifications/Notification.php b/lib/Magewire/Features/SupportMagewireNotifications/Notification.php new file mode 100644 index 00000000..c04a1212 --- /dev/null +++ b/lib/Magewire/Features/SupportMagewireNotifications/Notification.php @@ -0,0 +1,99 @@ +withMessage($notification); + } + + public function asSuccess(): static + { + return $this->as(NotificationType::Success); + } + + public function asError(): static + { + return $this->as(NotificationType::Error); + } + + public function asWarning(): static + { + return $this->as(NotificationType::Warning); + } + + public function asNotice(): static + { + return $this->as(NotificationType::Notice); + } + + public function as(NotificationType $type): static + { + $this->type = $type; + return $this; + } + + public function withMessage(string|Phrase $message): static + { + $this->message = is_string($message) ? __($message) : $message; + return $this; + } + + public function withTitle(string|Phrase $title): static + { + $this->title = is_string($title) ? __($title) : $title; + return $this; + } + + public function withoutTitle(): static + { + $this->title = null; + return $this; + } + + public function withDuration(int $milliseconds): static + { + $this->duration = $milliseconds; + return $this; + } + + public function notification(): Phrase + { + return $this->message; + } + + public function type(): NotificationType + { + return $this->type; + } + + public function title(): Phrase|null + { + return $this->title; + } + + public function duration(): int + { + return $this->duration; + } +} diff --git a/lib/Magewire/Features/SupportMagewireNotifications/NotificationType.php b/lib/Magewire/Features/SupportMagewireNotifications/NotificationType.php new file mode 100644 index 00000000..5f292c82 --- /dev/null +++ b/lib/Magewire/Features/SupportMagewireNotifications/NotificationType.php @@ -0,0 +1,20 @@ +component(); + + if ($component && $component->magewireNotifications()->count() > 0) { + $context->pushEffect('dispatches', [ + 'name' => 'magewire:notifications', + 'params' => $this->mapNotifications($component->magewireNotifications()) + ]); + } + } + + private function mapNotifications(MagewireNotifications $notifications): array + { + return array_map(static function (Notification $message) { + return [ + 'text' => $message->notification()->render(), + 'type' => $message->type() + ]; + }, $notifications->fetch()); + } +} diff --git a/lib/Magewire/Features/SupportMagewireRateLimiting/Config/Source/RateLimitingVariant.php b/lib/Magewire/Features/SupportMagewireRateLimiting/Config/Source/RateLimitingVariant.php new file mode 100644 index 00000000..619286e6 --- /dev/null +++ b/lib/Magewire/Features/SupportMagewireRateLimiting/Config/Source/RateLimitingVariant.php @@ -0,0 +1,30 @@ + self::COMPONENTS_ONLY, 'label' => 'Components only'], + ['value' => self::REQUESTS_ONLY, 'label' => 'Requests only'], + ['value' => self::NONE, 'label' => 'None'] + ]; + } +} diff --git a/lib/Magewire/Features/SupportMagewireRateLimiting/Config/Source/RequestsScope.php b/lib/Magewire/Features/SupportMagewireRateLimiting/Config/Source/RequestsScope.php new file mode 100644 index 00000000..7cdd6721 --- /dev/null +++ b/lib/Magewire/Features/SupportMagewireRateLimiting/Config/Source/RequestsScope.php @@ -0,0 +1,28 @@ + self::ISOLATED, 'label' => 'Isolated'], + ['value' => self::SHARED, 'label' => 'Shared'] + ]; + } +} diff --git a/lib/Magewire/Features/SupportMagewireRateLimiting/Exceptions/TooManyRequestsException.php b/lib/Magewire/Features/SupportMagewireRateLimiting/Exceptions/TooManyRequestsException.php new file mode 100644 index 00000000..da6c1fe0 --- /dev/null +++ b/lib/Magewire/Features/SupportMagewireRateLimiting/Exceptions/TooManyRequestsException.php @@ -0,0 +1,18 @@ +dateTime->gmtTimestamp(); + + $hits = $this->getCleanHits($key, $decaySeconds, $currentTime); + $hits[] = $currentTime; + + $this->storage->set($key, $hits, $decaySeconds); + + return count($hits); + } + + /** + * Validate if key is within the rate limit. + */ + public function validate(string $key, int $maxAttempts, int $decaySeconds = 60): bool + { + $hits = $this->getCleanHits($key, $decaySeconds, $this->dateTime->gmtTimestamp()); + + return count($hits) < $maxAttempts; + } + + /** + * Reset rate limit for given key + */ + public function reset(string $key): bool + { + return $this->storage->unset($key); + } + + /** + * Get current attempts for a key. + */ + public function getAttempts(string $key, int $decaySeconds = 60): int + { + return count($this->getCleanHits($key, $decaySeconds, $this->dateTime->gmtTimestamp())); + } + + /** + * Get remaining attempts for a key. + */ + public function getRemainingAttempts(string $key, int $maxAttempts, int $decaySeconds = 60): int + { + $current = $this->getAttempts($key, $decaySeconds); + + return max(0, $maxAttempts - $current); + } + + /** + * Get clean hits (remove expired ones). + */ + private function getCleanHits(string $key, int $decaySeconds, int $currentTime): array + { + return array_filter($this->storage->get($key), static fn ($timestamp) => ( $currentTime - $timestamp ) < $decaySeconds); + } +} diff --git a/lib/Magewire/Features/SupportMagewireRateLimiting/RateLimiterCache.php b/lib/Magewire/Features/SupportMagewireRateLimiting/RateLimiterCache.php new file mode 100644 index 00000000..034cba8d --- /dev/null +++ b/lib/Magewire/Features/SupportMagewireRateLimiting/RateLimiterCache.php @@ -0,0 +1,19 @@ +getRateLimitingVariant() === RateLimitingVariant::REQUESTS_ONLY; + } + + public function canRateLimitComponents(): bool + { + return $this->getRateLimitingVariant() === RateLimitingVariant::COMPONENTS_ONLY; + } + + public function isSharedScope(): bool + { + return $this->getRateLimitingScope() === RequestsScope::SHARED; + } + + public function isIsolatedScope(): bool + { + return $this->getRateLimitingScope() === RequestsScope::ISOLATED; + } + + public function getRequestsMaxAttempts(): int + { + return (int) ($this->config()->getFeaturesGroupValue('rate_limiting/requests/max_attempts') ?? 4); + } + + public function getRequestsDecaySeconds(): int + { + return (int) ($this->config()->getFeaturesGroupValue('rate_limiting/requests/decay_seconds') ?? 5); + } + + protected function getRateLimitingVariant(): string + { + return $this->config()->getFeaturesGroupValue('rate_limiting/variant') ?? RateLimitingVariant::NONE; + } + + protected function getRateLimitingScope(): string + { + return $this->config()->getFeaturesGroupValue('rate_limiting/requests/scope') ?? RequestsScope::SHARED; + } +} diff --git a/lib/Magewire/Features/SupportMagewireRateLimiting/RateLimiterExceptionHandler.php b/lib/Magewire/Features/SupportMagewireRateLimiting/RateLimiterExceptionHandler.php new file mode 100644 index 00000000..5463a8f9 --- /dev/null +++ b/lib/Magewire/Features/SupportMagewireRateLimiting/RateLimiterExceptionHandler.php @@ -0,0 +1,34 @@ +setBody('Too Many Requests.'); + $response->setHttpResponseCode(429); + + return $response; + }; + } + + return $exception; + } +} diff --git a/lib/Magewire/Features/SupportMagewireRateLimiting/RateLimiterStorageInterface.php b/lib/Magewire/Features/SupportMagewireRateLimiting/RateLimiterStorageInterface.php new file mode 100644 index 00000000..d97eb137 --- /dev/null +++ b/lib/Magewire/Features/SupportMagewireRateLimiting/RateLimiterStorageInterface.php @@ -0,0 +1,30 @@ +cache->fetch(); + + return $storage[$key] ?? []; + } + + public function set(string $key, array $data, int|null $ttl = null): bool + { + $storage = $this->cache->fetch(); + $storage[$key] = $data; + + return $this->cache->save($storage, $ttl); + } + + public function unset(string $key): bool + { + $storage = $this->cache->fetch(); + unset($storage[$key]); + + return $this->cache->save($storage); + } +} diff --git a/lib/Magewire/Features/SupportMagewireRateLimiting/SupportMagewireRateLimiting.php b/lib/Magewire/Features/SupportMagewireRateLimiting/SupportMagewireRateLimiting.php new file mode 100644 index 00000000..c42097b9 --- /dev/null +++ b/lib/Magewire/Features/SupportMagewireRateLimiting/SupportMagewireRateLimiting.php @@ -0,0 +1,53 @@ +rateLimiterConfig->canRateLimitRequests()) { + on('request', function (array $payload) { + $context = $payload[0] ?? false; + + // Global scope rate limiting validation. + if ($context && ! $this->rateLimiter->validateWithComponentRequestContext($context)) { + throw new TooManyRequestsException(); + } + }); + } elseif ($this->rateLimiterConfig->canRateLimitComponents()) { + on('magewire:component:reconstruct', function () { + return function (Template $block) { + $component = $block->getData('magewire'); + + // Component scope rate limiting validation. + if ($component instanceof Component && ! $this->rateLimiter->validateWithComponent($component)) { + throw new TooManyRequestsException(); + } + }; + }); + } + } +} diff --git a/lib/Magewire/Features/SupportMagewireRateLimiting/UpdateRequestRateLimiter.php b/lib/Magewire/Features/SupportMagewireRateLimiting/UpdateRequestRateLimiter.php new file mode 100644 index 00000000..d4974491 --- /dev/null +++ b/lib/Magewire/Features/SupportMagewireRateLimiting/UpdateRequestRateLimiter.php @@ -0,0 +1,67 @@ +storage, $this->datetime); + } + + public function validateWithComponentRequestContext(ComponentRequestContext $componentRequestContext): bool + { + $key = $this->generateKeyByRequestContext($componentRequestContext); + $attempts = $this->rateLimiterConfig->getRequestsMaxAttempts(); + $decay = $this->rateLimiterConfig->getRequestsDecaySeconds(); + + if ($result = $this->validate($key, $attempts, $decay)) { + $this->hit($key); + } + + return $result; + } + + public function validateWithComponent(Component $component): bool + { + $key = $this->generateKeyByComponent($component); + + if ($result = $this->validate($key, 4, 5)) { + $this->hit($key); + } + + return $result; + } + + private function generateKeyByRequestContext(ComponentRequestContext $componentRequestContext): string + { + $key = '123@RL'; + + if ($this->rateLimiterConfig->isIsolatedScope()) { + $key .= $componentRequestContext->getSnapshot()->getMemoValue('id'); + } + + return $key; + } + + private function generateKeyByComponent(Component $component): string + { + return '123@RLC' . $component->id(); + } +} diff --git a/lib/Magewire/Features/SupportMagewireViewModel/HandlesMagewireViewModel.php b/lib/Magewire/Features/SupportMagewireViewModel/HandlesMagewireViewModel.php new file mode 100644 index 00000000..2dd4dc5f --- /dev/null +++ b/lib/Magewire/Features/SupportMagewireViewModel/HandlesMagewireViewModel.php @@ -0,0 +1,34 @@ +magewireViewModel ??= Factory::get(MagewireViewModel::class); + } +} diff --git a/lib/Magewire/Features/SupportMagewireViewModel/MagewireViewModel.php b/lib/Magewire/Features/SupportMagewireViewModel/MagewireViewModel.php new file mode 100644 index 00000000..83434185 --- /dev/null +++ b/lib/Magewire/Features/SupportMagewireViewModel/MagewireViewModel.php @@ -0,0 +1,27 @@ +utils->$name($arguments) : $this->utils; + } +} diff --git a/lib/Magewire/Features/SupportMagewireViewModel/MagewireViewModelInterface.php b/lib/Magewire/Features/SupportMagewireViewModel/MagewireViewModelInterface.php new file mode 100644 index 00000000..5c8a4431 --- /dev/null +++ b/lib/Magewire/Features/SupportMagewireViewModel/MagewireViewModelInterface.php @@ -0,0 +1,19 @@ +isRootMagewireBlock($block)) { + $this->includeMagewireViewModel = true; + } + + /* + * Ensures the Magewire view model is automatically bound to the block as a "view_model" data argument, + * if it hasn't been set or is not an instance of the expected Magewire view model class. + * + * This reduces the need for manual binding, which is often repetitive since many sibling blocks depend on it. + * + * Why bind a view model instead of exposing a global template variable like $magewireViewModel? + * Because when a block is moved outside its parent "magewire" wrapper, it still needs to maintain compatibility. + * Using a shared view model ensures this compatibility without requiring changes to the template. + * + * Relying on global dictionary variables would force template modifications in such cases—something this method avoids. + */ + if ($this->includeMagewireViewModel) { + $model = $block->getData('view_model'); + + if ($model && ! $model instanceof MagewireViewModelInterface) { + throw new InvalidArgumentException('View model must be an instance of MagewireViewModelInterface'); + } elseif ($model === null) { + $block->setData('view_model', $this->magewireViewModelFactory->create()); + } + } + }); + } + + private function isRootMagewireBlock(AbstractBlock $block): bool + { + return $block->getNameInLayout() === 'magewire'; + } +} diff --git a/lib/Magewire/Mechanisms.php b/lib/Magewire/Mechanisms.php new file mode 100644 index 00000000..ccf5d14c --- /dev/null +++ b/lib/Magewire/Mechanisms.php @@ -0,0 +1,31 @@ +boot(); + } + }; + } + + protected function getBootModeFallback(): ServiceTypeItemBootMode + { + return ServiceTypeItemBootMode::LAZY; + } +} diff --git a/lib/Magewire/Mechanisms/FrontendAssets/FrontendAssetsFacade.php b/lib/Magewire/Mechanisms/FrontendAssets/FrontendAssetsFacade.php new file mode 100644 index 00000000..b2832aec --- /dev/null +++ b/lib/Magewire/Mechanisms/FrontendAssets/FrontendAssetsFacade.php @@ -0,0 +1,32 @@ +mechanism->returnJavaScriptAsFile(); + } + + public function getMagewireScriptAttributes(): array + { + return $this->mechanism->getDataByPath('script.html_attributes', []); + } +} diff --git a/lib/Magewire/Mechanisms/FrontendAssets/FrontendAssetsViewModel.php b/lib/Magewire/Mechanisms/FrontendAssets/FrontendAssetsViewModel.php new file mode 100644 index 00000000..f0ac62e3 --- /dev/null +++ b/lib/Magewire/Mechanisms/FrontendAssets/FrontendAssetsViewModel.php @@ -0,0 +1,43 @@ +frontendAssetsMechanism->returnJavaScriptAsFile(); + } + + public function getScriptAttributes(): string + { + $attributes = $this->frontendAssetsMechanism->getDataByPath('script.html_attributes', []); + + return implode(' ', array_map( + function ($attribute, string|null $expression) { + return $expression === null ? $attribute : sprintf('%s="%s"', $attribute, $this->escaper->escapeHtmlAttr($expression)); + }, + array_keys($attributes), + array_values($attributes) + )); + } +} diff --git a/lib/Magewire/Mechanisms/HandleComponents/ComponentContext/Effects.php b/lib/Magewire/Mechanisms/HandleComponents/ComponentContext/Effects.php new file mode 100644 index 00000000..0791969f --- /dev/null +++ b/lib/Magewire/Mechanisms/HandleComponents/ComponentContext/Effects.php @@ -0,0 +1,39 @@ +setData($key, $value); + } + + function exclude($key): static + { + return $this->unsetData($key); + } +} diff --git a/lib/Magewire/Mechanisms/HandleComponents/ComponentContext/Memo.php b/lib/Magewire/Mechanisms/HandleComponents/ComponentContext/Memo.php new file mode 100644 index 00000000..34e52376 --- /dev/null +++ b/lib/Magewire/Mechanisms/HandleComponents/ComponentContext/Memo.php @@ -0,0 +1,28 @@ +mechanism->update($snapshot->toArray(), $updates, $calls, $block); + } + + /** + * @throws RuntimeException + */ + public function mount(string $name, array $params, AbstractBlock $block, Component $component): Closure + { + return $this->mechanism->mount($name, $params, $block->getCacheKey(), $block, $component); + } +} diff --git a/lib/Magewire/Mechanisms/HandleComponents/Snapshot.php b/lib/Magewire/Mechanisms/HandleComponents/Snapshot.php new file mode 100644 index 00000000..f0617946 --- /dev/null +++ b/lib/Magewire/Mechanisms/HandleComponents/Snapshot.php @@ -0,0 +1,159 @@ +data = $data; + + return $this; + } + + /** + * @return array + */ + function getData(): array + { + return $this->data; + } + + /** + * @param array $memo + * @return $this + */ + function setMemo(array $memo): self + { + $this->memo = $memo; + + return $this; + } + + /** + * @return array + */ + function getMemo(): array + { + return $this->memo; + } + + /** + * @throws FileSystemException + * @throws RuntimeException + */ + function generateChecksum(): self + { + $this->checksum = $this->checksumHandler->generate($this->toArray(['checksum'])); + + return $this; + } + + /** + * @return string + */ + function getChecksum(): string + { + return $this->checksum; + } + + /** + * @param string $property + * @param $value + * @return $this + */ + function setDataValue(string $property, $value): self + { + $this->data[$property] = $value; + return $this; + } + + /** + * @param string $property + * @return mixed|null + */ + function getDataValue(string $property): mixed + { + return $this->data[$property] ?? null; + } + + /** + * @param string $key + * @param mixed $value + * @return $this + */ + function setMemoValue(string $key, mixed $value): Snapshot + { + $this->memo[$key] = $value; + + return $this; + } + + /** + * @param string $key + * @param mixed|null $default + * @return mixed|null + */ + function getMemoValue(string $key, mixed $default = null): mixed + { + return $this->memo[$key] ?? $default; + } + + /** + * @param array $exclude + * @return array + */ + function toArray(array $exclude = []): array + { + return array_diff_key([ + 'data' => $this->data, + 'memo' => $this->memo, + 'checksum' => $this->checksum + ], array_flip($exclude)); + } + + /** + * @param array $exclude + * @return string + */ + function toString(array $exclude = []): string + { + return $this->serializer->serialize($this->toArray($exclude)); + } +} diff --git a/lib/Magewire/Mechanisms/HandleComponents/Synthesizers/DataObjectSynth.php b/lib/Magewire/Mechanisms/HandleComponents/Synthesizers/DataObjectSynth.php new file mode 100644 index 00000000..0a543806 --- /dev/null +++ b/lib/Magewire/Mechanisms/HandleComponents/Synthesizers/DataObjectSynth.php @@ -0,0 +1,60 @@ + $child) { + $data[$key] = $dehydrateChild($key, $child); + } + + return [$data, []]; + } + + public function hydrate($value, $meta, $hydrateChild) + { + $obj = $this->dataObjectFactory->create(); + + foreach ($value as $key => $child) { + $obj->setData($key, $hydrateChild($key, $child)); + } + + return $obj; + } + + public function set(DataObject $target, $key, $value) + { + return $target->setData($key, $value); + } +} diff --git a/lib/Magewire/Mechanisms/HandleRequests/ComponentContext/EffectsInterface.php b/lib/Magewire/Mechanisms/HandleRequests/ComponentContext/EffectsInterface.php new file mode 100644 index 00000000..28110452 --- /dev/null +++ b/lib/Magewire/Mechanisms/HandleRequests/ComponentContext/EffectsInterface.php @@ -0,0 +1,37 @@ +calls = $calls; + return $this; + } + + function getCalls(): array + { + return $this->calls; + } + + function setSnapshot(Snapshot $snapshot): self + { + $this->snapshot = $snapshot; + return $this; + } + + function getSnapshot(): Snapshot + { + return $this->snapshot; + } + + public function setUpdates(array $updates = []): self + { + $this->updates = $updates; + return $this; + } + + public function getUpdates(): array + { + return $this->updates; + } +} diff --git a/lib/Magewire/Mechanisms/HandleRequests/HandleRequestFacade.php b/lib/Magewire/Mechanisms/HandleRequests/HandleRequestFacade.php new file mode 100644 index 00000000..c4e25596 --- /dev/null +++ b/lib/Magewire/Mechanisms/HandleRequests/HandleRequestFacade.php @@ -0,0 +1,43 @@ +mechanism; + } + + /** + * @throws NoSuchEntityException + * @throws ComponentNotFoundException + */ + public function update() + { + return $this->mechanism->handleUpdate(); + } + + public function getUpdateUri(): string + { + return $this->mechanism->getUpdateUri(); + } +} diff --git a/lib/Magewire/Mechanisms/ResolveComponents/ComponentArguments/BlockMagewireArguments.php b/lib/Magewire/Mechanisms/ResolveComponents/ComponentArguments/BlockMagewireArguments.php new file mode 100644 index 00000000..22237485 --- /dev/null +++ b/lib/Magewire/Mechanisms/ResolveComponents/ComponentArguments/BlockMagewireArguments.php @@ -0,0 +1,16 @@ +assembled) { + return $this; + } + + // Assemble public arguments. + $this->addData($this->assemblePublicArguments($block)); + // Assemble private group arguments. + $this->groups = $this->assembleGroupArguments($block); + + $this->assembled = true; + + return $this; + } + + public function reassemble(AbstractBlock $block): static + { + return $this->assemble($block, true); + } + + public function __call($method, $args) + { + switch (substr((string) $method, 0, 3)) { + case 'get': + $key = $this->_underscore(substr($method, 3)); + $index = $args[0] ?? null; + + return $this->getData($key, $index); + case 'set': + $key = $this->_underscore(substr($method, 3)); + $value = $args[0] ?? null; + + return $this->setData($key, $value); + case 'has': + $key = $this->_underscore(substr($method, 3)); + + return isset($this->_data[$key]); + } + + throw new LocalizedException(new Phrase('Invalid method %1::%2', [get_class($this), $method])); + } + + /** + * Returns mount arguments. + */ + public function forMount(): array + { + return $this->forGroup('mount'); + } + + /** + * Returns arguments for the given group. + */ + public function forGroup(string $name, array $default = []): array + { + return $this->groups[$name] ?? $default; + } + + /** + * Besides the "toArray" method, this method ensures that we have the flexibility to implement + * different logic for the parameters in the future, compared to the "toArray" method. + * For now, this function primarily serves as a precautionary measure. + */ + public function toParams(): array + { + return $this->toArray(); + } + + public function isLazy(): bool + { + return (bool) $this->getData('lazy'); + } + + protected function assemblePublicArguments(AbstractBlock $block): array + { + $arguments = array_filter( + $block->getData(), + static function ($key) { + return str_starts_with($key, 'magewire.'); + }, + ARRAY_FILTER_USE_KEY + ); + + // Remove the "magewire." prefix and convert a kebab-case to camelCase + return array_combine(array_map(static function ($key) { + return Str::camel(substr($key, 9)); + }, array_keys($arguments)), array_values($arguments)); + } + + protected function assembleGroupArguments(AbstractBlock $block): array + { + $arguments = array_filter( + $block->getData(), + static function ($key) { + return str_starts_with($key, 'magewire:'); + }, + ARRAY_FILTER_USE_KEY + ); + + foreach ($arguments as $key => $value) { + if (! preg_match('/^magewire:([^:]+):([^:]+)/', $key, $matches)) { + continue; + } + + $groups[$matches[1]][Str::camel($matches[2])] = $value; + } + + return $groups ?? []; + } +} diff --git a/lib/Magewire/Mechanisms/ResolveComponents/ComponentResolver/ComponentResolver.php b/lib/Magewire/Mechanisms/ResolveComponents/ComponentResolver/ComponentResolver.php new file mode 100644 index 00000000..75063675 --- /dev/null +++ b/lib/Magewire/Mechanisms/ResolveComponents/ComponentResolver/ComponentResolver.php @@ -0,0 +1,158 @@ +> $complyChecks */ + protected array $complyChecks = []; + + public function __construct( + private readonly Conditions $conditions + ) { + } + + /** + * The complies method is intended to be a lightweight check that verifies whether the given block + * meets the requirements to be constructed as a Magewire component. This can involve various conditions, + * such as a specific data key being defined or a block belonging to a specific class. + * + * Aim to refine the requirements to their utmost specificity, minimizing the risk of conflicts + * with potential other component resolvers. The overarching goal is to ensure seamless + * integration and coexistence with various resolver modules. + */ + public function complies(AbstractBlock $block, mixed $magewire = null): bool + { + if ($magewire) { + $this->conditions()->if(static fn () => $magewire instanceof Component, 'instanceof-component'); + } + + // Accept this as the resolver if the blocks data key is equal to the resolver accessor. + $this->conditions()->or(fn () => $block->getData(self::RESOLVER) === $this->accessor, 'block-resolver-data-key'); + + return $this->conditions()->evaluate($block, $magewire); + } + + abstract public function arguments(): MagewireArguments; + + /** + * After a block meets specific requirements, as verified by the compile method, + * the block can be constructed. + * + * Ultimately, the block _must_ meet two specific criteria: + * 1. An instance of \Magewirephp\Magewire\Component bound to the "magewire" block data key + * 2. The component has bound "name" and "id" + * + * @throws \Magewirephp\Magewire\Exceptions\ComponentNotFoundException + */ + abstract public function construct(AbstractBlock $block): AbstractBlock; + + /** + * After the given snapshot has been verified, an attempt can be made to reconstruct the block, + * including the component. At this point, only the snapshot data is available. + * + * Ultimately, the block _must_ meet two specific criteria: + * 1. An instance of \Magewirephp\Magewire\Component bound to the "magewire" block data key + * 2. The component has bound "name" and "id" + * + * It is recommended to have the reconstruction process invoke the original construct method. + * This best practice helps prevent code duplication and maintains consistency between + * preceding and subsequent components, fostering a more streamlined end result. + */ + abstract public function reconstruct(ComponentRequestContext $request): AbstractBlock; + + /** + * The accessor represents a unique public name used to retrieve the appropriate resolver + * during the reconstruction process, essential for constructing both the block and the component. + * + * The accessor must match the DI-mapping Resolver item's name exactly. + */ + public function getAccessor(): string + { + return $this->accessor; + } + + /** + * Assembles all the necessary components after a block has been constructed or reconstructed. + * It is considered best practice to perform block assembly as the final step to ensure all + * required Magewire elements are properly set. + * + * Responsible for the following tasks: + * 1. Setting the block onto the component + * 2. Setting the resolver onto the component + * 3. Assembling data arguments + * + * @throws RuntimeException + */ + public function assemble(AbstractBlock $block, Component $component): AbstractBlock + { + if ($this->key === null) { + $this->key = $block->getCacheKey(); + } + + return $block; + } + + /** + * Flags whether the resolver should be reused (cached) or always re-evaluated. + * + * When the resolver is reused (cached), it improves performance by avoiding redundant + * calculations, making it easier to determine which resolver needs to be used + * for either the construction or reconstruction of the component. + * + * On the other hand, making the resolver dynamic (fluent) requires more processing, + * but ensures that the component is resolved based on different terms or conditions + * at runtime. + */ + public function remember(): bool + { + return true; + } + + protected function conditions(): Conditions + { + return $this->conditions; + } +} diff --git a/lib/Magewire/Mechanisms/ResolveComponents/ComponentResolver/ComponentResolverFactory.php b/lib/Magewire/Mechanisms/ResolveComponents/ComponentResolver/ComponentResolverFactory.php new file mode 100644 index 00000000..aabc4e48 --- /dev/null +++ b/lib/Magewire/Mechanisms/ResolveComponents/ComponentResolver/ComponentResolverFactory.php @@ -0,0 +1,22 @@ +conditions); + } + + /** + * To create a Magewire component using regular Layout XML, you have three options for binding: + * + * 1. Bind the component directly to a child argument named "magewire" with a xsi:type of "object". + * This allows you to associate the component with the Magewire framework. + * + * 2. Bind the component as a Magewire argument with an xsi:type of "array". + * This array should contain a child item called "type" with an xsi:type of "object". + * This method also associates the component with the Magewire framework. + * + * 3. Bind the component using Option 2 as a template, but instead of assigning it an xsi:type of "object", + * set it as a "bool" with a value of true. + * + * This approach enables you to create the component dynamically, where you won't have a physical object + * to control interaction, but you can still utilize it for tasks like polling data at regular intervals. + * + * By setting the value to true, you establish a connection with the Magewire framework without the need + * for a specific object instance. + */ + public function complies(mixed $block, mixed $magewire = null): bool + { + $this->conditions()->validate(fn () => $this->isBlock($block), 'is_block'); + + return parent::complies($block, $magewire); + } + + /** + * @throws ComponentNotFoundException + */ + public function construct(AbstractBlock $block): AbstractBlock + { + $magewire = $block->getData('magewire') ?? null; + + if (! $magewire) { + throw new ComponentNotFoundException(sprintf('No component object found for "%s"', $block->getNameInLayout())); + } + + // Magewire block can be directly passed as an argument or nested within a type argument. + $component = is_array($magewire) ? $magewire['type'] : $magewire; + + if (! $component instanceof Component) { + throw new ComponentNotFoundException(sprintf('Component "%s" could not be constructed', $block->getNameInLayout())); + } + + $this->handleBackwardsCompatibilityForComponent($magewire); + // Fulfill the main promise to establish or reset a Component instance within the block. + $block->setData('magewire', $component); + + // Register a dehydrate listener to attach necessary layout handles to the server memo. + on('dehydrate', function (Component $component, ComponentContext $context) { + $resolver = $component->magewireResolver(); + + if ($resolver->getAccessor() === $this->getAccessor()) { + if ($this->canMemorizeLayoutHandles()) { + $context->addMemo('handles', array_values($this->determineLayoutHandles($component, $context))); + } + + if ($alias = $component->magewireBlock()->getData('magewire:alias')) { + $context->addMemo('alias', $alias); + } + } + }); + + return $block; + } + + /** + * Layout reconstruction involves loading layout XML-generated blocks + * based on the handles passed into the server memo during component construction. + * + * By this point, we can be certain that the layout handles accompany the + * XHR request's server memo, stored in the snapshot object. + * + * After locating the block, standard construction is performed to complete + * the process, creating the Magewire component as it would be during a regular page load. + * + * @throws ComponentNotFoundException|LocalizedException + */ + public function reconstruct(ComponentRequestContext $request): AbstractBlock + { + $snapshot = $request->getSnapshot(); + + $alias = $snapshot->getMemoValue('alias'); + $name = $snapshot->getMemoValue('name'); + + // Retrieve the layout handles that were stored on the context snapshot. + $handles = $this->recoverLayoutHandles($snapshot); + // Build the complete layout structure by processing the recovered handles into renderable blocks. + $blocks = $this->generateBlocks($handles); + + /** @var Template|false $block */ + $block = $blocks[$alias ?? $name] ?? false; + + if ($block === false) { + throw new HttpException(404, sprintf('Magewire component "%s" could not be found', $alias ?? $name)); + } + + if ($alias) { + $block->setData('magewire:alias', $alias); + $block->setNameInLayout($name); + } + + // Now everything is prepared, we can simply re-call the construct method like during preceding requests. + return $this->construct($block); + } + + public function arguments(): MagewireArguments + { + return $this->arguments ??= $this->layoutBlockArgumentsFactory->create(); + } + + /** + * @throws RuntimeException + */ + public function assemble(AbstractBlock $block, Component $component): AbstractBlock + { + /* + * The block assembly occurs after the component is constructed, finalizing it with + * necessary attributes such as name and ID. For the layout, the block's name and ID + * are derived from the block itself, using the nameInLayout method provided by Layout XML. + */ + $component->setName($block->getNameInLayout()); + $component->setId($block->getNameInLayout()); + $component->setAlias($component->getAlias() ?? $block->getData('magewire:alias')); + + $this->determineTemplate($block, $component); + + return parent::assemble($block, $component); + } + + protected function logger(): LoggerInterface + { + return $this->logger ??= Factory::get(LoggerInterface::class); + } + + /** + * Determines the template by a default template path + * when the path is not defined within the layout. + * + * Convention: {Vendor_Module::magewire/dashed-class-name.phtml} + */ + protected function determineTemplate(AbstractBlock $block, Component $component): void + { + if ($block->getTemplate() !== null) { + return; + } + + $classParts = array_values( + array_filter( + explode('\\', get_class($component)), + static fn (string $part) => $part !== 'Interceptor' + ) + ); + + $prefix = $classParts[0] . '_' . $classParts[1]; + $suffix = strtolower(preg_replace('/(?setTemplate($prefix . '::magewire/' . $suffix . '.phtml'); + } + + protected function generateBlocks(array $handles): array + { + $layout = $this->layoutManager->singleton(); + + /** + * Preferably, a total new instance of the layout should be used. But since this singleton is + * used all over the place, it's very hard to manage a custom layout and have difference between + * the two of them. + * + * This causes issues all over the place where we've tried to fix it using a Layout Manager + * to provide developers with the correct layout object defaulting to the global singleton. + * + * But testing this for a while, didn't give a strong and solid foundation for future usage. + * Therefore, we've decided to go with the global singleton for now. + */ + if ($layout instanceof Layout) { + $layout->getUpdate()->addHandle($handles); + return $layout->getAllBlocks(); + } + + throw new \RuntimeException('Unable to generate layout'); + } + + protected function isBlock(mixed $block): bool + { + return $block instanceof AbstractBlock; + } + + protected function determineLayoutHandles(Component $component, ComponentContext $context): array + { + return array_diff( + $context->getBlock()->getLayout()->getUpdate()->getHandles(), + ['default'] + ); + } + + protected function recoverLayoutHandles(Snapshot $snapshot): array + { + if ($this->canMemorizeLayoutHandles()) { + return $snapshot->getMemoValue('handles') ?? []; + } + + return []; + } + + protected function canMemorizeLayoutHandles(): bool + { + return true; + } + + protected function handleBackwardsCompatibilityForComponent(Component $component): void + { + $bc = store($component)->get('magewire:bc'); + + try { + $within = AttributesReader::for($component)->first(HandleBackwardsCompatibility::class); + + if ($within instanceof HandleBackwardsCompatibility) { + $bc = $within->isBackwardsCompatible(); + } + } catch (Exception $exception) { + $this->logger()->critical($exception->getMessage(), ['exception' => $exception]); + } + + store($component)->set('magewire:bc', $bc ?? false); + } +} diff --git a/lib/Magewire/Mechanisms/ResolveComponents/ComponentResolverNotFoundException.php b/lib/Magewire/Mechanisms/ResolveComponents/ComponentResolverNotFoundException.php new file mode 100644 index 00000000..65113ee4 --- /dev/null +++ b/lib/Magewire/Mechanisms/ResolveComponents/ComponentResolverNotFoundException.php @@ -0,0 +1,18 @@ + All blocks seen during render: route → block. */ + private array $blocks = []; + + /** @var array Bound components: route → component. */ + private array $components = []; + + /** @var array Anonymous block counters per parent route. */ + private array $anonymousCounters = []; + + /** + * Push a block onto the render stack. + * + * Builds a route for the block based on its nameInLayout and the current + * stack depth. Anonymous blocks or duplicate-named siblings are assigned + * a numeric index from a per-parent counter to guarantee route uniqueness. + */ + public function push(AbstractBlock $block): static + { + $parent = end($this->stack) ?: ''; + $name = $block->getNameInLayout(); + + if ($name === null || $name === '') { + $counterKey = $parent !== '' ? $parent : '__root'; + $this->anonymousCounters[$counterKey] ??= 0; + $segment = (string) $this->anonymousCounters[$counterKey]++; + } else { + $segment = $name; + } + + $route = $parent !== '' ? $parent . DIRECTORY_SEPARATOR . $segment : $segment; + + if (isset($this->blocks[$route])) { + $counterKey = $parent !== '' ? $parent : '__root'; + $this->anonymousCounters[$counterKey] ??= 0; + $route = $parent !== '' ? $parent . DIRECTORY_SEPARATOR . $this->anonymousCounters[$counterKey]++ : (string) $this->anonymousCounters[$counterKey]++; + } + + $this->stack[] = $route; + $this->blocks[$route] = $block; + + $component = $block->getData('magewire'); + + if ($component instanceof Component) { + $this->components[$route] = $component; + } + + return $this; + } + + /** + * Pop the current block off the render stack. + * + * Reverts the active route to the parent, effectively closing the current + * block's rendering scope. The stack is strictly LIFO — no identification + * of the block is needed. + */ + public function pop(): static + { + if ($this->stack !== []) { + $route = array_pop($this->stack); + + if (! isset($this->components[$route])) { + unset($this->blocks[$route]); + } + + unset($this->anonymousCounters[$route]); + } + + return $this; + } + + /** + * Bind a Magewire component to the block currently on top of the stack. + * + * Called during component construction/reconstruction to associate a + * Component instance with its block's route. This link enables all + * subsequent relationship queries (parentComponent, closestComponent, etc.). + */ + public function bind(Component $component): static + { + $route = end($this->stack); + + if ($route !== false) { + $this->components[$route] = $component; + } + + return $this; + } + + /** + * Get the component bound to a specific block, or null if the block has no component. + */ + public function componentFor(AbstractBlock $block): ?Component + { + $route = $this->routeForBlock($block); + + return $route !== null ? ($this->components[$route] ?? null) : null; + } + + /** + * Get the block that a component is bound to, or null if the component is not registered. + */ + public function blockFor(Component $component): ?AbstractBlock + { + $route = $this->routeForComponent($component); + + return $route !== null ? ($this->blocks[$route] ?? null) : null; + } + + /** + * Find the nearest ancestor component above a block in the render tree. + * + * Walks upward from the block's parent route (skipping the block itself) + * and returns the first component found. Used by SupportMagewireNestingComponents + * to inject the owning component into child template dictionaries. + */ + public function closestComponent(AbstractBlock $block): ?Component + { + $route = $this->routeForBlock($block); + + if ($route === null) { + return null; + } + + $route = $this->parentRoute($route); + + while ($route !== '') { + if (isset($this->components[$route])) { + return $this->components[$route]; + } + + $route = $this->parentRoute($route); + } + + return null; + } + + /** + * Find the nearest ancestor component of a given component. + * + * Walks upward from the component's route through the block tree and + * returns the first other component encountered. Returns null if the + * component is at the root or has no component ancestors. + */ + public function parentComponent(Component $component): ?Component + { + $route = $this->routeForComponent($component); + + if ($route === null) { + return null; + } + + $route = $this->parentRoute($route); + + while ($route !== '') { + if (isset($this->components[$route])) { + return $this->components[$route]; + } + + $route = $this->parentRoute($route); + } + + return null; + } + + /** + * Collect all ancestor components above a given component, ordered nearest-first. + * + * Walks the full route upward, collecting every component found along the + * way. An optional filter callback can narrow the result set. + */ + public function componentAncestors(Component $component, ?callable $filter = null): array + { + $route = $this->routeForComponent($component); + + if ($route === null) { + return []; + } + + $ancestors = []; + $route = $this->parentRoute($route); + + while ($route !== '') { + if (isset($this->components[$route])) { + $ancestors[] = $this->components[$route]; + } + + $route = $this->parentRoute($route); + } + + return $filter ? array_filter($ancestors, $filter) : $ancestors; + } + + /** + * Collect all descendant components below a given component. + * + * Finds every component whose route starts with this component's route + * prefix, meaning they are nested somewhere within its block subtree. + * Includes all depths (children, grandchildren, etc.). An optional + * filter callback can narrow the result set. + */ + public function componentChildren(Component $component, ?callable $filter = null): array + { + $route = $this->routeForComponent($component); + + if ($route === null) { + return []; + } + + $prefix = $route . DIRECTORY_SEPARATOR; + + $children = array_values(array_filter( + $this->components, + static fn (Component $c, string $r) => str_starts_with($r, $prefix), + ARRAY_FILTER_USE_BOTH + )); + + return $filter ? array_filter($children, $filter) : $children; + } + + /** + * Check whether the current rendering context is inside a block with the given name. + * + * Inspects the segments of the active route to determine if a block with + * this nameInLayout is an ancestor of (or is) the block currently being rendered. + */ + public function within(string $name): bool + { + $current = end($this->stack); + + if ($current === false) { + return false; + } + + return in_array($name, explode(DIRECTORY_SEPARATOR, $current), true); + } + + /** + * Whether any Magewire components have been bound during this render cycle. + */ + public function hasComponents(): bool + { + return $this->components !== []; + } + + /** + * Get the full route of the block currently being rendered. + * + * Returns an empty string when the stack is empty (no block is rendering). + */ + public function route(): string + { + return end($this->stack) ?: ''; + } + + private function routeForBlock(AbstractBlock $block): ?string + { + $route = array_search($block, $this->blocks, true); + + return $route !== false ? $route : null; + } + + private function routeForComponent(Component $component): ?string + { + $route = array_search($component, $this->components, true); + + return $route !== false ? $route : null; + } + + private function parentRoute(string $route): string + { + $pos = strrpos($route, DIRECTORY_SEPARATOR); + + return $pos !== false ? substr($route, 0, $pos) : ''; + } +} diff --git a/lib/Magewire/Mechanisms/ResolveComponents/Layout/MagewireLayoutDecorator.php b/lib/Magewire/Mechanisms/ResolveComponents/Layout/MagewireLayoutDecorator.php new file mode 100644 index 00000000..d1d2a53c --- /dev/null +++ b/lib/Magewire/Mechanisms/ResolveComponents/Layout/MagewireLayoutDecorator.php @@ -0,0 +1,38 @@ +dynamicLayoutBuilder->newInstance(['layout' => $layout]); + + $layout->setGeneratorPool($this->generatorPool); + $layout->setBuilder($builder); + } + + return $layout; + } +} diff --git a/lib/Magewire/Mechanisms/ResolveComponents/Management/ComponentResolverManager.php b/lib/Magewire/Mechanisms/ResolveComponents/Management/ComponentResolverManager.php new file mode 100644 index 00000000..f9cc7aa6 --- /dev/null +++ b/lib/Magewire/Mechanisms/ResolveComponents/Management/ComponentResolverManager.php @@ -0,0 +1,204 @@ + $resolvers + */ + public function __construct( + private LoggerInterface $logger, + private ResolversCache $resolversCache, + private ComponentResolverFactory $componentResolverFactory, + private array $resolvers = [] + ) { + $this->resolvers = array_filter($this->resolvers, static fn ($resolver) => is_object($resolver) || is_string($resolver)); + } + + /** + * Locate a matching resolver which complies to the given block. + * + * @throws ComponentResolverNotFoundException + * @throws RuntimeException + */ + public function resolve(AbstractBlock $block): ComponentResolver + { + $cache = $this->resolversCache->fetch(); + + // BACKWARDS COMPATIBILITY START: Handling cases where resolver cache values were stored as strings and + // the magewire cache not yet has been flushed. + $resolver = $cache[$block->getCacheKey()] ?? $cache[$block->getCacheKey()]['accessor'] ?? null; + // BACKWARDS COMPATIBILITY END. + + /* + * The resolver is determined through one of the following options: + * + * 1. The resolver is manually assigned via a Magewire resolver argument, + * specified as a xsi-type string representing a DI-mapped resolver key or full class path. + * 2. The resolver is automatically filtered and executed by all available + * injected resolvers, each calling the `complies()` method. + */ + $resolver ??= $block->getData('magewire:resolver') ?? null; + + if ($resolver instanceof ComponentResolver) { + return $resolver; + } + + // Check the Magewire blocks cache to find the cached resolver accessor. + $resolver ??= $cache['blocks'][$this->getBlockCacheKey($block)] ?? null; + + if (is_string($resolver)) { + if ($this->hasResolverClassInMapping($resolver)) { + try { + return $this->createResolverByAccessor($resolver); + } catch (NotFoundException $exception) { + $this->logger->info($exception->getMessage(), ['exception' => $exception]); + } + } + + // If the resolver string wasn't found in the class mapping or instantiation failed, + // attempt to resolve the class dynamically and instantiate it if successful. + try { + return $this->createResolverByType($this->getResolverClass($resolver)); + } catch (NotFoundException $exception) { + $this->logger->info(sprintf('Magewire resolver data value found on block, but "%s" can not be resolved.', $resolver), ['exception' => $exception]); + } + } + + try { + // Last resort: find a resolver that matches the given block. + // @todo Consider prioritizing resolvers instead of always using the first match. + $resolver = array_key_first(array_filter($this->resolvers, static fn (ComponentResolver $resolver) => $resolver->complies($block, $block->getData('magewire')))); + + $resolver = $this->createResolverByAccessor($resolver); + } catch (Exception $exception) { + $this->logger->critical($exception->getMessage(), ['exception' => $exception]); + + throw new ComponentResolverNotFoundException(sprintf('No component resolver complies for block %s.', $block->getNameInLayout())); + } + + if ($resolver->remember()) { + $this->cache($block, $resolver); + } + + return $resolver; + } + + public function resolverFactory(): ComponentResolverFactory + { + return $this->componentResolverFactory; + } + + /** + * Create a resolver instance by its accessor. + * + * @throws ComponentResolverNotFoundException + */ + public function createResolverByAccessor(string $resolver, array $arguments = []): ComponentResolver + { + return $this->createResolverByType($this->getResolverClass($resolver), $arguments); + } + + /** + * @throws ComponentResolverNotFoundException + */ + public function createResolverByType(string $type, array $arguments = []): ComponentResolver + { + $instance = $this->resolverFactory()->create($type, $arguments); + + if ($instance instanceof ComponentResolver) { + return $instance; + } + + throw new ComponentResolverNotFoundException('Component resolver cannot be found.'); + } + + /** + * Returns if the given resolver can be found by its accessor. + */ + public function hasResolverClassInMapping(string $resolver): bool + { + try { + return is_string($this->getResolverClass($resolver)); + } catch (ComponentResolverNotFoundException $exception) { + } + + return false; + } + + /** + * @template T of ComponentResolver + * @return class-string + * @throws ComponentResolverNotFoundException + */ + private function getResolverClass(string $resolver): string + { + $cache = $this->resolversCache->fetch(); + $data = $cache['resolvers'][$resolver] ?? null; + + if ($data) { + return $data['class']; + } + if (class_exists($resolver)) { + return $resolver; + } + + if (isset($this->resolvers[$resolver])) { + if (is_object($this->resolvers[$resolver])) { + return $this->resolvers[$resolver]::class; + } + + return $this->resolvers[$resolver]; + } + + throw new ComponentResolverNotFoundException(sprintf('No block resolver found for accessor "%s"', $resolver)); + } + + /** + * @throws RuntimeException + */ + private function getBlockCacheKey(AbstractBlock $block, bool $unique = true): string + { + return $unique ? sprintf('%s_%s', $block->getCacheKey(), $block->getNameInLayout()) : $block->getCacheKey(); + } + + /** + * @throws RuntimeException + */ + private function cache(AbstractBlock $block, ComponentResolver $resolver): void + { + $cache = $this->resolversCache->fetch(); + $cache['blocks'][$this->getBlockCacheKey($block)] = $resolver->getAccessor(); + + $resolvers = $cache['resolvers'] ?? [$resolver->getAccessor() => []]; + + $resolvers[$resolver->getAccessor()]['blocks'][] = $this->getBlockCacheKey($block); + $resolvers[$resolver->getAccessor()]['class'] ??= $resolver::class; + $resolvers[$resolver->getAccessor()]['name'] ??= $resolver->getAccessor(); + + $cache['resolvers'] = $resolvers; + + // Attempt to cache the resolvers, regardless of whether caching is enabled or disabled. + $this->resolversCache->save($cache); + } +} diff --git a/lib/Magewire/Mechanisms/ResolveComponents/Management/LayoutLifecycleManager.php b/lib/Magewire/Mechanisms/ResolveComponents/Management/LayoutLifecycleManager.php new file mode 100644 index 00000000..62e6e713 --- /dev/null +++ b/lib/Magewire/Mechanisms/ResolveComponents/Management/LayoutLifecycleManager.php @@ -0,0 +1,34 @@ +lifecycles()->get($area, Factory::create(LayoutLifecycle::class), true); + } + + private function lifecycles(): DataCollection + { + return $this->lifecycles ??= Factory::create(DataCollection::class); + } +} diff --git a/lib/Magewire/Mechanisms/ResolveComponents/Management/LayoutManager.php b/lib/Magewire/Mechanisms/ResolveComponents/Management/LayoutManager.php new file mode 100644 index 00000000..d2126091 --- /dev/null +++ b/lib/Magewire/Mechanisms/ResolveComponents/Management/LayoutManager.php @@ -0,0 +1,54 @@ +layout; + } + + /** + * Returns a new Layout instance. + */ + public function factory(): LayoutFactory + { + return $this->factory; + } + + public function decorator(): LayoutDecorator + { + return $this->decorator; + } + + public function lifecycle(string $name = 'magewire'): LayoutLifecycle + { + return $this->lifecycleManager->target($name); + } +} diff --git a/lib/Magewire/Mechanisms/ResolveComponents/ResolveComponents.php b/lib/Magewire/Mechanisms/ResolveComponents/ResolveComponents.php new file mode 100644 index 00000000..9cc44189 --- /dev/null +++ b/lib/Magewire/Mechanisms/ResolveComponents/ResolveComponents.php @@ -0,0 +1,108 @@ +magewireServiceProvider->runtime()->mode()->isSubsequent()) { + $this->layoutManager->decorator()->decorateForPagelessBlockFetching($this->layoutManager->singleton()); + } + + on('magewire:component:construct', function (AbstractBlock $block) { + return $this->build(function () use ($block): array { + $resolver = $this->componentResolverManagement->resolve($block); + $block = $resolver->construct($block); + + return [$resolver, $block]; + }); + }); + + on('magewire:component:reconstruct', function (ComponentRequestContext $request) { + if (! $this->componentResolverManagement->hasResolverClassInMapping($request->getSnapshot()->getMemoValue('resolver'))) { + throw new HttpException(400); + } + + return $this->build(function () use ($request): array { + $resolver = $this->componentResolverManagement->createResolverByAccessor($request->getSnapshot()->getMemoValue('resolver')); + $block = $resolver->reconstruct($request); + + return [$resolver, $block]; + }); + }); + + // Register a dehydrate listener to attach the used resolver accessor for easy reconstruction. + on('dehydrate', static function (Component $component, ComponentContext $context) { + $context->addMemo('resolver', $component->magewireResolver()->getAccessor()); + }); + } + + /** + * @throws ComponentNotFoundException + */ + protected function build(callable $builder): callable + { + $lifecycle = $this->layoutManager->lifecycle(); + [$resolver, $block] = $builder(); + + if (! $block->getData('magewire') instanceof Component) { + throw new ComponentNotFoundException(sprintf('Resolver "%s" failed to construct a Magewire component.', $resolver->getAccessor())); + } + + return static function () use ($resolver, $block, $lifecycle) { + $resolver->arguments()->assemble($block, true); + + /** @var Component $component */ + $component = $block->getData('magewire'); + + $component->magewireBlock($block); + $component->magewireResolver($resolver); + $component->magewireLayoutLifecycle($lifecycle); + + $assembly = $resolver->assemble($block, $component); + trigger('magewire:component:build', $assembly, $component, $resolver); + + return $assembly; + }; + } +} diff --git a/lib/Magewire/Mechanisms/ResolveComponents/ResolveComponentsViewModel.php b/lib/Magewire/Mechanisms/ResolveComponents/ResolveComponentsViewModel.php new file mode 100644 index 00000000..0a05f164 --- /dev/null +++ b/lib/Magewire/Mechanisms/ResolveComponents/ResolveComponentsViewModel.php @@ -0,0 +1,28 @@ +renderLifecycleManager->target('magewire')->hasComponents(); + } +} diff --git a/lib/Magewire/Mechanisms/ResolveComponents/ResolversCache.php b/lib/Magewire/Mechanisms/ResolveComponents/ResolversCache.php new file mode 100644 index 00000000..d1c08aa3 --- /dev/null +++ b/lib/Magewire/Mechanisms/ResolveComponents/ResolversCache.php @@ -0,0 +1,19 @@ +state = $state; + } + + return $this->state; + } + + /** + * @throws RuntimeException + */ + public function mode(RequestMode|null $mode = null): RequestMode + { + if ($mode === null || $mode === $this->mode) { + return $this->mode; + } + if ($mode === RequestMode::UNDEFINED) { + throw new RuntimeException('Magewire request mode cannot be unset.'); + } + + return $this->mode = $mode; + } +} diff --git a/lib/Magewire/ServiceType.php b/lib/Magewire/ServiceType.php new file mode 100644 index 00000000..dc4de7c7 --- /dev/null +++ b/lib/Magewire/ServiceType.php @@ -0,0 +1,214 @@ + $items + */ + public function __construct( + private array $items = [] + ) { + } + + /** + * Returns true when fully booted, false when only partially booted. + */ + public function boot(ServiceTypeItemBootMode|null $mode = null): bool + { + // Short circuit when the service is fully booted. + if ($this->items()->booted()) { + return true; + } + + return $this->items()->boot($mode, $this->callback())->booted(); + } + + public function booter(): ServiceTypeBooter + { + return $this->booter ??= Factory::create(ServiceTypeBooter::class); + } + + /** + * Returns an operation type facade which simplifies the interface of a complex type by + * exposing only the necessary methods, thus defining a clear and concise + * API for interacting with the type. + * + * @throws NotFoundException + */ + public function facade(string $for) + { + $for = preg_replace('/(?items[$for]['facade']; + + if (is_string($facade)) { + $this->items[$for]['facade'] = Factory::get($facade); + } elseif (! is_object($facade)) { + throw new NotFoundException(__('Operation type facade "%1" could not be found.', $for)); + } + + return $this->items[$for]['facade']; + } + + /** + * @throws NotFoundException + */ + public function item(string $name): object + { + $name = preg_replace('/(?items[$name])) { + return $this->items[$name]['type']; + } + + throw new NotFoundException(__('Operation type item "%1" could not be found.', $name)); + } + + /** + * @throws NotFoundException + */ + public function viewModel(string $name): ArgumentInterface + { + $name = preg_replace('/(?items[$name]['view_model'])) { + return $this->items[$name]['view_model']; + } + + throw new NotFoundException(__('Operation view model "%1" could not be found.', $name)); + } + + private function assemble(): static + { + if ($this->assembled) { + return $this; + } + + $assembled = []; + + foreach (array_filter($this->items, static fn ($value) => is_string($value) || is_array($value)) as $key => $item) { + if (is_string($item)) { + $item = ['type' => $item]; + } + if (is_object($item['type'])) { + $assembled[$key] = $item; + continue; + } + + $item['type'] = Factory::get($item['type']); + + // Injects data if a `setData()` method is present in the operation type item class. + if (method_exists($item['type'], 'setData')) { + foreach ($item['data'] ?? [] as $dataKey => $value) { + $item['type']->setData($dataKey, $value); + } + } + + if ($item['sort_order'] ?? false) { + $this->sortOrder = (int) $item['sort_order']; + } else { + $this->sortOrder++; + $item['sort_order'] = $this->sortOrder; + } + + // Ensure the facade key exists, defaulting to null if not set. + $item['facade'] ??= null; + // Keep only active sequences (where the value is true). + $item['sequence'] = array_filter($item['sequence'] ?? [], static fn ($item) => $item === true); + // Ensure the view model key exists, defaulting to null if not set. + $item['view_model'] ??= null; + // Ensure the config key exists, defaulting to null if not set. + $item['config'] ??= null; + // Use the array key as the item name if not explicitly defined. + $item['name'] ??= $key; + + // Ensure the boot mode exists, or set the default if not set. + $item['boot_mode'] = ServiceTypeItemBootMode::try($item['boot_mode'] ?? null, $this->getBootModeFallback()); + + $assembled[$key] = $item; + } + + $this->items = $assembled; + + $this->assembled = true; + return $this; + } + + private function sort(): array + { + if ($this->sorted) { + return $this->items; + } + + uasort($this->items, static function ($a, $b) { + if ($a['sort_order'] == $b['sort_order']) { + if (isset($a['sequence'])) { + foreach ($a['sequence'] as $dependency) { + if ($dependency == $b['type']) { + return 1; // $a should come after $b + } + } + } + + if (isset($b['sequence'])) { + foreach ($b['sequence'] as $dependency) { + if ($dependency == $a['type']) { + return -1; // $a should come before $b + } + } + } + + return 0; + } + + return ( $a['sort_order'] ?? 0 ) < ( $b['sort_order'] ?? 0 ) ? -1 : 1; + }); + + $this->sorted = true; + return $this->items; + } + + protected function items(): ServiceTypeBooter + { + return $this->booter()->setup($this->assemble()->sort()); + } + + protected function getBootModeFallback(): ServiceTypeItemBootMode + { + return ServiceTypeItemBootMode::lowest(); + } + + /** + * Callback ran during a booting process of a service type item. + */ + abstract protected function callback(): callable; +} diff --git a/lib/Magewire/ServiceTypeBooter.php b/lib/Magewire/ServiceTypeBooter.php new file mode 100644 index 00000000..7700bf9b --- /dev/null +++ b/lib/Magewire/ServiceTypeBooter.php @@ -0,0 +1,117 @@ + */ + private array $processed = []; + + private DataCollection|null $items = null; + + public function __construct( + private readonly DataCollectionFactory $dataArrayFactory + ) { + } + + public function setup(array $items): static + { + if ($this->setup) { + return $this; + } + + foreach ($items as $item) { + if (! ($item['boot_mode'] instanceof ServiceTypeItemBootMode)) { + $item['boot_mode'] = ServiceTypeItemBootMode::try($item['boot_mode'] ?? null); + } + + $key = $item['name'] ?? Random::string(); + $this->items()->set($key, $item); + $this->processed[$key] = false; + } + + $this->setup = true; + return $this; + } + + /** + * Returns true when all items at or above the given mode have been booted. + * When no mode is given, checks all items and caches the global booted state. + */ + public function booted(ServiceTypeItemBootMode|null $mode = null): bool + { + if ($this->booted) { + return true; + } + + foreach ($this->items()->all() as $key => $item) { + if ($mode !== null && $item['boot_mode']->isLowerThan($mode)) { + continue; + } + + if (($this->processed[$key] ?? false) === false) { + return false; + } + } + + if ($mode === null) { + $this->booted = true; + } + + return true; + } + + public function boot(ServiceTypeItemBootMode|null $mode, callable $callback): static + { + if ($this->booted) { + return $this; + } + + foreach ($this->items()->all() as $key => $item) { + // Skip items that were already booted. + if ($this->processed[$key] ?? false) { + continue; + } + + // Skip items with a lower priority than the requested mode. + if ($mode !== null && $item['boot_mode']->isLowerThan($mode)) { + continue; + } + + try { + $callback($item['type'], $item); + $this->processed[$key] = true; + } catch (Exception $exception) { + // TBD + } + } + + // Cache the global booted flag if all items are now done. + $this->booted(); + + return $this; + } + + private function items(): DataCollection + { + return $this->items ??= $this->dataArrayFactory->create(); + } +} diff --git a/lib/Magewire/Support/AttributesReader.php b/lib/Magewire/Support/AttributesReader.php new file mode 100644 index 00000000..379f68d6 --- /dev/null +++ b/lib/Magewire/Support/AttributesReader.php @@ -0,0 +1,111 @@ +reflection = $subject instanceof ReflectionClass ? $subject : new ReflectionClass($subject); + + $this->target = $this->reflection; + } + + /** + * @throws ReflectionException + */ + public static function for(object|string $subject): static + { + return new static($subject); + } + + /** + * Scope subsequent calls to a specific property. + * + * @throws ReflectionException + */ + public function property(string $name): static + { + $clone = clone $this; + $clone->target = $this->reflection->getProperty($name); + + return $clone; + } + + /** + * Returns the first instantiated attribute of $attributeClass, or null. + * + * @template T + * @param class-string $attributeClass + * @return T|null + */ + public function first(string $attributeClass): object|null + { + $attrs = $this->target->getAttributes($attributeClass); + + return ! empty($attrs) ? $attrs[0]->newInstance() : null; + } + + /** + * Returns all instantiated attributes of $attributeClass. + * + * @template T + * @param class-string $attributeClass + * @return T[] + */ + public function all(string $attributeClass): array + { + return array_map(static fn (ReflectionAttribute $attr) => $attr->newInstance(), $this->target->getAttributes($attributeClass)); + } + + public function has(string $attributeClass): bool + { + return ! empty($this->target->getAttributes($attributeClass)); + } + + /** + * Returns a map of property name => first attribute instance for all properties bearing $attributeClass. + * + * @template T + * @param class-string $attributeClass + * @return array + */ + public function properties(string $attributeClass): array + { + $result = []; + + foreach ($this->reflection->getProperties() as $property) { + $attrs = $property->getAttributes($attributeClass); + + if (! empty($attrs)) { + $result[$property->getName()] = $attrs[0]->newInstance(); + } + } + + return $result; + } +} diff --git a/lib/Magewire/Support/Concerns/AsDataObject.php b/lib/Magewire/Support/Concerns/AsDataObject.php new file mode 100644 index 00000000..7ce59681 --- /dev/null +++ b/lib/Magewire/Support/Concerns/AsDataObject.php @@ -0,0 +1,93 @@ +data[$key] ?? $default; + } + + return $default; + } + + function getDataByPath(string $path, $default = null, string $separator = '.') + { + $keys = explode($separator, $path); + $data = $this->data; + + foreach ($keys as $key) { + if (is_array($data) && array_key_exists($key, $data)) { + $data = $data[$key]; + } else { + return $default; + } + } + + if ($this->data === $data) { + return $default; + } + + return $data ?? $default; + } + + function setData(string $key, mixed $value): self + { + if (property_exists(self::class, 'data')) { + $this->data[$key] = $value; + } + + return $this; + } + + function hasData($key): bool + { + if (property_exists(self::class, 'data')) { + if (empty($key) || ! is_string($key)) { + return ! empty($this->data); + } + + return array_key_exists($key, $this->data); + } + + return false; + } + + /** + * @throws LocalizedException + */ + function __call($method, $args) + { + switch (substr((string) $method, 0, 3)) { + case 'get': + return $this->getData(substr($method, 3)); + case 'set': + return $this->setData(substr($method, 3), $args[0] ?? null); + case 'has': + return isset($this->data[substr($method, 3)]); + } + + throw new LocalizedException(new Phrase('Invalid method %1::%2', [get_class($this), $method])); + } + + function populate(array $data): void + { + $this->data = $data; + } +} diff --git a/lib/Magewire/Support/Concerns/AsFactory.php b/lib/Magewire/Support/Concerns/AsFactory.php new file mode 100644 index 00000000..2f7420e3 --- /dev/null +++ b/lib/Magewire/Support/Concerns/AsFactory.php @@ -0,0 +1,24 @@ +create($this->newInstanceType()); + } +} diff --git a/lib/Magewire/Support/Concerns/WithDiagnostics.php b/lib/Magewire/Support/Concerns/WithDiagnostics.php new file mode 100644 index 00000000..8ff8abc6 --- /dev/null +++ b/lib/Magewire/Support/Concerns/WithDiagnostics.php @@ -0,0 +1,27 @@ +withDiagnostics ??= $this->newTypeInstance(Diagnostics::class); + } +} diff --git a/lib/Magewire/Support/Concerns/WithFactory.php b/lib/Magewire/Support/Concerns/WithFactory.php new file mode 100644 index 00000000..fb0542fc --- /dev/null +++ b/lib/Magewire/Support/Concerns/WithFactory.php @@ -0,0 +1,58 @@ +|null $type + * @return T + */ + public function newInstance(array $arguments = [], string|null $type = null): static + { + $type ??= static::class; + + if ($type !== static::class && ! is_subclass_of($type, static::class)) { + throw new InvalidArgumentException(sprintf('Type %s must be equal to or a subclass of %s', $type, static::class)); + } + + return $this->newTypeInstance($type, $arguments); + } + + /** + * Creates a new instance of the current or given class using its factory. + * When a type is provided, it must be equal to or a subclass of the current class. + * + * @template T + * @param class-string $type + * @return T + */ + public function newTypeInstance(string $type, array $arguments = []): mixed + { + if (str_ends_with($type, 'Factory')) { + $type = substr($type, 0, -strlen('Factory')); + } + if (! class_exists($type)) { + throw new InvalidArgumentException(sprintf('Class %s does not exist', $type)); + } + + $factory = Factory::get(trim($type) . 'Factory'); + return $factory->create($arguments); + } +} diff --git a/lib/Magewire/Support/Conditions.php b/lib/Magewire/Support/Conditions.php new file mode 100644 index 00000000..a288cfe1 --- /dev/null +++ b/lib/Magewire/Support/Conditions.php @@ -0,0 +1,125 @@ +> */ + private array $conditions = []; + + public function validate(callable $condition, string|null $name = null): static + { + return $this->if($condition, $name); + } + + public function if(callable $condition, string|null $name = null): static + { + return $this->set($condition, ConditionEnum::AND, $name); + } + + /** + * @alias if + */ + private function set(callable $condition, ConditionEnum $type, string|null $name = null): static + { + $name ?? count($this->get($type)); + $this->conditions[$type->value][$name] = $condition; + + return $this; + } + + public function and(callable $condition, string|null $name = null): static + { + return $this->set($condition, ConditionEnum::AND, $name); + } + + public function or(callable|array $alternative, string|null $name = null): static + { + if (is_array($alternative)) { + $alternative = array_filter($alternative, static fn ($item) => is_callable($item)); + } + + return $this->set($alternative, ConditionEnum::OR, $name); + } + + public function swap(string $name, ConditionEnum $from, ConditionEnum $to): static + { + $target = $this->get($from, $name); + + if (is_callable($target) && ! is_callable($to)) { + $this->set($target, $to, $name); + } + + return $this; + } + + public function isset(string $name, ConditionEnum $type = ConditionEnum::AND): bool + { + return isset($this->conditions[$type->value][$name]); + } + + public function unset(string $name, ConditionEnum|null $type = null): static + { + if ($type && isset($this->conditions[$type->value])) { + unset($this->conditions[$type->value][$name]); + + return $this; + } + + if (count($this->get(ConditionEnum::AND))) { + $this->unset($name, ConditionEnum::AND); + } + if (count($this->get(ConditionEnum::OR))) { + $this->unset($name, ConditionEnum::OR); + } + + return $this; + } + + public function evaluate(...$args): bool + { + foreach ($this->get(ConditionEnum::AND) as $condition) { + if ($condition(...$args)) { + continue; + } + + foreach ($this->get(ConditionEnum::OR) as $alternative) { + if (is_array($alternative) && $this->evaluateGroup($alternative, ...$args)) { + return true; + } else { + return $alternative(...$args); + } + } + + return false; + } + + return true; + } + + /** + * @return callable|array + */ + private function get(ConditionEnum $type, string|null $name = null): callable|array + { + $type = $this->conditions[$type->value] ?? []; + + return $name ? $type[$name] : $type; + } + + private function evaluateGroup(array $group, ...$args): bool + { + return array_reduce($group, static fn ($carry, $item) => $carry && $item(...$args), true); + } +} diff --git a/lib/Magewire/Support/Conditions/ConditionEnum.php b/lib/Magewire/Support/Conditions/ConditionEnum.php new file mode 100644 index 00000000..279c80e9 --- /dev/null +++ b/lib/Magewire/Support/Conditions/ConditionEnum.php @@ -0,0 +1,18 @@ +isset($offset); + } + + public function offsetGet($offset): mixed + { + return $this->get($offset); + } + + public function offsetSet($offset, $value): void + { + is_string($offset) || is_int($offset) ? $this->set($offset, $value) : $this->push($value); + } + + public function offsetUnset($offset): void + { + $this->unset($offset); + } +} diff --git a/lib/Magewire/Support/DataCollection.php b/lib/Magewire/Support/DataCollection.php new file mode 100644 index 00000000..dbff7126 --- /dev/null +++ b/lib/Magewire/Support/DataCollection.php @@ -0,0 +1,467 @@ +> */ + private array $hooks = []; + + /** + * @param array $items + */ + public function __construct( + private readonly Filter $filter, + private array $items = [], + private readonly int $level = 0, + private readonly string|int $name = 'root', + private readonly DataCollection|null $parent = null + ) { + } + + public function map(array $map): static + { + foreach ($map as $from => $to) { + if (! is_string($to)) { + continue; + } + + $this->rename($from, $to); + } + + return $this; + } + + public function rename(string|int $from, string|int $to): static + { + return $this->copy($from, $to, true); + } + + public function each(callable $callback, callable|TypeFilter $filter = TypeFilter::ALL): static + { + $items = $this->filter() + ->with($filter) + ->return() + ->all(); + + foreach ($items as $key => $value) { + $callback($this, $value, $key); + } + + return $this; + } + + public function copy(string|int $from, string|int $to, bool $unset = false): static + { + if ($this->isset($from)) { + $this->set($to, $this->items[$from]); + + if ($unset) { + $this->unset($from); + } + } + + return $this; + } + + public function unset(string|int ...$keys): static + { + foreach ($keys as $key) { + if (! $this->isset($key)) { + continue; + } + + unset($this->items[$key]); + + $this->dispatch(Hook::UNSET, $key); + } + + return $this; + } + + public function set(string|int $name, $value): static + { + if ($this->isset($name)) { + return $this; + } + + $this->items[$name] = $value; + + $this->dispatch(Hook::SET, $name, $value); + return $this; + } + + public function push($value): static + { + $this->items[] = $value; + + $this->dispatch(Hook::PUSH, $value); + return $this; + } + + public function subset(string|int|null $name = null, string|null $type = null, array $arguments = []): DataCollection + { + $level = $name !== null ? $this->level + 1 : 0; + $name = is_string($name) ? Str::snake($name) : $name; + + if ($name !== null && $this->subsets()->has($name)) { + return $this->subsets()->get($name); + } + + $arguments = array_merge($arguments, [ + 'parent' => $this, + 'level' => $level, + 'name' => $name ?? Random::string(), + ]); + + if ($name === null) { + return $this->newTypeInstance($type ?? static::class, $arguments); + } + + $instance = $this->newTypeInstance($type ?? static::class, $arguments); + $this->subsets()->set($name, $instance); + + return $instance; + } + + /** + * Data collection subsets entry point. + */ + public function subsets(): static + { + return $this->subsets ??= Factory::create(static::class, [ + 'parent' => $this, + 'name' => 'subsets' + ]); + } + + /** + * @deprecated Has been replaced using $this->subsets()->count() + * @param array $names + */ + public function hasSubsets(array $names, bool $strict = true): bool + { + $expected = count($names); + $found = 0; + + foreach ($names as $name) { + if (! ($this->subsets()->has($name))) { + continue; + } + + $found++; + } + + return $strict ? ($found === $expected) : ($found > 0); + } + + /** + * Update (or set) an (existing) value by name. + */ + public function put(string|int $name, $value, bool $force = false): static + { + if ($this->isset($name) || $force) { + $this->items[$name] = $value; + + $this->dispatch(Hook::PUT, $name, $value); + } + + return $this; + } + + public function merge(array $items): static + { + foreach ($items as $key => $value) { + $this->isset($key) ? $this->put($key, $value) : $this->set($key, $value); + } + + return $this; + } + + public function fill(array $items, bool $arrayAsSubset = false): static + { + foreach ($items as $key => $value) { + $target = $this; + + if (is_array($value) && $arrayAsSubset) { + $target = $this->subset($key); + } + + $target->isset($key) ? $target->put($key, $value) : $target->set($key, $value); + } + + return $this; + } + + public function isset(string|int $name): bool + { + return isset($this->items[$name]); + } + + public function has(string|int $name): bool + { + return $this->isset($name); + } + + /** + * Check if all given props are set within the collection. + */ + public function contains(array $names, bool $strict = true): bool + { + return ! in_array(false, array_map($this->has(...), $names), $strict); + } + + public function all(): array + { + $result = []; + + foreach ($this->items as $key => $value) { + if ($value instanceof DataCollection) { + $value = $value->all(); + } + + $result[$key] = $value; + } + + return $result; + } + + public function raw(): array + { + return $this->items; + } + + /** + * Encodes the collection to a JSON string. + * When a filter is provided, only items passing both the JSON_ENCODABLE + * filter and the supplied filter are included in the output. + * + * @todo Implement lazy JSON caching when PHP 8.4+ property hooks are available. + * Use a cached $json property with a set hook that invalidates the cache whenever + * $items is modified. This would allow returning pre-encoded JSON if items haven't + * changed, eliminating redundant encoding operations. The cache would only contain + * JSON-encodable items to ensure validity. + */ + public function json(callable|TypeFilter|null $filter = null): string|false + { + $items = $this; + + if ($filter) { + $items = $this->subset()->fill( + $this->filter()->with(TypeFilter::JSON_ENCODABLE)->return()->all() + )->filter()->with($filter)->return(); + } + + return json_encode($items->all()); + } + + public function filter(): Filter + { + return $this->filter->using($this); + } + + public function pluck(array $keys, array $defaults = []): array + { + $result = []; + + foreach ($keys as $key) { + if ($this->isset($key)) { + $result[$key] = $this->get($key); + } elseif (array_key_exists($key, $defaults)) { + $result[$key] = $defaults[$key]; + } + } + + return $result; + } + + public function values(): array + { + return array_values($this->items); + } + + public function keys(): array + { + return array_keys($this->items); + } + + public function get(string|int $name, $default = null, bool $set = false): mixed + { + $value = $this->items[$name] ?? ($set ? $this->default($name, $default)->get($name) : $default); + + $this->dispatch(Hook::GET, $name, $value); + return $value; + } + + /** + * Harvest the collection by passing all items into a callback to compute the final output. + */ + public function harvest(callable $callback, array $carry = []): array + { + return (array) $callback($this->items, $carry); + } + + public function reset(): static + { + $this->items = []; + + $this->dispatch(Hook::RESET); + return $this; + } + + public function destroy(): static + { + foreach ($this->subsets !== null ? $this->subsets->raw() : [] as $subset) { + if (!($subset instanceof DataCollection)) { + continue; + } + + $subset->destroy(); + } + + $this->subsets = null; + $this->reset(); + + return $this; + } + + public function default(string|int $key, $value): static + { + if ($this->isset($key)) { + return $this; + } + + return $this->set($key, $value); + } + + public function defaults(array $defaults): static + { + foreach ($defaults as $key => $value) { + if (! ( is_string($key) || is_int($key) )) { + continue; + } + + $this->default($key, $value); + } + + return $this; + } + + public function clear(callable|TypeFilter $filter = TypeFilter::NONE): static + { + $this->items = $this->filter() + ->with($filter) + ->return() + ->all(); + + $this->dispatch(Hook::CLEAR); + return $this; + } + + public function count(): int + { + return count($this->items); + } + + public function snapshot(): static + { + return $this->newInstance(['items' => $this->all()]); + } + + public function walk(callable $callback): static + { + $result = $callback($this); + $subsets = $this->subsets(); + + // Callback returned nothing or this instance. + if ($result === $this || $result instanceof static || $result === null) { + foreach ($subsets as $subitem) { + $subitem->walk($callback); + } + } + + return $this; + } + + public function collect(callable $callback): array + { + $result = $callback($this); + $subsets = $this->subsets(); + + // Gather all results into a flat array. + $collection = [$this->name() => $result]; + + foreach ($subsets as $subitem) { + $collection = array_merge($collection, $subitem->collect($callback)); + } + + return $collection; + } + + public function level(): int + { + return $this->level; + } + + public function name(): string|int + { + return $this->name; + } + + public function parent(): static|null + { + return $this->parent; + } + + /** + * Registers a callable to be triggered when the specified hook event occurs. + */ + public function hook(Hook|string $onto, callable $then): static + { + $hook = is_string($onto) ? $onto : $onto->value; + $this->hooks[$hook][] = $then; + + return $this; + } + + public function getIterator(): Traversable + { + return $this->newTypeInstance(ArrayIterator::class, ['array' => $this->all()]); + } + + /** + * Dispatches a hook by invoking all registered actions for the given hook, + * passing the current instance followed by any additional arguments. + */ + protected function dispatch(Hook|string $hook, mixed ...$args): static + { + $hook = is_string($hook) ? $hook : $hook->value; + + foreach ($this->hooks[$hook] ?? [] as $action) { + $action($this, ...$args); + } + + return $this; + } +} diff --git a/lib/Magewire/Support/DataCollection/Filter.php b/lib/Magewire/Support/DataCollection/Filter.php new file mode 100644 index 00000000..781f196c --- /dev/null +++ b/lib/Magewire/Support/DataCollection/Filter.php @@ -0,0 +1,183 @@ + $presets */ + private array $presets = []; + + public function with(callable|TypeFilter|string $filter): static + { + if (is_string($filter)) { + $filter = $this->presets[$filter] ?? throw new InvalidArgumentException(sprintf('Data array filter preset "%s" does not exist.', $filter)); + } + if (is_callable($filter)) { + return $this->byClosure($filter); + } + + return $this->byType($filter); + } + + public function byType(TypeFilter $filter): static + { + return $this->byClosure($filter->get()); + } + + public function byAnyType(TypeFilter ...$filters): static + { + return $this->byClosure(TypeFilter::any(...$filters)); + } + + public function byOnlyType(TypeFilter ...$filters): static + { + return $this->byClosure(TypeFilter::only(...$filters)); + } + + public function byClosure(callable $callable): static + { + return $this->chain(array_filter($this->items(), $callable, ARRAY_FILTER_USE_BOTH)); + } + + public function byOffset(int $offset, int $start = 0): static + { + return $this->chain(array_slice($this->items(), $start, $offset, true)); + } + + public function byPage(int $page, int $limit = 10): static + { + return $this->byOffset($limit, ( $page - 1 ) * $limit); + } + + public function byKeys(array $keys): static + { + return $this->chain(array_intersect_key($this->items(), array_flip($keys))); + } + + public function byValues(array $values): static + { + return $this->chain(array_intersect($this->items(), $values)); + } + + public function byKeyPattern(string $pattern): static + { + return $this->chain(array_filter( + $this->items(), + static function ($value, $key) use ($pattern) { + return preg_match($pattern, (string) $key); + }, + ARRAY_FILTER_USE_BOTH + )); + } + + public function byValuePattern(string $pattern): static + { + return $this->chain(array_filter($this->items(), static function ($value) use ($pattern) { + return preg_match($pattern, (string) $value); + })); + } + + public function byRange($min, $max): static + { + return $this->chain(array_filter($this->items(), static function ($value) use ($min, $max) { + return $value >= $min && $value <= $max; + })); + } + + public function byInstance(string $type): static + { + return $this->chain(array_filter($this->items(), static function ($value) use ($type) { + return $value instanceof $type; + })); + } + + public function byUnique(): static + { + return $this->chain(array_unique($this->items(), SORT_REGULAR)); + } + + public function byEmpty(): static + { + return $this->chain(array_filter($this->items(), static fn ($value) => empty($value))); + } + + public function byNotEmpty(): static + { + return $this->chain(array_filter($this->items(), static fn ($value) => ! empty($value))); + } + + public function byJsonEncodable(): static + { + return $this->byType(TypeFilter::JSON_ENCODABLE); + } + + public function using(DataCollection $collection): static + { + $this->collection = $collection; + return $this; + } + + public function preset(string $name, callable $filter): static + { + $this->presets[$name] = $filter; + return $this; + } + + public function shorten(callable $callable): array + { + return $this->byClosure($callable)->return()->all(); + } + + /** + * Chain method to return to the collection output. + * + * @alias return + */ + public function and(): DataCollection + { + return $this->collection(); + } + + public function return(): DataCollection + { + return $this->and(); + } + + protected function items(): array + { + return $this->collection()->raw(); + } + + protected function collection(): DataCollection + { + if ($this->collection) { + return $this->collection; + } + + throw new RuntimeException('No data collection found.'); + } + + protected function chain(array $result): static + { + return $this->collection() + ->newInstance([ + 'items' => $result, + 'filter' => $this + ]) + ->filter(); + } +} diff --git a/lib/Magewire/Support/DataCollection/Filter/DataCollectionFilter.php b/lib/Magewire/Support/DataCollection/Filter/DataCollectionFilter.php new file mode 100644 index 00000000..4daf4056 --- /dev/null +++ b/lib/Magewire/Support/DataCollection/Filter/DataCollectionFilter.php @@ -0,0 +1,18 @@ + static fn ($value) => true, + self::NONE => static fn ($value) => false, + self::STRINGS => static fn ($value) => is_string($value), + self::INTEGERS => static fn ($value) => is_int($value), + self::FLOATS => static fn ($value) => is_float($value), + self::BOOLEANS => static fn ($value) => is_bool($value), + self::ARRAYS => static fn ($value) => is_array($value), + self::OBJECTS => static fn ($value) => is_object($value), + self::NULLS => static fn ($value) => is_null($value), + self::NUMERIC => static fn ($value) => is_numeric($value), + self::SCALAR => static fn ($value) => is_scalar($value), + self::CALLABLE => static fn ($value) => is_callable($value), + self::ITERABLE => static fn ($value) => is_iterable($value), + self::COUNTABLE => static fn ($value) => $value instanceof Countable, + self::SERIALIZABLE => fn ($value) => $this->isSerializable($value), + self::JSON_ENCODABLE => fn ($value) => $this->isJsonEncodable($value) + }; + } + + /** + * Combine multiple filters with OR logic. + */ + public static function any(self ...$filters): callable + { + return static function ($value) use ($filters) { + if ($value instanceof DataArray) { + $value = $value->all(); + } + + foreach ($filters as $filter) { + $filter = $filter->get(); + + if ($filter($value)) { + return true; + } + } + return false; + }; + } + + /** + * Combine multiple filters with AND logic. + */ + public static function only(self ...$filters): callable + { + return static function ($value) use ($filters) { + if ($value instanceof DataArray) { + $value = $value->all(); + } + + foreach ($filters as $filter) { + $filter = $filter->get(); + + if ($filter($value)) { + return false; + } + } + return true; + }; + } + + private function isSerializable($value): bool + { + if (is_scalar($value) || is_null($value)) { + return true; + } + if (is_array($value)) { + return array_reduce($value, fn ($carry, $item) => $carry && $this->isSerializable($item), true); + } + + return false; + } + + private function isJsonEncodable($value): bool + { + try { + json_encode($value); + } catch (Exception) { + return false; + } + + return true; + } +} diff --git a/lib/Magewire/Support/DataScope.php b/lib/Magewire/Support/DataScope.php new file mode 100644 index 00000000..53c46bc4 --- /dev/null +++ b/lib/Magewire/Support/DataScope.php @@ -0,0 +1,252 @@ + $latest */ + private array $latest = []; + + public function __construct( + private readonly DataScope|null $root = null, + protected array $data = [] + ) { + } + + /** + * Sets a value at the given path, supporting aliasing and array stacking for paths ending in 's'. + * Automatically tracks the latest modified path, including numeric keys or aliases. + * Ensures the target structure exists before assigning the value. + */ + public function set(string $path, mixed $value, string|null $alias = null): static + { + $keys = explode('.', $path); + $target = array_pop($keys); + + $current = &$this->data($keys, true); + + if (! is_array($current)) { + $current = []; + } + + if (str_ends_with($path, 's')) { + if (! isset($current[$target]) || ! is_array($current[$target])) { + $current[$target] = []; + } + + if ($alias) { + $current[$target][$alias] = $value; + $this->latest[] = $path . '.' . $alias; + } else { + $current[$target][] = $value; + $this->latest[] = $path . '.' . array_key_last($current[$target]); + } + + return $this; + } + + $current[$target] = $value; + $this->latest[] = $path; + + return $this; + } + + /** + * Pushes a value onto the specified section, appending it to an array if the section ends with 's'. + * Supports aliasing for array elements and ensures the target structure exists before adding the value. + * Automatically tracks the latest modified path. + */ + public function push(string $section, mixed $value, string|null $alias = null, string|null $path = null): static + { + $section = rtrim($section, 's') . 's'; + + return $this->set($path ? $path . '.' . $section : $section, $value, $alias); + } + + /** + * Groups a value under the specified path by appending it to an array within the given group. + * Ensures the target structure exists, initializing it as an array if necessary, before adding the value. + * Automatically tracks the latest modified path. + */ + public function group(string $group, string $path, $value): static + { + $path = $path . '.' . $group; + + $keys = explode('.', $path); + $current = &$this->data($keys, true); + + if (! is_array($current)) { + $current = []; + } + + $current[] = $value; + $this->latest[] = $path . '.' . array_key_last($current); + + return $this; + } + + /** + * Retrieves a value from the given path, with optional alias support and a default fallback. + * Uses the data method to navigate nested structures while handling array keys gracefully. + * Returns the value if found, or the default if the path or alias does not exist. + */ + public function get(string $path, string|null $alias = null, mixed $default = null): mixed + { + $keys = explode('.', $path); + $target = array_pop($keys); + + $current = $this->data($keys); + $target = $alias ?? $target; + + if (is_array($current) && array_key_exists($target, $current)) { + return $current[$target]; + } + + return $default; + } + + /** + * Checks if a value exists at the given path, optionally using an alias. + * Returns true if the value exists, false otherwise. + */ + public function isset(string $path, string|null $alias = null): bool + { + return $this->get($path, $alias) !== null; + } + + /** + * Removes the value at the given path, supporting aliases. + * Uses the data method to navigate and unset the key if it exists. + */ + public function unset(string $path, string|null $alias = null): static + { + $keys = explode('.', $path); + $target = array_pop($keys); + + $current = &$this->data($keys); + $target = $alias ?? $target; + + if (is_array($current) && array_key_exists($target, $current)) { + unset($current[$target]); + } + + return $this; + } + + /** + * Replaces the value at the given path with the specified value, supporting aliasing. + * Uses the data method to navigate nested structures and modifies the value if the key exists. + * Does nothing if the path or alias does not exist. + */ + public function replace(string $path, mixed $value, string|null $alias = null): static + { + $keys = explode('.', $path); + $target = array_pop($keys); + + $current = &$this->data($keys); + $target = $alias ?? $target; + + if (is_array($current) && array_key_exists($target, $current)) { + $current[$target] = $value; + } + + return $this; + } + + /** + * Returns all data, optionally filtered using a callback. + * Applies the provided filter to the data if given; otherwise, returns the full dataset. + */ + public function fetch(callable|null $filter = null): array + { + return $filter ? array_filter($this->data, $filter) : $this->data; + } + + public function merge(array $data): static + { + return $this; + } + + /** + * Retrieves the most recently modified or added item, or the full dataset if none are tracked. + */ + public function latest(): mixed + { + $latest = array_pop($this->latest); + + return $latest ? $this->get($latest) : $this->data; + } + + /** + * Creates a new data scope branching from the root. + * + * Initializes a new data scope at the specified path with a given alias. + * The newly created scope retains access to the root data, allowing for hierarchical data management. + * If a scope already exists at the given path and alias, it is returned directly. + */ + public function scope(string $path, string $alias, bool $isolate = false, string $scope = DataScope::class): DataScope + { + $branch = $this->get($path, $alias); + + if ($branch) { + return $branch; + } + + $branch = ObjectManager::getInstance()->create($scope, [ + 'root' => $isolate ? $this : $this->root() + ]); + + return $this->set($path, $branch, $alias)->latest(); + } + + /** + * Returns the root data scope instance. + * If no root is set, returns the current instance as the root. + */ + public function root(): DataScope + { + return $this->root ?? $this; + } + + /** + * Traverses the data structure by following the given keys, optionally creating missing keys. + * Returns a reference to the target value if found, or null if the path is invalid and no construction is allowed. + * Supports array key navigation with an option to auto-construct missing arrays. + */ + private function &data(array $keys, bool $construct = false): mixed + { + $current = &$this->data; + + foreach ($keys as $key) { + if (! isset($current[$key])) { + if ($construct) { + $current[$key] = []; + } + } + + if (! is_array($current[$key])) { + if ($construct) { + $current[$key] = []; + } + } + + $current = &$current[$key]; + } + + return $current; + } +} diff --git a/lib/Magewire/Support/DataScope/Compiler.php b/lib/Magewire/Support/DataScope/Compiler.php new file mode 100644 index 00000000..0789fd63 --- /dev/null +++ b/lib/Magewire/Support/DataScope/Compiler.php @@ -0,0 +1,38 @@ + $uses */ + protected array $uses = []; + + abstract public function compile(DataScope $data): array|string; + + /** + * Assign an additional dependency to be used by callable values requiring + * access to global variables that are otherwise inaccessible during injection. + */ + public function use(string $name, object $object): static + { + $this->uses[$name] = $object; + + return $this; + } + + protected function uses(): array + { + return $this->uses; + } +} diff --git a/lib/Magewire/Support/DataScope/Compiler/RecursiveArray.php b/lib/Magewire/Support/DataScope/Compiler/RecursiveArray.php new file mode 100644 index 00000000..c285c2f2 --- /dev/null +++ b/lib/Magewire/Support/DataScope/Compiler/RecursiveArray.php @@ -0,0 +1,47 @@ +fetch() as $key => $value) { + if (is_array($value)) { + $result[$key] = array_map(function ($item) { + if ($item instanceof DataScope) { + return $this->compile($item); + } + + return $item; + }, $value); + } elseif ($value instanceof DataScope) { + $result[$key] = $this->compile($value); + } elseif (is_callable($value)) { + try { + $result[$key] = $value(array_reverse(array_values($this->uses()))); + } catch (Exception $exception) { + // WIP: logging needs to be implemented in a way... + } + } else { + $result[$key] = $value; + } + } + + return $result; + } +} diff --git a/lib/Magewire/Support/DataScope/InvalidDataScopeException.php b/lib/Magewire/Support/DataScope/InvalidDataScopeException.php new file mode 100644 index 00000000..3470c4c6 --- /dev/null +++ b/lib/Magewire/Support/DataScope/InvalidDataScopeException.php @@ -0,0 +1,18 @@ +getMessage() : $message; + $this->metadata()->push('logs', $message); + + return $this; + } + + /** + * Metadata entry. + */ + public function metadata(): Metadata + { + return $this->data()->get(self::METADATA); + } + + public function report(): array + { + return array_merge($this->data()->all(), ['__metadata' => $this->metadata()]); + } + + protected function data(): DataArray + { + return $this->data ??= $this->newTypeInstance(DataArray::class)->set(self::METADATA, $this->newTypeInstance(Metadata::class) + ->data() + ->defaults([ + 'logs' => [] + ])); + } +} diff --git a/lib/Magewire/Support/Distributor.php b/lib/Magewire/Support/Distributor.php new file mode 100644 index 00000000..9bfdb555 --- /dev/null +++ b/lib/Magewire/Support/Distributor.php @@ -0,0 +1,74 @@ + */ + protected array $instances = []; + + /** + * @param class-string $type + * @param array> $mapping + */ + public function __construct( + protected string $type, + protected array $mapping = [] + ) { + } + + /** + * @return object + */ + public function __call(string $name, array $arguments = []) + { + return $this->instances[$name] ??= $this->create($name, $arguments); + } + + /** + * Resolve the appropriate type for a given name. + * + * @param string $name The instance identifier + * @return class-string The resolved class name + */ + protected function resolve(string $name): string + { + $map = $this->mapping[$name] ?? null; + + if ($map !== null && is_a($map, $this->type, true)) { + return $map; + } + + return $this->type; + } + + protected function create(string|null $type = null, array $arguments = []): object + { + if ($type === null) { + return Factory::create($this->type, $arguments); + } + + return Factory::create($this->resolve($type), $arguments); + } +} diff --git a/lib/Magewire/Support/Factory.php b/lib/Magewire/Support/Factory.php new file mode 100644 index 00000000..1818c5d8 --- /dev/null +++ b/lib/Magewire/Support/Factory.php @@ -0,0 +1,42 @@ + $type + * @return T + */ + public static function create(string $type, array $arguments = []) + { + // POC: Factories can be singletons because of their single responsibility. + if (str_ends_with($type, 'Factory')) { + return static::get($type); + } + + return ObjectManager::getInstance()->create($type, $arguments); // phpcs:ignore + } + + /** + * @template T + * @param class-string $type + * @return T + */ + public static function get(string $type) + { + return ObjectManager::getInstance()->get($type); // phpcs:ignore + } +} diff --git a/lib/Magewire/Support/Metadata.php b/lib/Magewire/Support/Metadata.php new file mode 100644 index 00000000..5d12ce23 --- /dev/null +++ b/lib/Magewire/Support/Metadata.php @@ -0,0 +1,74 @@ +data()->get($prop, 0); + + if (is_int($subject)) { + $this->data()->set($prop, $subject + $by); + } + + return $this; + } + + public function decrement(string $prop, int $by = 1): static + { + $subject = $this->data()->get($prop, 0); + + if (is_int($subject) && $subject > 0) { + $this->data()->set($prop, $subject - $by); + } + + return $this; + } + + public function push(string $prop, mixed $value, string|null $key = null): static + { + $key ??= Random::integer(); + + if (is_array($this->get($prop))) { + $this->data()->set($prop, array_merge($this->get($prop), [$key => $value])); + } + + return $this; + } + + public function get(string $prop): mixed + { + return $this->data()->get($prop); + } + + public function fetch(callable $filter): array + { + return $this->data() + ->filter() + ->byClosure($filter) + ->all(); + } + + protected function data(): DataArray + { + return $this->data ??= $this->dataArrayFactory->create(); + } +} diff --git a/lib/Magewire/Support/Middleware.php b/lib/Magewire/Support/Middleware.php new file mode 100644 index 00000000..5e05d168 --- /dev/null +++ b/lib/Magewire/Support/Middleware.php @@ -0,0 +1,81 @@ + */ + protected array $groups = []; + /** @var array */ + protected array $positions = []; + + public function run(mixed $throughput, callable|null $action = null): mixed + { + $origin = $this->pipes; + $this->pipes[] = $action ?? static fn (mixed $t) => $t; + + try { + return parent::run($this->processGroups($throughput)); + } finally { + $this->pipes = $origin; + } + } + + /** + * Create or retrieve a middleware group with execution position. + */ + public function group(string $name, int $position = 500): Pipeline + { + $this->positions[$name] ??= $position; + + if (! isset($this->groups[$name])) { + $this->groups[$name] = $this->newTypeInstance(Pipeline::class); + } + + return $this->groups[$name]; + } + + /** + * Execute all groups in position order (ascending = lower first). + * + * @throws Throwable + */ + protected function processGroups(mixed $throughput): mixed + { + if (empty($this->groups)) { + return $throughput; + } + + $groups = $this->groups; + + uasort($groups, function ($a, $b) { + $posA = $this->positions[array_search($a, $this->groups, true)] ?? 500; + $posB = $this->positions[array_search($b, $this->groups, true)] ?? 500; + + return $posA <=> $posB; + }); + + foreach ($groups as $group) { + $throughput = $group->run($throughput); + } + + return $throughput; + } +} diff --git a/lib/Magewire/Support/Parser.php b/lib/Magewire/Support/Parser.php new file mode 100644 index 00000000..c4417d22 --- /dev/null +++ b/lib/Magewire/Support/Parser.php @@ -0,0 +1,24 @@ +attributes()->set($match[1], $match[3]); + } + + return $this; + } + + public function attributes(): DataArray + { + return $this->attributes ??= $this->attributesFactory->create(); + } +} diff --git a/lib/Magewire/Support/Pipeline.php b/lib/Magewire/Support/Pipeline.php new file mode 100644 index 00000000..40b804fe --- /dev/null +++ b/lib/Magewire/Support/Pipeline.php @@ -0,0 +1,206 @@ + $pipes */ + protected array $pipes = []; + /** @var Middleware|null $middleware */ + protected Middleware|null $middleware = null; + /** @var array $handlers */ + private array $handlers = []; + + public function __construct( + protected LoggerInterface $logger + ) { + } + + /** + * @throws Throwable + */ + public function run(mixed $throughput): mixed + { + return $this->processPipes($throughput); + } + + /** + * Register a pipe in the pipeline. + * + * Pipes are callbacks that receive the current throughput and a $next callable. + * They must call $next($throughput) to continue the pipeline chain. + * + * @example $pipeline->pipe(fn ($throughput, callable $next) => $next($throughput)); + */ + public function pipe(callable $callback): static + { + $this->pipes[] = $callback; + + return $this; + } + + /** + * Pipeline middleware entrypoint. + */ + public function middleware(): Middleware + { + return $this->middleware ??= $this->newInstance([], Middleware::class); + } + + /** + * Register a handler to execute when an exception occurs during pipeline execution. + * + * Catch handlers receive the exception and logger as arguments. + * Multiple catch handlers can be registered and will all be executed. + * + * @example $pipeline->onCatch(fn($e, $logger) => $logger->error($e->getMessage())); + */ + public function onCatch(callable $handler, string|null $alias = null): static + { + return $this->registerHandler('catch', $handler, $alias); + } + + /** + * Register a handler to execute after pipeline completion (success or failure). + * + * Finally handlers receive the throughput as an argument and are always executed, + * similar to a try-finally block. + * + * @example $pipeline->onFinally(fn($data) => cleanup($data)); + */ + public function onFinally(callable $handler, string|null $alias = null): static + { + return $this->registerHandler('finally', $handler, $alias); + } + + /** + * Register a handler to execute after pipeline completion (success only). + * + * Success handlers receive the throughput as an argument and are always executed + * + * @example $pipeline->onFinally(fn($data) => cleanup($data)); + */ + public function onSuccess(callable $handler, string|null $alias = null): static + { + return $this->registerHandler('success', $handler, $alias); + } + + /** + * Get the number of currently registered pipes. + */ + public function count(): int + { + return count($this->pipes); + } + + /** + * Process all pipes in the pipeline with the given throughput. + * + * Couples all pipes into a single callable chain, applies middleware if configured, + * handles exceptions with catch handlers, and ensures finally handlers are executed. + * Performs cleanup after successful execution to remove one-time pipes. + */ + protected function processPipes(mixed $throughput): mixed + { + try { + $pipeline = $this->couple($this->pipes); + + $throughput = $this->middleware ? $this->middleware()->run($throughput, $pipeline) : $pipeline($throughput); + + $this->processHandler('success', $throughput); + } catch (Throwable $exception) { + $this->processHandler('catch', $exception, $this->logger); + } finally { + $this->processHandler('finally', $throughput); + } + + return $throughput; + } + + /** + * Execute all registered handlers for a specific event type. + * + * Automatically logs critical exceptions when processing 'catch' handlers. + * If a handler itself throws an exception, it's logged but doesn't interrupt other handlers. + */ + protected function processHandler(string $handler, mixed ...$args): static + { + $handlers = $this->handlers[$handler] ?? []; + + // Re-throw exception if no catch handlers are registered to process it. + if (empty($handlers) && $handler === 'catch' && ( $args[0] ?? null ) instanceof Throwable) { + throw $args[0]; + } + + $class = basename(str_replace('\\', '/', __CLASS__)); + + foreach ($handlers as $callback) { + try { + $callback(...$args); + } catch (Throwable $exception) { + $this->logger->error($class . ' exception: ' . $exception->getMessage(), [ + 'handler' => $handler, + 'exception' => $exception + ]); + } + } + + return $this; + } + + /** + * Register a handler for a specific event type. + * + * Prevents duplicate registration of handlers with the same alias unless + * the force flag is set to true. + */ + protected function registerHandler( + string $event, + callable $handler, + string|null $alias = null + ): static { + $this->handlers[$event][$alias ?? Random::alphabetical()] = $handler; + + return $this; + } + + /** + * Couple multiple pipes into a single callable chain. + * + * Uses array_reduce to build a nested chain of callables where each pipe + * wraps the next, creating a middleware-style execution pattern. + */ + protected function couple(array $pipes): callable + { + $core = static fn (mixed $throughput): mixed => $throughput; + + foreach (array_reverse($pipes) as $pipe) { + $core = static function (mixed $throughput) use ($pipe, $core): mixed { + return $pipe($throughput, $core); + }; + } + + return $core; + } +} diff --git a/lib/Magewire/Support/Random.php b/lib/Magewire/Support/Random.php new file mode 100644 index 00000000..4265a3ed --- /dev/null +++ b/lib/Magewire/Support/Random.php @@ -0,0 +1,34 @@ +actions[] = static function () use ($action, $scheduler) { + // TBD. + }; + + return $scheduler; + } +} diff --git a/lib/Magewire/Support/Scheduler.php b/lib/Magewire/Support/Scheduler.php new file mode 100644 index 00000000..eb0630e9 --- /dev/null +++ b/lib/Magewire/Support/Scheduler.php @@ -0,0 +1,63 @@ +newTypeInstance(Interval::class, ['interval' => $interval]); + } + + public function daily(): static + { + $this->every(1)->days(); + return $this; + } + + public function monthly(): Interval + { + return $this->every(1)->months(); + } + + public function yearly($interval): Interval + { + return $this->every(1)->years(); + } + + public function when(callable $condition): Conditions + { + return $this->conditions()->if($condition); + } + + public function unless(callable $condition): Conditions + { + return $this->when(static fn (...$args) => ! $condition(...$args)); + } + + protected function conditions(): Conditions + { + return $this->conditions ??= $this->newTypeInstance(Conditions::class); + } +} diff --git a/lib/Magewire/Support/Scheduler/Interval.php b/lib/Magewire/Support/Scheduler/Interval.php new file mode 100644 index 00000000..cdba7779 --- /dev/null +++ b/lib/Magewire/Support/Scheduler/Interval.php @@ -0,0 +1,55 @@ +unit = 'minutes'; + return $this; + } + + public function hours(): static + { + $this->unit = 'hours'; + return $this; + } + + public function days(): static + { + $this->unit = 'days'; + return $this; + } + + public function months(): static + { + $this->unit = 'months'; + return $this; + } + + public function years(): static + { + $this->unit = 'years'; + return $this; + } +} diff --git a/lib/Magewire/Support/Str.php b/lib/Magewire/Support/Str.php new file mode 100644 index 00000000..4e425650 --- /dev/null +++ b/lib/Magewire/Support/Str.php @@ -0,0 +1,16 @@ +enabled; + } +} diff --git a/lib/MagewireBc/Features/SupportMagewireBackwardsCompatibility/HandlesComponentBackwardsCompatibility.php b/lib/MagewireBc/Features/SupportMagewireBackwardsCompatibility/HandlesComponentBackwardsCompatibility.php new file mode 100644 index 00000000..50395016 --- /dev/null +++ b/lib/MagewireBc/Features/SupportMagewireBackwardsCompatibility/HandlesComponentBackwardsCompatibility.php @@ -0,0 +1,66 @@ +magewireResolvePublicProperties(); + } + + if ($refresh || $this->__publicProperties === null) { + $this->__publicProperties = $this->magewireResolvePublicProperties(); + } + + return $this->__publicProperties; + } + + private function magewireResolvePublicProperties(): array + { + return array_diff_key(Utils::getPublicProperties($this), array_flip(self::RESERVED_PROPERTIES)); + } +} diff --git a/lib/MagewireBc/Features/SupportMagewireBackwardsCompatibility/HandlesFormComponentBackwardsCompatibility.php b/lib/MagewireBc/Features/SupportMagewireBackwardsCompatibility/HandlesFormComponentBackwardsCompatibility.php new file mode 100644 index 00000000..b9cf7c52 --- /dev/null +++ b/lib/MagewireBc/Features/SupportMagewireBackwardsCompatibility/HandlesFormComponentBackwardsCompatibility.php @@ -0,0 +1,20 @@ +component(); + + // Continue if components isn't available or backwards compatible. + if (! is_string($name) || ! $component || ! store($component)->get('magewire:bc') ?? false) { + return [$name, $params]; + } + + foreach ($this->resolvers as $resolver) { + if (! $resolver->supports($component, $name)) { + continue; + } + + [$name, $params] = $resolver->resolve($component, $name, $params); + break; + } + + return [$name, $params]; + } +} diff --git a/lib/MagewireBc/Features/SupportMagewireBackwardsCompatibility/Resolver/ArrayPropertyReconstructionResolver.php b/lib/MagewireBc/Features/SupportMagewireBackwardsCompatibility/Resolver/ArrayPropertyReconstructionResolver.php new file mode 100644 index 00000000..8f3bc698 --- /dev/null +++ b/lib/MagewireBc/Features/SupportMagewireBackwardsCompatibility/Resolver/ArrayPropertyReconstructionResolver.php @@ -0,0 +1,105 @@ +mapping as $class => $methods) { + if (is_a($component, $class) && ($methods[$method] ?? null) !== null) { + return true; + } + } + + return false; + } + + public function resolve(Component $component, string $method, array $params): array + { + if (count($params) < 2) { + return [$method, $params]; + } + + $property = $this->extractPropertyName($method); + + if ($property === null || ! property_exists($component, $property)) { + return [$method, $params]; + } + + [$value, $path] = $params; + + $data = $component->{$property}; + + if (is_array($data) && is_string($path)) { + $this->fill($data, $path, $value); + } + + return [$method, [$property => $data]]; + } + + /** + * Extract the property name from a hook method like 'updatingData' or 'updatedAddress'. + */ + private function extractPropertyName(string $method): string|null + { + if (preg_match('/^(?:updating|updated)(.+)$/', $method, $matches)) { + return lcfirst($matches[1]); + } + + return null; + } + + /** + * Set a value in a nested array using dot notation path. + */ + private function fill(array &$array, string $path, mixed $value): void + { + $parts = explode('.', $path); + $current = &$array; + + foreach ($parts as $index => $part) { + if ($index === array_key_last($parts)) { + $current[$part] = $value; + continue; + } + + if (! is_array($current[$part] ?? null)) { + $current[$part] = []; + } + + $current = &$current[$part]; + } + } +} diff --git a/lib/MagewireBc/Features/SupportMagewireBackwardsCompatibility/Resolver/UpdatingUpdatedArgumentSwapResolver.php b/lib/MagewireBc/Features/SupportMagewireBackwardsCompatibility/Resolver/UpdatingUpdatedArgumentSwapResolver.php new file mode 100644 index 00000000..ff941c45 --- /dev/null +++ b/lib/MagewireBc/Features/SupportMagewireBackwardsCompatibility/Resolver/UpdatingUpdatedArgumentSwapResolver.php @@ -0,0 +1,36 @@ +$method($component, $method, $params); + } + + /** + * Swap parameters when backwards compatible. + */ + private function updated(Component $component, string $method, array $params): array + { + return [$method, [$params[1], $params[0]]]; + } +} diff --git a/lib/MagewireBc/Features/SupportMagewireBackwardsCompatibility/SupportMagewireBackwardsCompatibility.php b/lib/MagewireBc/Features/SupportMagewireBackwardsCompatibility/SupportMagewireBackwardsCompatibility.php new file mode 100644 index 00000000..01ea28e7 --- /dev/null +++ b/lib/MagewireBc/Features/SupportMagewireBackwardsCompatibility/SupportMagewireBackwardsCompatibility.php @@ -0,0 +1,52 @@ +component + ->getRequest() + ->setServerMemo([ + 'data' => $this->getProperties() + ]); + } + + public function dehydrate(ComponentContext $context): void + { + $bc = [ + 'map' => $this->resolvePathsMap(), + 'preferences' => $this->resolvePreferences() + ]; + + foreach ($bc as $ikey => $value) { + $context->pushEffect('bc', $value, $ikey); + } + } + + private function resolvePathsMap(): array + { + return [ + 'data' => 'path:$wire', + '__livewire' => 'path:queuedUpdates' + ]; + } + + private function resolvePreferences(): array + { + return []; + } +} diff --git a/lib/MagewireBc/Model/Component/Resolver/Layout.php b/lib/MagewireBc/Model/Component/Resolver/Layout.php new file mode 100644 index 00000000..4c3c3fd2 --- /dev/null +++ b/lib/MagewireBc/Model/Component/Resolver/Layout.php @@ -0,0 +1,134 @@ +resultPageFactory = $resultPageFactory; + $this->eventManager = $eventManager; + $this->componentFactory = $componentFactory; + } + + public function getName(): string + { + return 'layout'; + } + + public function complies(AbstractBlock $block): bool + { + return true; + } + + /** + * @throws MissingComponentException + */ + public function construct(AbstractBlock $block): Component + { + $magewire = $block->getData('magewire'); + + if ($magewire) { + $component = is_array($magewire) + ? $magewire['type'] : (is_object($magewire) + ? $magewire : $this->componentFactory->create()); + + if ($component instanceof Component) { +// if ($this->init) { +// $component = $this->componentFactory->create($component); +// } + + $component->name = $block->getNameInLayout(); + $component->id ??= $component->name; + + return $component->setParent($this->determineTemplate($block, $component)); + } + } + + throw new MissingComponentException(__('Magewire component not found')); + } + + /** + * @throws MissingComponentException + */ + public function reconstruct(RequestInterface $request): Component + { + $page = $this->resultPageFactory->create(); + $page->addHandle(strtolower($request->getFingerprint('handle')))->initLayout(); + + /** + * @deprecated this code is no longer supported and may cause issues if used. + * Please do not use it in the future. + */ + $this->eventManager->dispatch('locate_wire_component_before', [ + 'post' => $request->toArray(), + 'page' => $page + ]); + + /** @var Template|false $block */ + $block = $page->getLayout()->getBlock($request->getFingerprint('name')); + + if ($block === false) { + throw new HttpException( + 404, + sprintf('Magewire component "%1s" could not be found', $request->getFingerprint('name')) + ); + } + + return $this->construct($block); + } + + /** + * Determines the template by a default template path + * when the path is not defined within the layout. + * + * Results in: {Module_Name::magewire/dashed-class-name.phtml} + */ + protected function determineTemplate(BlockInterface $block, MagewireComponent $component): Template + { + /** @var Template $block */ + + if ($block->getTemplate() !== null) { + return $block; + } + + $module = array_filter( + explode('\\', get_class($component)), + static fn (string $part) => $part !== 'Interceptor' + ); + + $prefix = $module[0] . '_' . $module[1]; + $affix = strtolower(preg_replace('/(?setTemplate($prefix . '::magewire/' . $affix . '.phtml'); + } +} diff --git a/lib/MagewireBc/Model/Component/ResolverInterface.php b/lib/MagewireBc/Model/Component/ResolverInterface.php new file mode 100644 index 00000000..dec89240 --- /dev/null +++ b/lib/MagewireBc/Model/Component/ResolverInterface.php @@ -0,0 +1,52 @@ +objectManager = $objectManager; + $this->layout = $layout; + } + + public function create(?Component $component = null, array $data = []): Component + { + $class = $component ? get_class($component) : Component::class; + + if (isset($this->instances[$class])) { + return $this->objectManager->create($class, [$data]); + } + + $this->instances[$class] = $component; + return $component; + } +} diff --git a/lib/MagewireBc/Model/ComponentResolver.php b/lib/MagewireBc/Model/ComponentResolver.php new file mode 100644 index 00000000..2364d8ff --- /dev/null +++ b/lib/MagewireBc/Model/ComponentResolver.php @@ -0,0 +1,21 @@ +dispatchQueue; + } + + /** + * @deprecated Has been replaced with a universal "dispatch" method. + * @see dispatch() + */ + public function dispatchBrowserEvent($event, $data = null): void + { + $this->dispatch($event, ...$data); + } +} diff --git a/lib/MagewireBc/Model/Concern/Emit.php b/lib/MagewireBc/Model/Concern/Emit.php new file mode 100644 index 00000000..34f8672c --- /dev/null +++ b/lib/MagewireBc/Model/Concern/Emit.php @@ -0,0 +1,75 @@ + $params + */ + public function emit(string $event, $params = []): Event + { + return $this->dispatch($event, $params); + } + + /** + * @param array $params + */ + public function emitUp(string $event, $params = []): Event + { + } + + /** + * Only emit an event on the component that fired the event. + * + * @param array $params + */ + public function emitSelf(string $event, $params = []): Event + { + } + + /** + * Only emit an event to other components of the same type. + * + * @param array $params + */ + public function emitTo(string $name, string $event, $params = []): Event + { + return $this->emit($event, $params)->to($name); + } + + /** + * Only emit a "refresh" event to other components of the same type. + * + * @param array $params + */ + public function emitToRefresh(string $name, $params = []): Event + { + } + + /** + * Refresh all parents. + * + * @param array $params + */ + public function emitToRefreshUp($params = []): Event + { + } +} diff --git a/src/Model/Concern/Error.php b/lib/MagewireBc/Model/Concern/Error.php similarity index 67% rename from src/Model/Concern/Error.php rename to lib/MagewireBc/Model/Concern/Error.php index 6fbcd10c..abb182c6 100644 --- a/src/Model/Concern/Error.php +++ b/lib/MagewireBc/Model/Concern/Error.php @@ -1,4 +1,5 @@ -errors; } - foreach ($targets as $key => $value) - { + foreach ($targets as $key => $value) { if (isset($this->errors[$value])) { $targets[$value] = $this->errors[$value]; } @@ -52,30 +49,24 @@ public function getErrors(array $targets = []): array return $targets; } - /** - * @param array $targets - * @return bool - */ public function hasErrors(array $targets = []): bool { - return !empty($this->getErrors($targets)); + return ! empty($this->getErrors($targets)); } - /** - * @param string $property - * @return bool - */ public function hasError(string $property): bool { return isset($this->errors[$property]); } - /** - * @param string $property - * @return Phrase - */ public function getError(string $property): Phrase { return $this->hasError($property) ? $this->errors[$property] : __('No %1 error found', [$property]); } + + public function clearErrors(): self + { + $this->errors = []; + return $this; + } } diff --git a/lib/MagewireBc/Model/Concern/Request.php b/lib/MagewireBc/Model/Concern/Request.php new file mode 100644 index 00000000..a539b579 --- /dev/null +++ b/lib/MagewireBc/Model/Concern/Request.php @@ -0,0 +1,36 @@ +__magewireRequest = $request; + } + + return $this->__magewireRequest ??= Factory::create(RequestInterface::class); + } +} diff --git a/lib/MagewireBc/Model/Concern/View.php b/lib/MagewireBc/Model/Concern/View.php new file mode 100644 index 00000000..7ecbfba0 --- /dev/null +++ b/lib/MagewireBc/Model/Concern/View.php @@ -0,0 +1,58 @@ +skipRender; + } + + return store($this)->get('skipRender', false); + } + + /** + * Switch template of the parent block. + * + * @deprecated Has been replaced with calling the 'magewireBlock' manually and is no longer a shortcut method. + * @throws BackwardsIncompatibilityException + */ + public function switchTemplate(string $template): void + { + if (method_exists($this, 'magewireBlock')) { + $block = $this->magewireBlock(); + + if ($block instanceof Template) { + $block->setTemplate($template); + return; + } + } + + throw new BackwardsIncompatibilityException('Something went wrong while switching template.'); + } +} diff --git a/src/Model/HydratorInterface.php b/lib/MagewireBc/Model/HydratorInterface.php similarity index 89% rename from src/Model/HydratorInterface.php rename to lib/MagewireBc/Model/HydratorInterface.php index 5a61d15a..87ca770d 100644 --- a/src/Model/HydratorInterface.php +++ b/lib/MagewireBc/Model/HydratorInterface.php @@ -1,4 +1,5 @@ + */ + private array $history = []; + + /** + * @var array + */ + private array $views = []; + + private string|null $start = null; + + /** + * Marks view as 'start rendering'. + */ + public function start(string $name): LayoutRenderLifecycle + { + if ($this->start === null) { + $this->start = $name; + } + + $this->views[$name] = null; + return $this; + } + + /** + * Marks view as 'stop rendering'. + */ + public function stop(string $parent): LayoutRenderLifecycle + { + $views = $this->getViews(); + $position = array_search($parent, array_keys($views), true); + + if ($position === false) { + return $this; + } + + $children = array_slice($views, $position + 1, count($views), true); + + // Special use case where a single component on the page doesn't have a child. + if (isset($this->views[$parent]) && $parent === $this->start) { + $children[$parent] = $this->views[$parent]; + } + + foreach ($children as $key => $value) { + if ($parent === $this->start) { + $this->history[$key] = $value; + } else { + $this->history[$parent][$key] = $value; + } + + unset($this->views[$key]); + } + + return $this; + } + + public function exists(string $name): bool + { + return array_key_exists($name, $this->views); + } + + public function canStop(string $name): bool + { + return $name !== array_search($name, array_reverse($this->views), true) || $name === $this->start; + } + + public function getViews(): array + { + return $this->views; + } + + public function hasHistory(): bool + { + return count($this->getHistory()) !== 0; + } + + public function getHistory(): array + { + return $this->history; + } + + public function setStartTag(string $tag, string $for): LayoutRenderLifecycle + { + $this->views[$for] = $tag; + return $this; + } + + /** + * Validate if it's the overall highest laying component without a parent. + */ + public function isParent(string $name): bool + { + $views = $this->getViews(); + $parent = array_search($name, array_keys($views), true); + + return $parent === 0; + } + + /** + * Validate if it's a nested component (no matter the depth). + */ + public function isChild(string $name): bool + { + return ! $this->isParent($name); + } + + /** + * Validate if family member of the given parent name. + */ + public function isDescendant(string $from): bool + { + return array_key_exists($from, $this->getViews()) && $this->views[$from] === null; + } +} diff --git a/lib/MagewireBc/Model/Request.php b/lib/MagewireBc/Model/Request.php new file mode 100644 index 00000000..bd9f3fb6 --- /dev/null +++ b/lib/MagewireBc/Model/Request.php @@ -0,0 +1,107 @@ +memo)) { + return $this->memo[$index] ?? null; + } + + return $this->memo; + } + + public function setServerMemo($memo): \Magewirephp\Magewire\Model\RequestInterface + { + $this->memo = $memo; + return $this; + } + + public function getUpdates(string $index) + { + // TODO: Implement getUpdates() method. + } + + public function setUpdates($updates): \Magewirephp\Magewire\Model\RequestInterface + { + return $this; + } + + public function getSectionByName(string $section): array|null + { + return null; + } + + public function isSubsequent(bool|null $flag = null, bool $force = false) + { + return $this->magewireServiceProvider + ->runtime() + ->mode() + ->isSubsequent(); + } + + public function isPreceding(): bool + { + return ! $this->isSubsequent(); + } + + public function isRefreshing(bool|null $flag = null) + { + return false; + } + + public function toArray(): array + { + return []; + } +} diff --git a/src/Model/RequestInterface.php b/lib/MagewireBc/Model/RequestInterface.php similarity index 66% rename from src/Model/RequestInterface.php rename to lib/MagewireBc/Model/RequestInterface.php index 0d3b6bd7..eb182eac 100644 --- a/src/Model/RequestInterface.php +++ b/lib/MagewireBc/Model/RequestInterface.php @@ -1,4 +1,5 @@ obj = $obj; + $this->reflected = new ReflectionClass($obj); + } + + public function &__get($name) + { + $getProperty = function &() use ($name) { + return $this->{$name}; + }; + + $getProperty = $getProperty->bindTo($this->obj, get_class($this->obj)); + + return $getProperty(); + } + + public function __set($name, $value) + { + $setProperty = function () use ($name, &$value) { + $this->{$name} = $value; + }; + + $setProperty = $setProperty->bindTo($this->obj, get_class($this->obj)); + + $setProperty(); + } + + public function __call($name, $params) + { + $method = $this->reflected->getMethod($name); + + $method->setAccessible(true); + + return $method->invoke($this->obj, ...$params); + } + }; +} + +function once($fn) +{ + $hasRun = false; + + return function (...$params) use ($fn, &$hasRun) { + if ($hasRun) { + return; + } + + $hasRun = true; + + return $fn(...$params); + }; +} + +function of(...$params) +{ + return $params; +} + +function revert(&$variable) +{ + $cache = $variable; + + return function () use (&$variable, $cache) { + $variable = $cache; + }; +} + +function wrap($subject) +{ + return ObjectManager::getInstance()->create(Wrapped::class, [ + 'target' => $subject + ]); +} + +function pipe($subject) +{ + return ObjectManager::getInstance()->create(Pipe::class, [ + 'target' => $subject + ]); +} + +function trigger($name, ...$params) +{ + return ObjectManager::getInstance()->get(EventBus::class)->trigger($name, ...$params); +} + +function on($name, $callback) +{ + return ObjectManager::getInstance()->get(EventBus::class)->on($name, $callback); +} + +function after($name, $callback) +{ + return ObjectManager::getInstance()->get(EventBus::class)->after($name, $callback); +} + +function before($name, $callback) +{ + return ObjectManager::getInstance()->get(EventBus::class)->before($name, $callback); +} + +function off($name, $callback) +{ + return ObjectManager::getInstance()->get(EventBus::class)->off($name, $callback); +} + +function memoize($target) +{ + static $memo = new \WeakMap(); + + return new class($target, $memo) { + public function __construct( + protected $target, + protected &$memo + ) { + } + + public function __call($method, $params) + { + $this->memo[$this->target] ??= []; + + $signature = $method . crc32(json_encode($params)); + + return $this->memo[$this->target][$signature] ??= $this->target->$method(...$params); + } + }; +} + +function store($instance = null) +{ + if (! $instance) { + $instance = ObjectManager::getInstance()->get(\Magewirephp\Magewire\Mechanisms\DataStore::class); + } + + return new class($instance) { + public function __construct( + protected $instance + ) { + } + + public function get($key, $default = null) + { + return ObjectManager::getInstance()->get(\Magewirephp\Magewire\Mechanisms\DataStore::class)->get($this->instance, $key, $default); + } + + public function set($key, $value): void + { + ObjectManager::getInstance()->get(\Magewirephp\Magewire\Mechanisms\DataStore::class)->set($this->instance, $key, $value); + } + + public function push($key, $value, $iKey = null): void + { + ObjectManager::getInstance()->get(\Magewirephp\Magewire\Mechanisms\DataStore::class)->push($this->instance, $key, $value, $iKey); + } + + public function find($key, $iKey = null, $default = null) + { + return ObjectManager::getInstance()->get(\Magewirephp\Magewire\Mechanisms\DataStore::class)->find($this->instance, $key, $iKey, $default); + } + + public function has($key, $iKey = null) + { + return ObjectManager::getInstance()->get(\Magewirephp\Magewire\Mechanisms\DataStore::class)->has($this->instance, $key, $iKey); + } + + public function unset($key, $iKey = null) + { + return ObjectManager::getInstance()->get(\Magewirephp\Magewire\Mechanisms\DataStore::class)->unset($this->instance, $key, $iKey); + } + }; +} + +/** + * Get the specified configuration value. + * + * @param string $key + * @param mixed $default + * + * @return mixed|array + */ +function config(string $key, mixed $default = null): mixed +{ + $magewireConfig = ObjectManager::getInstance()->get(MagewireConfig::class); + + try { + return $magewireConfig->getValue($key) ?? $default; + } catch (FileSystemException|RuntimeException $exception) { + $logger = ObjectManager::getInstance()->get(LoggerInterface::class); + $logger->critical($exception->getMessage(), ['exception' => $exception]); + } + + return null; +} + +/** + * Get the available container instance. + * + * @param null $abstract + * @param array $arguments + * + * @return mixed + * @throws NotFoundException + */ +function app($abstract = null, array $arguments = []): mixed +{ + if (is_string($abstract) && class_exists($abstract)) { + return ObjectManager::getInstance()->get($abstract); + } + if (is_null($abstract)) { + return ObjectManager::getInstance(); + } + if (is_object($abstract)) { + return ObjectManager::getInstance()->get($abstract); + } + + $containers = ObjectManager::getInstance()->get(Containers::class); + + return $containers->item($abstract); +} + +/** + * Get an item from an array or object using "dot" notation. + * + * @param mixed $target + * @param string|array|int|null $key + * @param mixed $default + * + * @return mixed + */ +function data_get($target, $key, $default = null) +{ + if (is_null($key)) { + return $target; + } + + $key = is_array($key) ? $key : explode('.', $key); + + foreach ($key as $i => $segment) { + unset($key[$i]); + + if (is_null($segment)) { + return $target; + } + + if ($segment === '*') { + if (! is_iterable($target)) { + return value($default); + } + + $result = []; + + foreach ($target as $item) { + $result[] = data_get($item, $key); + } + + return in_array('*', $key) ? Arr::collapse($result) : $result; + } + + $segment = match ($segment) { + '\*' => '*', + '\{first}' => '{first}', + '{first}' => array_key_first(is_array($target) ? $target : collect($target)->all()), + '\{last}' => '{last}', + '{last}' => array_key_last(is_array($target) ? $target : collect($target)->all()), + default => $segment + }; + + if (Arr::accessible($target) && Arr::exists($target, $segment)) { + $target = $target[$segment]; + } elseif (is_object($target) && isset($target->{$segment})) { + $target = $target->{$segment}; + } else { + return value($default); + } + } + + return $target; +} + +function response($content = '', $status = 200, array $headers = []) +{ + $factory = app(ResponseFactory::class); + + if (func_num_args() === 0) { + return $factory; + } + + return $factory->make($content, $status, $headers); +} + +function map(callable $callback, array $data): array +{ + $keys = array_keys($data); + $items = array_map($callback, $data, $keys); + + return array_combine($keys, $items); +} + +function map_with_keys(callable $callback, array $data): array +{ + $result = []; + + foreach ($data as $key => $value) { + $assoc = $callback($value, $key); + + foreach ($assoc as $mapKey => $mapValue) { + $result[$mapKey] = $mapValue; + } + } + + return $result; +} diff --git a/lib/registration.php b/lib/registration.php new file mode 100644 index 00000000..a5a9ce39 --- /dev/null +++ b/lib/registration.php @@ -0,0 +1,17 @@ + [ + 'source' => [ + 'portman/lib/Livewire' => [ + 'composer' => [ + 'name' => 'livewire/livewire', + 'version' => '~3.7.11', + 'version-lock' => '3.7.11', + 'base-path' => 'src' + ], + 'glob' => '**/*.php', + 'ignore' => [ + '{Wi,Attr,hel,Imp,Form}*', + 'Livewire.php', + 'Attributes/**/*', + '!Attributes/{Locked,On}.php', + 'Drawer/{ImplicitRouteBinding,Regexes}*', + 'Exceptions/{Event,Livewire,Root}*', + 'Features/**/*', + '!Features/Support{Attributes,Events,LifecycleHooks,Locales,NestingComponents,Redirects,FormObjects,Validation,LockedProperties,Streaming}/**/*', + 'Features/SupportEvents/TestsEvents.php', + 'Features/SupportRedirects/TestsRedirects.php', + 'Mechanisms/CompileLivewireTags/**/*', + 'Mechanisms/ExtendBlade/**/*', + 'Mechanisms/HandleComponents/Synthesizers/{Carbon,Collection,Stringable}*', + 'Mechanisms/HandleComponents/{BaseRenderless,CorruptComponent,ViewContext}*', + 'Mechanisms/RenderComponent.php', + 'Component.php', + 'LivewireServiceProvider.php', + 'Features/SupportRedirects/Redirector.php' + ] + ] + ], + 'augmentation' => [ + 'portman/Livewire' + ], + 'output' => 'dist' + ], + 'transformations' => [ + 'Livewire\\' => [ + 'rename' => 'Magewirephp\\Magewire\\', + 'children' => [ + 'LivewireManager' => [ + 'rename' => 'MagewireManager', + 'remove-methods' => [ + 'setProvider', + 'provide', + 'component', + 'componentHook', + 'propertySynthesizer', + 'directive', + 'precompiler', + 'new', + 'isDiscoverable', + 'resolveMissingComponent', + 'snapshot', + 'fromSnapshot', + 'listen', + 'current', + 'findSynth', + 'updateProperty', + 'isLivewireRequest', + 'componentHasBeenRendered', + 'forceAssetInjection', + 'setUpdateRoute', + 'getUpdateUri', + 'setScriptRoute', + 'useScriptTagAttributes', + 'withUrlParams', + 'withQueryParams', + 'withCookie', + 'withCookies', + 'withHeaders', + 'withoutLazyLoading', + 'test', + 'visit', + 'actingAs', + 'isRunningServerless', + 'addPersistentMiddleware', + 'setPersistentMiddleware', + 'getPersistentMiddleware', + 'originalUrl', + 'originalPath', + 'originalMethod' + ] + ], + 'LivewireServiceProvider' => [ + 'rename' => 'MagewireServiceProvider' + ], + 'Features\\SupportRedirects\\HandlesRedirects' => [ + 'remove-methods' => [ + 'redirectAction', + 'redirectRoute', + 'redirectIntended' + ] + ] + ] + ], + 'Magewirephp\\Magewire\\' => [ + 'file-doc-block' => '/** + * Livewire copyright © Caleb Porzio (https://github.com/livewire/livewire). + * Magewire copyright © Willem Poortman 2024-present. + * All rights reserved. + * + * Please read the README and LICENSE files for more + * details on copyrights and license information. + */', + ] + ], + 'post-processors' => [ + 'rector' => false, + 'php-cs-fixer' => false + ] +]; diff --git a/portman/Livewire/ComponentHook.php b/portman/Livewire/ComponentHook.php new file mode 100644 index 00000000..ea8ec665 --- /dev/null +++ b/portman/Livewire/ComponentHook.php @@ -0,0 +1,51 @@ +component; + } + + /** + * @deprecated Still undecided if this should be something that needs to go into the framework. + */ + public function callMagewireComponentConstruct(...$params): void + { + if (method_exists($this, 'magewireComponentConstruct')) { + $this->magewireComponentConstruct(...$params); + } + } + + /** + * @deprecated Still undecided if this should be something that needs to go into the framework. + */ + public function callMagewireComponentReconstruct(...$params): void + { + if (method_exists($this, 'magewireComponentReconstruct')) { + $this->magewireComponentReconstruct(...$params); + } + } + + /** + * @deprecated Has been replaced with the component method eliminating the get prefix. + * @see self::$component() + */ + public function getComponent() + { + return $this->component; + } +} diff --git a/portman/Livewire/ComponentHookRegistry.php b/portman/Livewire/ComponentHookRegistry.php new file mode 100644 index 00000000..02c7c8c1 --- /dev/null +++ b/portman/Livewire/ComponentHookRegistry.php @@ -0,0 +1,111 @@ +provide(); + } + + if (in_array($hook, static::$componentHooks)) return; + + static::$componentHooks[] = $hook; + } + + static function boot() + { + static::$components = new \WeakMap(); + + foreach (static::$componentHooks as $hook) { + on('mount', function ($component, $params, $key, $parent) use ($hook) { + if (! $hook = static::initializeHook($hook, $component)) { + return; + } + + $hook->callBoot(); + $hook->callMount($params, $parent); + }); + + on('hydrate', function ($component, $memo) use ($hook) { + if (! $hook = static::initializeHook($hook, $component)) { + return; + } + + $hook->callBoot(); + $hook->callHydrate($memo); + }); + } + + on('update', function ($component, $fullPath, $newValue) { + $propertyName = \Magewirephp\Magewire\Drawer\Utils::beforeFirstDot($fullPath); + + return static::proxyCallToHooks($component, 'callUpdate')($propertyName, $fullPath, $newValue); + }); + + on('call', function ($component, $method, $params, $addEffect, $earlyReturn) { + return static::proxyCallToHooks($component, 'callCall')($method, $params, $earlyReturn); + }); + + on('render', function ($component, $view, $data) { + return static::proxyCallToHooks($component, 'callRender')($view, $data); + }); + + on('rendered', function ($component, $view, $data) { + return static::proxyCallToHooks($component, 'callRendered')($view, $data); + }); + + on('dehydrate', function ($component, $context) { + static::proxyCallToHooks($component, 'callDehydrate')($context); + }); + + on('destroy', function ($component, $context) { + static::proxyCallToHooks($component, 'callDestroy')($context); + }); + + on('exception', function ($target, $e, $stopPropagation) { + if ($target instanceof \Magewirephp\Magewire\Component) { + static::proxyCallToHooks($target, 'callException')($e, $stopPropagation); + } + }); + } + + static public function initializeHook($hook, $target) + { + if (! isset(static::$components[$target])) { + static::$components[$target] = []; + } + + $hook = ObjectManager::getInstance()->create($hook::class); + + $hook->setComponent($target); + + // If no `skip` method has been implemented, then boot the hook anyway + if (method_exists($hook, 'skip') && $hook->skip()) { + return; + } + + static::$components[$target][] = $hook; + + return $hook; + } +} diff --git a/portman/Livewire/Concerns/InteractsWithProperties.php b/portman/Livewire/Concerns/InteractsWithProperties.php new file mode 100644 index 00000000..efdcafa1 --- /dev/null +++ b/portman/Livewire/Concerns/InteractsWithProperties.php @@ -0,0 +1,102 @@ +{Utils::beforeFirstDot($name)}; + + if (Utils::containsDots($name)) { + return data_get($value, Utils::afterFirstDot($name)); + } + + return $value; + } + + function fill($values) + { + $publicProperties = array_keys($this->all()); + + if ($values instanceof DataObject) { + $values = $values->toArray(); + } + + foreach ($values as $key => $value) { + if (in_array(Utils::beforeFirstDot($key), $publicProperties)) { + data_set($this, $key, $value); + } + } + } + + public function reset(...$properties) + { + $properties = count($properties) && is_array($properties[0]) + ? $properties[0] + : $properties; + + // Reset all + if (empty($properties)) { + $properties = array_keys($this->all()); + } + + $freshInstance = Factory::create(static::class); + + foreach ($properties as $property) { + $property = str($property); + + // Check if the property contains a dot which means it is actually on a nested object like a FormObject + if (str($property)->contains('.')) { + $propertyName = $property->afterLast('.'); + $objectName = $property->before('.'); + + // form object reset +// if (is_subclass_of($this->{$objectName}, Form::class)) { +// $this->{$objectName}->reset($propertyName); +// continue; +// } + + $object = data_get($freshInstance, $objectName, null); + + if (is_object($object)) { + $isInitialized = (new \ReflectionProperty($object, (string) $propertyName))->isInitialized($object); + } else { + $isInitialized = false; + } + } else { + $isInitialized = (new \ReflectionProperty($freshInstance, (string) $property))->isInitialized($freshInstance); + } + + // Handle resetting properties that are not initialized by default. + if (! $isInitialized) { + data_forget($this, (string) $property); + continue; + } + + data_set($this, $property, data_get($freshInstance, $property)); + } + } + + function all() + { + return Utils::getPublicPropertiesDefinedOnSubclass($this); + } +} diff --git a/portman/Livewire/Drawer/BaseUtils.php b/portman/Livewire/Drawer/BaseUtils.php new file mode 100644 index 00000000..da1321e8 --- /dev/null +++ b/portman/Livewire/Drawer/BaseUtils.php @@ -0,0 +1,36 @@ +getDeclaringClass()->getName() !== \Magewirephp\Magewire\Component::class; + }); + } + + static function getPublicMethodsDefinedBySubClass($target) + { + $methods = array_filter((new \ReflectionObject($target))->getMethods(), function ($method) { + $isInBaseComponentClass = $method->getDeclaringClass()->getName() === \Livewire\Component::class; + + return $method->isPublic() + && ! $method->isStatic() + && ! $isInBaseComponentClass; + }); + + return array_map(function ($method) { + return $method->getName(); + }, $methods); + } +} diff --git a/portman/Livewire/Drawer/Utils.php b/portman/Livewire/Drawer/Utils.php new file mode 100644 index 00000000..8e75a947 --- /dev/null +++ b/portman/Livewire/Drawer/Utils.php @@ -0,0 +1,134 @@ +mapWithKeys(function ($value, $key) { + return [$key => static::escapeStringForHtml($value)]; + })->map(function ($value, $key) { + return sprintf('%s="%s"', $key, $value); + })->implode(' '); + } + + static function escapeStringForHtml($subject) + { + if (is_string($subject) || is_numeric($subject)) { + return htmlspecialchars($subject, ENT_QUOTES | ENT_SUBSTITUTE); + } + + return htmlspecialchars(json_encode($subject), ENT_QUOTES | ENT_SUBSTITUTE); + } + + static function pretendResponseIsFile($file, $mimeType = 'application/javascript') + { + $expires = strtotime('+1 year'); + $lastModified = filemtime($file); + $cacheControl = 'public, max-age=31536000'; + + if (static::matchesCache($lastModified)) { + return response('', 304, [ + 'Expires' => static::httpDate($expires), + 'Cache-Control' => $cacheControl, + ]); + } + + $headers = [ + 'Content-Type' => "$mimeType; charset=utf-8", + 'Expires' => static::httpDate($expires), + 'Cache-Control' => $cacheControl, + 'Last-Modified' => static::httpDate($lastModified), + ]; + + if (str($file)->endsWith('.br')) { + $headers['Content-Encoding'] = 'br'; + } + + return response()->file($file, $headers); + } + + static function matchesCache($lastModified) + { + $ifModifiedSince = $_SERVER['HTTP_IF_MODIFIED_SINCE'] ?? ''; + + return @strtotime($ifModifiedSince) === $lastModified; + } + + static function httpDate($timestamp) + { + return sprintf('%s GMT', gmdate('D, d M Y H:i:s', $timestamp)); + } + + static function containsDots($subject) + { + return str_contains($subject, '.'); + } + + static function dotSegments($subject) + { + return explode('.', $subject); + } + + static function beforeFirstDot($subject) + { + return head(explode('.', $subject)); + } + + static function afterFirstDot($subject): string + { + return str($subject)->after('.'); + } + + static function hasProperty($target, $property) + { + return property_exists($target, static::beforeFirstDot($property)); + } + + static function extractAttributeDataFromHtml($html, $attribute) + { + $data = (string)str($html)->betweenFirst($attribute . '="', '"'); + + return json_decode( + htmlspecialchars_decode($data, ENT_QUOTES | ENT_SUBSTITUTE), + associative: true, + ); + } +} diff --git a/portman/Livewire/Features/SupportEvents/Event.php b/portman/Livewire/Features/SupportEvents/Event.php new file mode 100644 index 00000000..52ac8d0a --- /dev/null +++ b/portman/Livewire/Features/SupportEvents/Event.php @@ -0,0 +1,30 @@ + $this->name, 'params' => $this->params]; + + if ($this->self) { + $output['self'] = true; + } + if ($this->component) { + $output['to'] = app(ComponentRegistry::class)->getName($this->component); + } + + return $output; + } +} diff --git a/portman/Livewire/Features/SupportLocales/SupportLocales.php b/portman/Livewire/Features/SupportLocales/SupportLocales.php new file mode 100644 index 00000000..e4a222fe --- /dev/null +++ b/portman/Livewire/Features/SupportLocales/SupportLocales.php @@ -0,0 +1,35 @@ +localeResolver->getLocale(); + $locale = strstr($code, '_', true); + + $context->addMemo('locale', $locale); + } +} diff --git a/portman/Livewire/Features/SupportNestingComponents/SupportNestingComponents.php b/portman/Livewire/Features/SupportNestingComponents/SupportNestingComponents.php new file mode 100644 index 00000000..d1bbe122 --- /dev/null +++ b/portman/Livewire/Features/SupportNestingComponents/SupportNestingComponents.php @@ -0,0 +1,65 @@ +"; + + static::setParentChild($parent, $key, $tag, $childId); + + $hijack($finish($html)); + } + }); + + on('mount', function ($component, $params, $key, $parent) { + $start = null; + if ($parent && config('app.debug')) $start = microtime(true); + + static::setParametersToMatchingProperties($component, $params); + + return function ($html) use ($component, $key, $parent, $start) { + if ($parent) { + if (config('app.debug')) trigger('profile', 'child:'.$component->getId(), $parent->getId(), [$start, microtime(true)]); + + preg_match('/<([a-zA-Z0-9\-]*)/', $html, $matches, PREG_OFFSET_CAPTURE); + $tag = $matches[1][0]; + static::setParentChild($parent, $key, $tag, $component->getId()); + } + }; + }); + } + + static function setParametersToMatchingProperties($component, $params) + { + $componentProperties = Utils::getPublicPropertiesDefinedOnSubclass($component); + + foreach ($params as $property => $value) { + if (array_key_exists($property, $componentProperties)) { + $component->{$property} = $value; // Assign public component properties that have matching parameters. + } + } + } +} diff --git a/portman/Livewire/Features/SupportRedirects/HandlesRedirects.php b/portman/Livewire/Features/SupportRedirects/HandlesRedirects.php new file mode 100644 index 00000000..fc102f90 --- /dev/null +++ b/portman/Livewire/Features/SupportRedirects/HandlesRedirects.php @@ -0,0 +1,3 @@ +component($this->component); + } + + public function dehydrate($context) + { + $to = $this->storeGet('redirect'); + $usingNavigate = $this->storeGet('redirectUsingNavigate'); + + if ($to) { + $context->addEffect('redirect', $to); + } + + $usingNavigate && $context->addEffect('redirectUsingNavigate', true); + + if (! $context->isMounting()) { + static::$atLeastOneMountedComponentHasRedirected = true; + } + } +} diff --git a/portman/Livewire/Features/SupportStreaming/HandlesStreaming.php b/portman/Livewire/Features/SupportStreaming/HandlesStreaming.php new file mode 100644 index 00000000..6b057bf2 --- /dev/null +++ b/portman/Livewire/Features/SupportStreaming/HandlesStreaming.php @@ -0,0 +1,15 @@ +stream($to, $content, $replace); + } +} diff --git a/portman/Livewire/Features/SupportStreaming/SupportStreaming.php b/portman/Livewire/Features/SupportStreaming/SupportStreaming.php new file mode 100644 index 00000000..6c006c22 --- /dev/null +++ b/portman/Livewire/Features/SupportStreaming/SupportStreaming.php @@ -0,0 +1,59 @@ +ensureStreamResponseStarted(); + $this->streamContent(['name' => $name, 'content' => $content, 'replace' => $replace]); + } + + public function ensureStreamResponseStarted(): void + { + if ($this->response) { + return; + } + + $this->response = $this->streamedResponseFactory->create([ + 'callback' => null, + 'status' => 200, + 'headers' => [ + 'Cache-Control' => 'no-cache', + 'Content-Type' => 'text/event-stream', + 'X-Accel-Buffering' => 'no', + 'X-Livewire-Stream' => true, + ] + ]); + + $this->response->sendHeaders(); + } + + public function streamContent($body): void + { + echo json_encode([ + 'stream' => true, + 'body' => $body, + 'endStream' => true + ]); + + if (ob_get_level() > 0) { + ob_flush(); + } + + flush(); + } +} diff --git a/portman/Livewire/LivewireManager.php b/portman/Livewire/LivewireManager.php new file mode 100644 index 00000000..eb478860 --- /dev/null +++ b/portman/Livewire/LivewireManager.php @@ -0,0 +1,70 @@ +componentRegistry->new($name, $id); + } + + /** + * @throws NotFoundException + */ + public function mount($name, $params = [], $key = null, AbstractBlock|null $block = null, Component|null $component = null): void + { + /** @var HandleComponentsFacade $handleComponentsMechanismFacade */ + $handleComponentsMechanismFacade = $this->magewireServiceProvider->getHandleComponentsMechanismFacade(); + + $this->renderStack[$block->getNameInLayout()] = $handleComponentsMechanismFacade + ->mount($name, $params, $block, $component); + } + + /** + * @throws FileSystemException + * @throws RuntimeException + * @throws NotFoundException + */ + public function update($snapshot, $diff, $calls, AbstractBlock|null $block = null): void + { + /** @var HandleComponentsFacade $handleComponentsMechanismFacade */ + $handleComponentsMechanismFacade = $this->magewireServiceProvider->getHandleComponentsMechanismFacade(); + + $this->renderStack[$block->getNameInLayout()] = $handleComponentsMechanismFacade + ->update($snapshot, $diff, $calls, $block); + } + + public function render(AbstractBlock $block, string $html) + { + $renderer = $this->renderStack[$block->getNameInLayout()]; + + array_pop($this->renderStack); + + return $renderer($block, $html); + } +} diff --git a/portman/Livewire/Mechanisms/ComponentRegistry.php b/portman/Livewire/Mechanisms/ComponentRegistry.php new file mode 100644 index 00000000..99cb1b76 --- /dev/null +++ b/portman/Livewire/Mechanisms/ComponentRegistry.php @@ -0,0 +1,37 @@ +getNameAndClass($nameOrClass); + + $component = new $class; + + $component->setId($id ?: str()->random(20)); + + $component->setName($name); + + // // Parameters passed in automatically set public properties by the same name... + // foreach ($params as $key => $value) { + // if (! property_exists($component, $key)) continue; + + // // Typed properties shouldn't be set back to "null". It will throw an error... + // if ((new \ReflectionProperty($component, $key))->getType() && is_null($value)) continue; + + // $component->$key = $value; + // } + + return $component; + } +} diff --git a/portman/Livewire/Mechanisms/FrontendAssets/FrontendAssets.php b/portman/Livewire/Mechanisms/FrontendAssets/FrontendAssets.php new file mode 100644 index 00000000..1d0f5f4a --- /dev/null +++ b/portman/Livewire/Mechanisms/FrontendAssets/FrontendAssets.php @@ -0,0 +1,54 @@ +setScriptRoute(fn () => $this->returnJavaScriptAsFile()); + + on('flush-state', function () { + $this->hasRenderedScripts = false; + $this->hasRenderedStyles = false; + }); + } + + function returnJavaScriptAsFile() + { + $url = $this->assetsRepository->getUrlWithParams($this->getDataByPath('script.file_path'), [ + '_secure' => $this->request->isSecure(), + ]); + + $queryParams = $this->getDataByPath('script.query_params'); + + if (is_array($queryParams)) { + return $url . '?'. http_build_query($queryParams); + } + + return $url; + } +} diff --git a/portman/Livewire/Mechanisms/HandleComponents/Checksum.php b/portman/Livewire/Mechanisms/HandleComponents/Checksum.php new file mode 100644 index 00000000..5dac15bc --- /dev/null +++ b/portman/Livewire/Mechanisms/HandleComponents/Checksum.php @@ -0,0 +1,62 @@ +generate($snapshot)) { + trigger('checksum.fail', $checksum, $comparator, $snapshot); + + throw new CorruptComponentPayloadException; + } + } + + /** + * @throws FileSystemException + * @throws RuntimeException + */ + function generate(array $snapshot): string + { + $hashKey = $this->deploymentConfig->get('crypt/key'); + $checksum = hash_hmac('sha256', $this->serializer->serialize($snapshot), $hashKey); + + trigger('checksum.generate', $checksum, $snapshot); + + return $checksum; + } +} diff --git a/portman/Livewire/Mechanisms/HandleComponents/ComponentContext.php b/portman/Livewire/Mechanisms/HandleComponents/ComponentContext.php new file mode 100644 index 00000000..56ced4fe --- /dev/null +++ b/portman/Livewire/Mechanisms/HandleComponents/ComponentContext.php @@ -0,0 +1,149 @@ +effects = $effects instanceof Effects ? $effects : null + ?? ObjectManager::getInstance()->create(Effects::class); + $this->memo = $memo instanceof Memo ? $memo : null + ?? ObjectManager::getInstance()->create(Memo::class); + } + + public function setEffects(Effects $effects) + { + $this->effects = $effects; + + return $this; + } + + public function setMemo(Memo $memo) + { + $this->memo = $memo; + + return $this; + } + + public function getEffects(): Effects + { + return $this->effects; + } + + public function getMemo(): Memo + { + return $this->memo; + } + + public function getComponent(): Component + { + return $this->component; + } + + public function getBlock(): AbstractBlock + { + return $this->block; + } + + public function addEffect($key, $value) + { + $this->getEffects()->setData($key, $value); + } + + public function pushEffect($key, $value, $iKey = null) + { + $effects = $this->getEffects()->getData(); + + if (! is_array($effects)) { + $effects = []; + } + + if ($iKey) { + $effects[$key][$iKey] = $value; + } else { + $effects[$key][] = $value; + } + + $this->getEffects()->setData($effects); + + return $this; + } + + public function hasEffect($key, $iKey = null): bool + { + $has = $this->getEffects()->hasData($key); + + if ($has && $iKey) { + $data = $this->getEffects()->getData($key); + + if (is_array($data)) { + return isset($data[$iKey]); + } + + return false; + } + + return $has; + } + + public function addMemo($key, $value) + { + $this->getMemo()->setData($key, $value); + } + + public function pushMemo($key, $value, $iKey = null) + { + $memo = $this->getMemo()->getData(); + + if (! is_array($memo)) { + $memo = []; + } + + if ($iKey) { + $memo[$key][$iKey] = $value; + } else { + $memo[$key][] = $value; + } + + $this->getMemo()->setData($memo); + + return $this; + } + + public function hasMemo($key, $iKey = null) + { + $has = $this->getMemo()->hasData($key); + + if ($has && $iKey) { + $data = $this->getMemo()->getData($key); + + if (is_array($data)) { + return isset($data[$iKey]); + } + + return false; + } + + return $has; + } +} diff --git a/portman/Livewire/Mechanisms/HandleComponents/HandleComponents.php b/portman/Livewire/Mechanisms/HandleComponents/HandleComponents.php new file mode 100644 index 00000000..323b243e --- /dev/null +++ b/portman/Livewire/Mechanisms/HandleComponents/HandleComponents.php @@ -0,0 +1,486 @@ +propertySynthesizers = $synthesizers; + } + + public static function provide() + { + on('pre-mount', function ($target, $view) { + return function ($html) use ($view, $target) { + if (method_exists($target, 'render')) { + return $target->render(); + } + + return $html; + }; + }); + } + + public function mount($name, $params = [], $key = null, AbstractBlock|null $block = null, Component|null $component = null) + { + $parent = last(self::$componentStack); + + if ($html = $this->shortCircuitMount($name, $params, $key, $parent)) { + $shortCircuitHtml = $html; + + return function (AbstractBlock $block, string $html) use ($shortCircuitHtml) { + return [$block, $shortCircuitHtml]; + }; + } + + if ($block === null) { + throw new InvalidArgumentException('Argument $block can not be null.'); + } + + $context = $this->componentContextFactory->create([ + 'block' => $block, + 'component' => $component, + 'mounting' => true, + ]); + + if (config('app.debug')) { + $start = microtime(true); + } + + $finish = trigger('mount', $component, $params, $key, $parent); + + if (config('app.debug')) { + trigger('profile', 'mount', $component->getId(), [$start ?? 0, microtime(true)]); + } + + $this->pushOntoComponentStack($component); + $start = config('app.debug') ? microtime(true) : null; + + /* + * This function is divided based on the original design due to the presence of both pre and post-render + * mechanisms within Magento. It serves as a form of middleware where the Magewire mechanism is embedded + * around it, instead of being directly responsible for rendering the component. The standard Block render + * mechanism within Magewire remains responsible for the actual rendering process and does return HTML. + */ + return function (AbstractBlock $block, string $html) use ($component, $context, $start, $finish) { + if (config('app.debug')) { + $start = microtime(true); + } + $html = $this->render($component, '
', $html); + if (config('app.debug')) { + trigger('profile', 'render', $component->getId(), [$start, microtime(true)]); + } + + if (config('app.debug')) { + $start = microtime(true); + } + trigger('dehydrate', $component, $context); + + $snapshot = $this->snapshot($component, $context); + if (config('app.debug')) { + trigger('profile', 'dehydrate', $component->getId(), [$start, microtime(true)]); + } + + trigger('destroy', $component, $context); + + $html = Utils::insertAttributesIntoHtmlRoot($html, [ + 'wire:snapshot' => $snapshot, + 'wire:effects' => $context->getEffects()->toArray(), + ]); + + $this->popOffComponentStack(); + + return [$block, $finish($html, $snapshot)]; + }; + } + + /** + * @throws FileSystemException + * @throws ComponentNotFoundException + * @throws MethodNotFoundException + * @throws RuntimeException + */ + public function update($snapshot, $updates, $calls, AbstractBlock|null $block = null) + { + if ($block === null) { + throw new InvalidArgumentException('Argument $block can not be of type null.'); + } + + $data = $snapshot['data']; + $memo = $snapshot['memo']; + + if (config('app.debug')) { + $start = microtime(true); + } + + [$component, $context] = $this->fromSnapshot($snapshot, $block); + + $this->pushOntoComponentStack($component); + + trigger('hydrate', $component, $memo, $context); + + $this->updateProperties($component, $updates, $data, $context); + + if (config('app.debug')) { + trigger('profile', 'hydrate', $component->getId(), [$start ?? 0, microtime(true)]); + } + + $this->callMethods($component, $calls, $context); + + /* + * This function is divided based on the original design due to the presence of both pre and post-render + * mechanisms within Magento. It serves as a form of middleware where the Magewire mechanism is embedded + * around it, instead of being directly responsible for rendering the component. The standard Block render + * mechanism within Magewire remains responsible for the actual rendering process. + */ + return function (AbstractBlock $block, string $html) use ($snapshot, $context, $component) { + if (config('app.debug')) { + $start = microtime(true); + } + + if ($html = $this->render($component, '', $html)) { + $context->addEffect('html', $html); + if (config('app.debug')) { + trigger('profile', 'render', $component->getId(), [$start ?? 0, microtime(true)]); + } + } + + if (config('app.debug')) { + $start = microtime(true); + } + + trigger('dehydrate', $component, $context); + + $snapshot = $this->snapshot($component, $context); + + if (config('app.debug')) { + trigger('profile', 'dehydrate', $component->getId(), [$start ?? 0, microtime(true)]); + } + + trigger('destroy', $component, $context); + + $this->popOffComponentStack(); + + return [$snapshot, $context->effects]; + }; + } + + protected function render($component, $default = null, string|null $html = null) + { + $replace = store($component)->get('skipRender', false); + + if ($replace) { + $replace = value(is_string($html) ? $html : $default); + + if (! $replace) { + return ''; + } + + return Utils::insertAttributesIntoHtmlRoot($html, [ + 'wire:id' => $component->getId(), + ]); + } + + [ $block, $properties ] = $this->getView($component); + + return $this->trackInRenderStack($component, function () use ($component, $block, $properties, $html) { + $finish = trigger('render', $component, $block, $properties); + + $html = Utils::insertAttributesIntoHtmlRoot($html, [ + 'wire:id' => $component->getId(), + ]); + + return $finish($html, function ($newHtml) use (&$html) { + $html = $newHtml; + }, $component->block()); + }); + } + + protected function trackInRenderStack($component, $callback) + { + static::$renderStack[] = $component; + + return tap($callback(), function () { + array_pop(static::$renderStack); + }); + } + + protected function hydrateForUpdate($raw, $path, $value, $context) + { + $meta = $this->getMetaForPath($raw, $path); + + // If we have meta data already for this property, let's use that to get a synth... + if ($meta) { + return $this->hydratePropertyUpdate([$value, $meta], $context, $path, $raw); + } + + // If we don't, let's check to see if it's a typed property and fetch the synth that way... + $parent = str($path)->contains('.') + ? data_get($context->component, str($path)->beforeLast('.')->toString()) + : $context->component; + + $childKey = str($path)->afterLast('.'); + + if ($parent && is_object($parent) && property_exists($parent, $childKey) && Utils::propertyIsTyped($parent, $childKey)) { + $type = Utils::getProperty($parent, $childKey)->getType(); + + $types = $type instanceof ReflectionUnionType ? $type->getTypes() : [$type]; + + foreach ($types as $type) { + $synth = $this->getSynthesizerByType($type->getName(), $context, $path); + + if ($synth) return $synth->hydrateFromType($type->getName(), $value); + } + } + + return $value; + } + + protected function getMetaForPath($raw, $path) + { + $segments = explode('.', $path); + + $first = array_shift($segments); + + [$data, $meta] = Utils::isSyntheticTuple($raw) ? $raw : [$raw, null]; + + if ($path !== '') { + $value = $data[$first] ?? null; + + return $this->getMetaForPath($value, implode('.', $segments)); + } + + return $meta; + } + + protected function getView($component) + { +// WIP +// if (method_exists($component, 'render')) { +// $block = wrap($component)->render(); +// +// if ($block instanceof AbstractBlock) { +// $block->setData($component->getBlock()->getData()); +// $block->setNameInLayout($component->getBlock()->getNameInLayout()); +// +// $component->getResolver()->construct($block); +// } +// } + + return [$component->block(), Utils::getPublicPropertiesDefinedOnSubclass($component)]; + } + + /** + * @throws ComponentNotFoundException + * @throws FileSystemException + * @throws RuntimeException + * @throws Exception + */ + public function fromSnapshot($snapshot, AbstractBlock|null $block = null) + { + if (! $block instanceof AbstractBlock) { + throw new Exception( + sprintf('Invalid block type: %s', is_object($block) ? get_class($block) : 'Unknown') + ); + } elseif (! $block->getData('magewire') instanceof Component) { + throw new ComponentNotFoundException( + sprintf('Unable to find component: [%s]', $block->getNameInLayout()) + ); + } + + /** @var Component $component */ + $component = $block->getData('magewire'); + + $context = $this->componentContextFactory->create([ + 'component' => $component, + 'block' => $block, + ]); + + $this->hydrateProperties($context->getComponent(), $snapshot['data'], $context); + + return [$component, $context]; + } + + public function snapshot($component, $context = null) + { + $context ??= $this->componentContextFactory->create(['component' => $component]); + + $data = $this->dehydrateProperties($component, $context); + + $snapshot = [ + 'data' => $data, + 'memo' => [ + 'id' => $component->getId(), + 'name' => $component->getName(), + ...$context->getMemo()->toArray(), + ], + ]; + + $snapshot['checksum'] = $this->checksum->generate($snapshot); + + return $snapshot; + } + + /** + * @throws MethodNotFoundException + */ + protected function callMethods($root, $calls, $context) + { + $returns = []; + + foreach ($calls as $idx => $call) { + $method = $call['method']; + $params = $call['params']; + + $earlyReturnCalled = false; + $earlyReturn = null; + $returnEarly = function ($return = null) use (&$earlyReturnCalled, &$earlyReturn) { + $earlyReturnCalled = true; + $earlyReturn = $return; + }; + + $finish = trigger('call', $root, $method, $params, $context, $returnEarly); + + if ($earlyReturnCalled) { + $returns[] = $finish($earlyReturn); + + continue; + } + + $methods = Utils::getPublicMethodsDefinedBySubClass($root); + + // Also remove "render" from the list... + $methods = array_values(array_diff($methods, ['render'])); + + // @todo: put this in a better place: + $methods[] = '__dispatch'; + + if (! in_array($method, $methods)) { + throw new MethodNotFoundException($method); + } + + if (config('app.debug')) { + $start = microtime(true); + } + $return = wrap($root)->{$method}(...$params); + if (config('app.debug')) { + trigger('profile', 'call' . $idx, $root->getId(), [$start, microtime(true)]); + } + + $returns[] = $finish($return); + } + + $context->addEffect('returns', $returns); + } + + protected function dehydrateProperties($component, $context) + { + $data = Utils::getPublicPropertiesDefinedOnSubclass($component); + + foreach ($data as $key => $value) { + $data[$key] = $this->dehydrate($value, $context, $key); + } + + return $data; + } + + protected function dehydrate($target, $context, $path) + { + if (Utils::isAPrimitive($target)) { + return $target; + } + + $synth = $this->propertySynth($target, $context, $path); + + [ $data, $meta ] = $synth->dehydrate($target, function ($name, $child) use ($context, $path) { + return $this->dehydrate($child, $context, "{$path}.{$name}"); + }); + + $meta['s'] = $synth::getKey(); + + return [$data, $meta]; + } + + protected function hydrate($valueOrTuple, $context, $path) + { + if (! Utils::isSyntheticTuple($value = $tuple = $valueOrTuple)) { + return $value; + } + + [$value, $meta] = $tuple; + + if ($this->isRemoval($value) && str($path)->contains('.')) { + return $value; + } + + $synth = $this->propertySynth($meta['s'], $context, $path); + + return $synth->hydrate($value, $meta, function ($name, $child) use ($context, $path) { + return $this->hydrate($child, $context, "{$path}.{$name}"); + }); + } + + /** + * @throws Exception + */ + protected function getSynthesizerByKey($key, $context, $path) + { + foreach ($this->propertySynthesizers as $synth) { + if ($synth::getKey() === $key) { + return ObjectManager::getInstance()->create($synth, [ + 'context' => $context, + 'path' => $path + ]); + } + } + + throw new Exception('No synthesizer found for key: "' . $key . '"'); + } + + /** + * @throws Exception + */ + protected function getSynthesizerByTarget($target, $context, $path) + { + foreach ($this->propertySynthesizers as $synth) { + if ($synth::match($target)) { + return ObjectManager::getInstance()->create($synth, [ + 'context' => $context, + 'path' => $path + ]); + } + } + + throw new Exception('Property type not supported in Magewire for property: [' . json_encode($target) . ']'); + } +} diff --git a/portman/Livewire/Mechanisms/HandleRequests/HandleRequests.php b/portman/Livewire/Mechanisms/HandleRequests/HandleRequests.php new file mode 100644 index 00000000..64ae5adc --- /dev/null +++ b/portman/Livewire/Mechanisms/HandleRequests/HandleRequests.php @@ -0,0 +1,112 @@ +isMagewireRequest(); + } + + public function isMagewireRequest() + { + return $this->magewireServiceProvider->runtime()->mode()->isSubsequent(); + } + + public function request(): Http + { + return $this->request; + } + + /** + * @return MagewireUpdateResult|mixed|null + * @throws ComponentNotFoundException + * @throws NoSuchEntityException + */ + public function handleUpdate() + { + /** @var ComponentRequestContext[] $updates */ + $requestPayload = $this->request->getParam('components'); + + $finish = trigger('request', $requestPayload); + + $requestPayload = $finish($requestPayload); + + $componentResponses = []; + + foreach ($requestPayload as $componentPayload) { + $reconstruct = trigger('magewire:component:reconstruct', $componentPayload); + + $block = $reconstruct(); + $component = $block->getData('magewire'); + + if (! $component instanceof Component) { + throw new ComponentNotFoundException( + 'Something went wrong during block reconstruction' + ); + } + + /* + * Marks the component to indicate that it is being updated, distinguishing it from a preceding page load + * or refresh. This notification is crucial for informing other systems about the context of the operation. + */ + store($component)->set('magewire:update', $componentPayload); + + /* + * When the 'toHtml' method is invoked on any block with the 'magewire' argument, it initiates the + * rendering lifecycle. During initial (in other words: preceding) page renders, this process is + * automatically managed by the framework. However, on subsequent requests, it becomes necessary to + * manually trigger this lifecycle for the targeted block. + */ + [$snapshot, $effects] = $this->magewireManager->render($block, $block->toHtml()); + + $componentResponses[] = [ + 'effects' => $effects->toArray(), + 'snapshot' => $this->serializer->serialize($snapshot), + ]; + } + + $responsePayload = [ + 'components' => $componentResponses ?? [], + 'assets' => [] // should be: 'assets' => SupportScriptsAndAssets::getAssets() ( = TODO) + ]; + + $finish = trigger('response', $responsePayload); + + return $finish($responsePayload); + } +} diff --git a/portman/Livewire/Mechanisms/PersistentMiddleware/PersistentMiddleware.php b/portman/Livewire/Mechanisms/PersistentMiddleware/PersistentMiddleware.php new file mode 100644 index 00000000..0fb26340 --- /dev/null +++ b/portman/Livewire/Mechanisms/PersistentMiddleware/PersistentMiddleware.php @@ -0,0 +1,60 @@ +extractPathAndMethodFromRequest(); + + $context->addMemo('path', $path); + // Although it's a POST request (stored in $method), Livewire still requires a GET value. + $context->addMemo('method', 'GET'); + }); + + on('flush-state', function() { + // Only flush these at the end of a full request, so that child components have access to this data. + $this->path = null; + $this->method = null; + }); + } + + protected function extractPathAndMethodFromRequest() + { + return [$this->request->getBasePath(), $this->request->getMethod()]; + } +} diff --git a/portman/Livewire/Wrapped.php b/portman/Livewire/Wrapped.php new file mode 100644 index 00000000..71a40c1d --- /dev/null +++ b/portman/Livewire/Wrapped.php @@ -0,0 +1,54 @@ +target, $method)) { + return value($this->fallback); + } + + try { + $arguments = $this->methodsMap->getMethodParams($this->target::class, $method); + + if (count($params) !== 0 && isset($params[0])) { + $params = array_combine(array_column($arguments, 'name'), array_intersect_key($params, $arguments)); + } + + // Remove parameters that are not required by the method. + $params = array_intersect_key($params, array_flip(array_column($arguments, 'name'))); + + return $this->target->{$method}(...$params); + } catch (\Throwable $e) { + $shouldPropagate = true; + + $stopPropagation = function () use (&$shouldPropagate) { + $shouldPropagate = false; + }; + + trigger('exception', $this->target, $e, $stopPropagation); + + $shouldPropagate && throw $e; + } + } +} diff --git a/portman/lib/Livewire/Attribute.php b/portman/lib/Livewire/Attribute.php new file mode 100644 index 00000000..9b7b4948 --- /dev/null +++ b/portman/lib/Livewire/Attribute.php @@ -0,0 +1,10 @@ +getId(); + } + + function setId($id) + { + $this->__id = $id; + } + + function getId() + { + return $this->__id; + } + + function setName($name) + { + $this->__name = $name; + } + + function getName() + { + return $this->__name; + } + + function skipRender($html = null) + { + store($this)->set('skipRender', $html ?: true); + } + + function skipMount() + { + store($this)->set('skipMount', true); + } + + function skipHydrate() + { + store($this)->set('skipHydrate', true); + } + + function __isset($property) + { + try { + $value = $this->__get($property); + + if (isset($value)) { + return true; + } + } catch(PropertyNotFoundException $ex) {} + + return false; + } + + function __get($property) + { + $value = 'noneset'; + + $returnValue = function ($newValue) use (&$value) { + $value = $newValue; + }; + + $finish = trigger('__get', $this, $property, $returnValue); + + $value = $finish($value); + + if ($value === 'noneset') { + throw new PropertyNotFoundException($property, $this->getName()); + } + + return $value; + } + + function __unset($property) + { + trigger('__unset', $this, $property); + } + + function __call($method, $params) + { + $value = 'noneset'; + + $returnValue = function ($newValue) use (&$value) { + $value = $newValue; + }; + + $finish = trigger('__call', $this, $method, $params, $returnValue); + + $value = $finish($value); + + if ($value !== 'noneset') { + return $value; + } + + if (static::hasMacro($method)) { + return $this->macroCall($method, $params); + } + + throw new BadMethodCallException(sprintf( + 'Method %s::%s does not exist.', static::class, $method + )); + } + + public function tap($callback) + { + $callback($this); + + return $this; + } +} diff --git a/portman/lib/Livewire/ComponentHook.php b/portman/lib/Livewire/ComponentHook.php new file mode 100644 index 00000000..3a869093 --- /dev/null +++ b/portman/lib/Livewire/ComponentHook.php @@ -0,0 +1,103 @@ +component = $component; + } + + function callBoot(...$params) { + if (method_exists($this, 'boot')) $this->boot(...$params); + } + + function callMount(...$params) { + if (method_exists($this, 'mount')) $this->mount(...$params); + } + + function callHydrate(...$params) { + if (method_exists($this, 'hydrate')) $this->hydrate(...$params); + } + + function callUpdate($propertyName, $fullPath, $newValue) { + $callbacks = []; + + if (method_exists($this, 'update')) $callbacks[] = $this->update($propertyName, $fullPath, $newValue); + + return function (...$params) use ($callbacks) { + foreach ($callbacks as $callback) { + if (is_callable($callback)) $callback(...$params); + } + }; + } + + function callCall($method, $params, $returnEarly) { + $callbacks = []; + + if (method_exists($this, 'call')) $callbacks[] = $this->call($method, $params, $returnEarly); + + return function (...$params) use ($callbacks) { + foreach ($callbacks as $callback) { + if (is_callable($callback)) $callback(...$params); + } + }; + } + + function callRender(...$params) { + $callbacks = []; + + if (method_exists($this, 'render')) $callbacks[] = $this->render(...$params); + + return function (...$params) use ($callbacks) { + foreach ($callbacks as $callback) { + if (is_callable($callback)) $callback(...$params); + } + }; + } + + function callDehydrate(...$params) { + if (method_exists($this, 'dehydrate')) $this->dehydrate(...$params); + } + + function callDestroy(...$params) { + if (method_exists($this, 'destroy')) $this->destroy(...$params); + } + + function callException(...$params) { + if (method_exists($this, 'exception')) $this->exception(...$params); + } + + function getProperties() + { + return $this->component->all(); + } + + function getProperty($name) + { + return data_get($this->getProperties(), $name); + } + + function storeSet($key, $value) + { + store($this->component)->set($key, $value); + } + + function storePush($key, $value, $iKey = null) + { + store($this->component)->push($key, $value, $iKey); + } + + function storeGet($key, $default = null) + { + return store($this->component)->get($key, $default); + } + + function storeHas($key) + { + return store($this->component)->has($key); + } +} diff --git a/portman/lib/Livewire/ComponentHookRegistry.php b/portman/lib/Livewire/ComponentHookRegistry.php new file mode 100644 index 00000000..1ac67a30 --- /dev/null +++ b/portman/lib/Livewire/ComponentHookRegistry.php @@ -0,0 +1,120 @@ +callBoot(); + $hook->callMount($params, $parent); + }); + + on('hydrate', function ($component, $memo) use ($hook) { + if (! $hook = static::initializeHook($hook, $component)) { + return; + } + + $hook->callBoot(); + $hook->callHydrate($memo); + }); + } + + on('update', function ($component, $fullPath, $newValue) { + $propertyName = Utils::beforeFirstDot($fullPath); + + return static::proxyCallToHooks($component, 'callUpdate')($propertyName, $fullPath, $newValue); + }); + + on('call', function ($component, $method, $params, $addEffect, $earlyReturn) { + return static::proxyCallToHooks($component, 'callCall')($method, $params, $earlyReturn); + }); + + on('render', function ($component, $view, $data) { + return static::proxyCallToHooks($component, 'callRender')($view, $data); + }); + + on('dehydrate', function ($component, $context) { + static::proxyCallToHooks($component, 'callDehydrate')($context); + }); + + on('destroy', function ($component, $context) { + static::proxyCallToHooks($component, 'callDestroy')($context); + }); + + on('exception', function ($target, $e, $stopPropagation) { + if ($target instanceof \Livewire\Component) { + static::proxyCallToHooks($target, 'callException')($e, $stopPropagation); + } + }); + } + + static public function initializeHook($hook, $target) + { + if (! isset(static::$components[$target])) static::$components[$target] = []; + + $hook = new $hook; + + $hook->setComponent($target); + + // If no `skip` method has been implemented, then boot the hook anyway + if (method_exists($hook, 'skip') && $hook->skip()) { + return; + } + + static::$components[$target][] = $hook; + + return $hook; + } + + static function proxyCallToHooks($target, $method) { + return function (...$params) use ($target, $method) { + $callbacks = []; + + foreach (static::$components[$target] ?? [] as $hook) { + $callbacks[] = $hook->{$method}(...$params); + } + + return function (...$forwards) use ($callbacks) { + foreach ($callbacks as $callback) { + $callback(...$forwards); + } + }; + }; + } +} diff --git a/portman/lib/Livewire/Concerns/InteractsWithProperties.php b/portman/lib/Livewire/Concerns/InteractsWithProperties.php new file mode 100644 index 00000000..c07a120c --- /dev/null +++ b/portman/lib/Livewire/Concerns/InteractsWithProperties.php @@ -0,0 +1,144 @@ +{Utils::beforeFirstDot($name)}; + + if (Utils::containsDots($name)) { + return data_get($value, Utils::afterFirstDot($name)); + } + + return $value; + } + + public function fill($values) + { + $publicProperties = array_keys($this->all()); + + if ($values instanceof Model) { + $values = $values->toArray(); + } + + foreach ($values as $key => $value) { + if (in_array(Utils::beforeFirstDot($key), $publicProperties)) { + data_set($this, $key, $value); + } + } + } + + public function reset(...$properties) + { + $properties = count($properties) && is_array($properties[0]) + ? $properties[0] + : $properties; + + // Reset all + if (empty($properties)) { + $properties = array_keys($this->all()); + } + + $freshInstance = new static; + + foreach ($properties as $property) { + $property = str($property); + + // Check if the property contains a dot which means it is actually on a nested object like a FormObject + if (str($property)->contains('.')) { + $propertyName = $property->afterLast('.'); + $objectName = $property->before('.'); + + // form object reset + if (is_subclass_of($this->{$objectName}, Form::class)) { + $this->{$objectName}->reset($propertyName); + continue; + } + + $object = data_get($freshInstance, $objectName, null); + + if (is_object($object)) { + $isInitialized = (new \ReflectionProperty($object, (string) $propertyName))->isInitialized($object); + } else { + $isInitialized = false; + } + } else { + $isInitialized = (new \ReflectionProperty($freshInstance, (string) $property))->isInitialized($freshInstance); + } + + // Handle resetting properties that are not initialized by default. + if (! $isInitialized) { + data_forget($this, (string) $property); + continue; + } + + data_set($this, $property, data_get($freshInstance, $property)); + } + } + + protected function resetExcept(...$properties) + { + if (count($properties) && is_array($properties[0])) { + $properties = $properties[0]; + } + + $keysToReset = array_diff(array_keys($this->all()), $properties); + + if($keysToReset === []) { + return; + } + + $this->reset($keysToReset); + } + + public function pull($properties = null) + { + $wantsASingleValue = is_string($properties); + + $properties = is_array($properties) ? $properties : func_get_args(); + + $beforeReset = match (true) { + empty($properties) => $this->all(), + $wantsASingleValue => $this->getPropertyValue($properties[0]), + default => $this->only($properties), + }; + + $this->reset($properties); + + return $beforeReset; + } + + public function only($properties) + { + $results = []; + + foreach (is_array($properties) ? $properties : func_get_args() as $property) { + $results[$property] = $this->hasProperty($property) ? $this->getPropertyValue($property) : null; + } + + return $results; + } + + public function except($properties) + { + $properties = is_array($properties) ? $properties : func_get_args(); + + return array_diff_key($this->all(), array_flip($properties)); + } + + public function all() + { + return Utils::getPublicPropertiesDefinedOnSubclass($this); + } +} diff --git a/portman/lib/Livewire/Drawer/BaseUtils.php b/portman/lib/Livewire/Drawer/BaseUtils.php new file mode 100644 index 00000000..60246c91 --- /dev/null +++ b/portman/lib/Livewire/Drawer/BaseUtils.php @@ -0,0 +1,94 @@ +getDeclaringClass()->getName() !== \Livewire\Component::class && $property->getDeclaringClass()->getName() !== \Livewire\Volt\Component::class; + }); + } + + static function getPublicProperties($target, $filter = null) + { + return collect((new \ReflectionObject($target))->getProperties()) + ->filter(function ($property) { + return $property->isPublic() && ! $property->isStatic() && $property->isDefault(); + }) + ->filter($filter ?? fn () => true) + ->mapWithKeys(function ($property) use ($target) { + // Ensures typed property is initialized in PHP >=7.4, if so, return its value, + // if not initialized, return null (as expected in earlier PHP Versions) + if (method_exists($property, 'isInitialized') && !$property->isInitialized($target)) { + // If a type of `array` is given with no value, let's assume users want + // it prefilled with an empty array... + $value = (method_exists($property, 'getType') && $property->getType() && method_exists($property->getType(), 'getName') && $property->getType()->getName() === 'array') + ? [] : null; + } else { + $value = $property->getValue($target); + } + + return [$property->getName() => $value]; + }) + ->all(); + } + + static function getPublicMethodsDefinedBySubClass($target) + { + $methods = array_filter((new \ReflectionObject($target))->getMethods(), function ($method) { + $isInBaseComponentClass = $method->getDeclaringClass()->getName() === \Livewire\Component::class || $method->getDeclaringClass()->getName() === \Livewire\Volt\Component::class; + + return $method->isPublic() + && ! $method->isStatic() + && ! $isInBaseComponentClass; + }); + + return array_map(function ($method) { + return $method->getName(); + }, $methods); + } + + static function hasAttribute($target, $property, $attributeClass) { + $property = static::getProperty($target, $property); + + foreach ($property->getAttributes() as $attribute) { + $instance = $attribute->newInstance(); + + if ($instance instanceof $attributeClass) return true; + } + + return false; + } + + static function getProperty($target, $property) { + return (new \ReflectionObject($target))->getProperty($property); + } + + static function propertyIsTyped($target, $property) { + $property = static::getProperty($target, $property); + + return $property->hasType(); + } + + static function propertyIsTypedAndUninitialized($target, $property) { + $property = static::getProperty($target, $property); + + return $property->hasType() && (! $property->isInitialized($target)); + } +} diff --git a/portman/lib/Livewire/Drawer/ImplicitRouteBinding.php b/portman/lib/Livewire/Drawer/ImplicitRouteBinding.php new file mode 100644 index 00000000..594aa85c --- /dev/null +++ b/portman/lib/Livewire/Drawer/ImplicitRouteBinding.php @@ -0,0 +1,148 @@ +container = $container; + } + + public function resolveAllParameters(Route $route, Component $component) + { + $params = $this->resolveMountParameters($route, $component); + $props = $this->resolveComponentProps($route, $component); + + return $params->merge($props)->all(); + } + + public function resolveMountParameters(Route $route, Component $component) + { + if (! method_exists($component, 'mount')) { + return new Collection(); + } + + // Cache the current route action (this callback actually), just to be safe. + $cache = $route->getAction(); + + // We'll set the route action to be the "mount" method from the chosen + // Livewire component, to get the proper implicit bindings. + $route->uses(get_class($component).'@mount'); + + try { + // This is normally handled in the "SubstituteBindings" middleware, but + // because that middleware has already ran, we need to run them again. + $this->container['router']->substituteImplicitBindings($route); + + $parameters = $route->resolveMethodDependencies($route->parameters(), new ReflectionMethod($component, 'mount')); + + // Restore the original route action... + $route->setAction($cache); + } catch(\Exception $e) { + // Restore the original route action before an exception is thrown... + $route->setAction($cache); + + throw $e; + } + + return new Collection($parameters); + } + + public function resolveComponentProps(Route $route, Component $component) + { + return $this->getPublicPropertyTypes($component) + ->intersectByKeys($route->parametersWithoutNulls()) + ->map(function ($className, $propName) use ($route) { + // If typed public property, resolve the class + if ($className) { + $resolved = $this->resolveParameter($route, $propName, $className); + + // We'll also pass the resolved model back to the route + // so that it can be used for any depending on bindings + $route->setParameter($propName, $resolved); + + return $resolved; + } + + // Otherwise, just return the route parameter + return $route->parameter($propName); + }); + } + + public function getPublicPropertyTypes($component) + { + return collect(Utils::getPublicPropertiesDefinedOnSubclass($component)) + ->map(function ($value, $name) use ($component) { + return Reflector::getParameterClassName(new \ReflectionProperty($component, $name)); + }); + } + + protected function resolveParameter($route, $parameterName, $parameterClassName) + { + $parameterValue = $route->parameter($parameterName); + + if ($parameterValue instanceof UrlRoutable) { + return $parameterValue; + } + + if($enumValue = $this->resolveEnumParameter($parameterValue, $parameterClassName)) { + return $enumValue; + } + + $instance = $this->container->make($parameterClassName); + + $parent = $route->parentOfParameter($parameterName); + + if ($parent instanceof UrlRoutable && ($route->enforcesScopedBindings() || array_key_exists($parameterName, $route->bindingFields()))) { + $model = $parent->resolveChildRouteBinding($parameterName, $parameterValue, $route->bindingFieldFor($parameterName)); + } else { + if ($route->allowsTrashedBindings()) { + $model = $instance->resolveSoftDeletableRouteBinding($parameterValue, $route->bindingFieldFor($parameterName)); + } else { + $model = $instance->resolveRouteBinding($parameterValue, $route->bindingFieldFor($parameterName)); + } + } + + if (! $model) { + throw (new ModelNotFoundException())->setModel(get_class($instance), [$parameterValue]); + } + + return $model; + } + + protected function resolveEnumParameter($parameterValue, $parameterClassName) + { + if ($parameterValue instanceof BackedEnum) { + return $parameterValue; + } + + if ((new ReflectionClass($parameterClassName))->isEnum()) { + $enumValue = $parameterClassName::tryFrom($parameterValue); + + if (is_null($enumValue)) { + throw new BackedEnumCaseNotFoundException($parameterClassName, $parameterValue); + } + + return $enumValue; + } + + return null; + } +} diff --git a/portman/lib/Livewire/Drawer/Regexes.php b/portman/lib/Livewire/Drawer/Regexes.php new file mode 100644 index 00000000..76257fc3 --- /dev/null +++ b/portman/lib/Livewire/Drawer/Regexes.php @@ -0,0 +1,169 @@ + + (?: + \s+ + (?: + (?: + @(?:class)(\( (?: (?>[^()]+) | (?-1) )* \)) + ) + | + (?: + \{\{\s*\\\$attributes(?:[^}]+?)?\s*\}\} + ) + | + (?: + [\w\-:.@]+ + ( + = + (?: + \\\"[^\\\"]*\\\" + | + \'[^\']*\' + | + [^\'\\\"=<>]+ + ) + )? + ) + ) + )* + \s* + ) + (? + "; + + static $livewireOpeningTagOrSelfClosingTag = " + < + \s* + livewire[-\:]([\w\-\:\.]*) + (? + (?: + \s+ + (?: + (?: + @(?:class)(\( (?: (?>[^()]+) | (?-1) )* \)) + ) + | + (?: + \{\{\s*\\\$attributes(?:[^}]+?)?\s*\}\} + ) + | + (?: + [:][$][\w]+ + ) + | + (?: + [\w\-:.@]+ + ( + = + (?: + \\\"[^\\\"]*\\\" + | + \'[^\']*\' + | + [^\'\\\"=<>]+ + ) + )? + ) + ) + )* + \s* + ) + \/?> + "; + + static $livewireSelfClosingTag = " + < + \s* + livewire[-\:]([\w\-\:\.]*) + \s* + (? + (?: + \s+ + (?: + (?: + @(?:class)(\( (?: (?>[^()]+) | (?-1) )* \)) + ) + | + (?: + \{\{\s*\\\$attributes(?:[^}]+?)?\s*\}\} + ) + | + (?: + [\w\-:.@]+ + ( + = + (?: + \\\"[^\\\"]*\\\" + | + \'[^\']*\' + | + [^\'\\\"=<>]+ + ) + )? + ) + ) + )* + \s* + ) + \/> + "; + + static $livewireClosingTag = '<\/\s*livewire[-\:][\w\-\:\.]*\s*>'; + + static $slotOpeningTag = " + < + \s* + x[\-\:]slot + (?:\:(?\w+(?:-\w+)*))? + (?:\s+(:?)name=(?(\"[^\"]+\"|\\\'[^\\\']+\\\'|[^\s>]+)))? + (? + (?: + \s+ + (?: + (?: + @(?:class)(\( (?: (?>[^()]+) | (?-1) )* \)) + ) + | + (?: + \{\{\s*\\\$attributes(?:[^}]+?)?\s*\}\} + ) + | + (?: + [\w\-:.@]+ + ( + = + (?: + \\\"[^\\\"]*\\\" + | + \'[^\']*\' + | + [^\'\\\"=<>]+ + ) + )? + ) + ) + )* + \s* + ) + (? + "; + + static $slotClosingTag = '<\/\s*x[\-\:]slot[^>]*>'; + + static $bladeDirective = "\B@(@?\w+(?:::\w+)?)([ \t]*)(\( ( (?>[^()]+) | (?3) )* \))?"; + + static function specificBladeDirective($directive) { + return "(@?$directive(?:::\w+)?)([ \t]*)(\( ( (?>[^()]+) | (?3) )* \))"; + } +} diff --git a/portman/lib/Livewire/Drawer/Utils.php b/portman/lib/Livewire/Drawer/Utils.php new file mode 100644 index 00000000..782c7268 --- /dev/null +++ b/portman/lib/Livewire/Drawer/Utils.php @@ -0,0 +1,202 @@ +mapWithKeys(function ($value, $key) { + return [$key => static::escapeStringForHtml($value)]; + })->map(function ($value, $key) { + return sprintf('%s="%s"', $key, $value); + })->implode(' '); + } + + static function escapeStringForHtml($subject) + { + if (is_string($subject) || is_numeric($subject)) { + return htmlspecialchars($subject, ENT_QUOTES|ENT_SUBSTITUTE); + } + + return htmlspecialchars(json_encode($subject), ENT_QUOTES|ENT_SUBSTITUTE); + } + + static function pretendResponseIsFile($file, $contentType = 'application/javascript; charset=utf-8') + { + $lastModified = filemtime($file); + + return static::cachedFileResponse($file, $contentType, $lastModified, + fn ($headers) => response()->file($file, $headers)); + } + + static function pretendPreviewResponseIsPreviewFile($filename) + { + $file = FileUploadConfiguration::path($filename); + $storage = FileUploadConfiguration::storage(); + $mimeType = FileUploadConfiguration::mimeType($filename); + $lastModified = FileUploadConfiguration::lastModified($file); + + return self::cachedFileResponse($filename, $mimeType, $lastModified, + fn ($headers) => $storage->download($file, $filename, $headers)); + } + + static private function cachedFileResponse($filename, $contentType, $lastModified, $downloadCallback) + { + $expires = strtotime('+1 year'); + $cacheControl = 'public, max-age=31536000'; + + if (static::matchesCache($lastModified)) { + return response('', 304, [ + 'Expires' => static::httpDate($expires), + 'Cache-Control' => $cacheControl, + ]); + } + + $headers = [ + 'Content-Type' => $contentType, + 'Expires' => static::httpDate($expires), + 'Cache-Control' => $cacheControl, + 'Last-Modified' => static::httpDate($lastModified), + ]; + + if (str($filename)->endsWith('.br')) { + $headers['Content-Encoding'] = 'br'; + } + + return $downloadCallback($headers); + } + + static function matchesCache($lastModified) + { + $ifModifiedSince = app(Request::class)->header('if-modified-since'); + + return $ifModifiedSince !== null && @strtotime($ifModifiedSince) === $lastModified; + } + + static function httpDate($timestamp) + { + return sprintf('%s GMT', gmdate('D, d M Y H:i:s', $timestamp)); + } + + static function containsDots($subject) + { + return str_contains($subject, '.'); + } + + static function dotSegments($subject) + { + return explode('.', $subject); + } + + static function beforeFirstDot($subject) + { + return head(explode('.', $subject)); + } + + static function afterFirstDot($subject) : string + { + return str($subject)->after('.'); + } + + static public function hasProperty($target, $property) + { + return property_exists($target, static::beforeFirstDot($property)); + } + + static public function shareWithViews($name, $value) + { + $old = app('view')->shared($name, 'notfound'); + + app('view')->share($name, $value); + + return $revert = function () use ($name, $old) { + if ($old === 'notfound') { + unset(invade(app('view'))->shared[$name]); + } else { + app('view')->share($name, $old); + } + }; + } + + static function generateBladeView($subject, $data = []) + { + if (! is_string($subject)) { + return tap($subject)->with($data); + } + + $component = new class($subject) extends \Illuminate\View\Component + { + protected $template; + + public function __construct($template) + { + $this->template = $template; + } + + public function render() + { + return $this->template; + } + }; + + $view = app('view')->make($component->resolveView(), $data); + + return $view; + } + + static function applyMiddleware(\Illuminate\Http\Request $request, $middleware = []) + { + $response = (new \Illuminate\Pipeline\Pipeline(app())) + ->send($request) + ->through($middleware) + ->then(function() { + return new \Illuminate\Http\Response(); + }); + + if ($response instanceof \Illuminate\Http\RedirectResponse) { + abort($response); + } + + return $response; + } + + static function extractAttributeDataFromHtml($html, $attribute) + { + $data = (string) str($html)->betweenFirst($attribute.'="', '"'); + + return json_decode( + htmlspecialchars_decode($data, ENT_QUOTES|ENT_SUBSTITUTE), + associative: true, + ); + } +} diff --git a/portman/lib/Livewire/EventBus.php b/portman/lib/Livewire/EventBus.php new file mode 100644 index 00000000..bd969e3e --- /dev/null +++ b/portman/lib/Livewire/EventBus.php @@ -0,0 +1,82 @@ +singleton($this::class); + } + + function on($name, $callback) { + if (! isset($this->listeners[$name])) $this->listeners[$name] = []; + + $this->listeners[$name][] = $callback; + + return fn() => $this->off($name, $callback); + } + + function before($name, $callback) { + if (! isset($this->listenersBefore[$name])) $this->listenersBefore[$name] = []; + + $this->listenersBefore[$name][] = $callback; + + return fn() => $this->off($name, $callback); + } + + function after($name, $callback) { + if (! isset($this->listenersAfter[$name])) $this->listenersAfter[$name] = []; + + $this->listenersAfter[$name][] = $callback; + + return fn() => $this->off($name, $callback); + } + + function off($name, $callback) { + $index = array_search($callback, $this->listeners[$name] ?? []); + $indexAfter = array_search($callback, $this->listenersAfter[$name] ?? []); + $indexBefore = array_search($callback, $this->listenersBefore[$name] ?? []); + + if ($index !== false) unset($this->listeners[$name][$index]); + elseif ($indexAfter !== false) unset($this->listenersAfter[$name][$indexAfter]); + elseif ($indexBefore !== false) unset($this->listenersBefore[$name][$indexBefore]); + } + + function trigger($name, ...$params) { + $middlewares = []; + + $listeners = array_merge( + ($this->listenersBefore[$name] ?? []), + ($this->listeners[$name] ?? []), + ($this->listenersAfter[$name] ?? []), + ); + + foreach ($listeners as $callback) { + $result = $callback(...$params); + + if ($result) { + $middlewares[] = $result; + } + } + + return function (&$forward = null, ...$extras) use ($middlewares) { + foreach ($middlewares as $finisher) { + if ($finisher === null) continue; + + $finisher = is_array($finisher) ? last($finisher) : $finisher; + + $result = $finisher($forward, ...$extras); + + // Only overwrite previous "forward" if something is returned from the callback. + $forward = $result ?? $forward; + } + + return $forward; + }; + } +} diff --git a/portman/lib/Livewire/Exceptions/BypassViewHandler.php b/portman/lib/Livewire/Exceptions/BypassViewHandler.php new file mode 100644 index 00000000..6f4aee2e --- /dev/null +++ b/portman/lib/Livewire/Exceptions/BypassViewHandler.php @@ -0,0 +1,8 @@ +component = $component; + $this->subName = $subName; + $this->subTarget = $subTarget; + $this->level = $level; + $this->levelName = $name; + } + + function getComponent() + { + return $this->component; + } + + function getSubTarget() + { + return $this->subTarget; + } + + function getSubName() + { + return $this->subName; + } + + function getLevel() + { + return $this->level; + } + + function getName() + { + return $this->levelName; + } + + function getValue() + { + if ($this->level !== AttributeLevel::PROPERTY) { + throw new \Exception('Can\'t get the value of a non-property attribute.'); + } + + return data_get($this->component->all(), $this->levelName); + } + + function setValue($value, ?bool $nullable = false) + { + if ($this->level !== AttributeLevel::PROPERTY) { + throw new \Exception('Can\'t set the value of a non-property attribute.'); + } + + if ($enum = $this->tryingToSetStringOrIntegerToEnum($value)) { + if($nullable) { + $value = $enum::tryFrom($value); + } + + else { + $value = $enum::from($value); + } + } + + data_set($this->component, $this->levelName, $value); + } + + protected function tryingToSetStringOrIntegerToEnum($subject) + { + if (! is_string($subject) && ! is_int($subject)) return; + + $target = $this->subTarget ?? $this->component; + + $name = $this->subName ?? $this->levelName; + + $property = str($name)->before('.')->toString(); + + $reflection = new \ReflectionProperty($target, $property); + + $type = $reflection->getType(); + + // If the type is available, display its name + if ($type instanceof \ReflectionNamedType) { + $name = $type->getName(); + + // If the type is a BackedEnum then return it's name + if (is_subclass_of($name, \BackedEnum::class)) { + return $name; + } + } + + return false; + } +} diff --git a/portman/lib/Livewire/Features/SupportAttributes/AttributeCollection.php b/portman/lib/Livewire/Features/SupportAttributes/AttributeCollection.php new file mode 100644 index 00000000..17ad7e8e --- /dev/null +++ b/portman/lib/Livewire/Features/SupportAttributes/AttributeCollection.php @@ -0,0 +1,55 @@ +push(tap($attribute->newInstance(), function ($attribute) use ($component, $subTarget) { + $attribute->__boot($component, AttributeLevel::ROOT, null, null, $subTarget); + })); + } + + foreach ($reflected->getMethods() as $method) { + foreach ($method->getAttributes(Attribute::class, ReflectionAttribute::IS_INSTANCEOF) as $attribute) { + $instance->push(tap($attribute->newInstance(), function ($attribute) use ($component, $method, $propertyNamePrefix, $subTarget) { + $attribute->__boot($component, AttributeLevel::METHOD, $propertyNamePrefix . $method->getName(), $method->getName(), $subTarget); + })); + } + } + + foreach ($reflected->getProperties() as $property) { + foreach ($property->getAttributes(Attribute::class, ReflectionAttribute::IS_INSTANCEOF) as $attribute) { + $instance->push(tap($attribute->newInstance(), function ($attribute) use ($component, $property, $propertyNamePrefix, $subTarget) { + $attribute->__boot($component, AttributeLevel::PROPERTY, $propertyNamePrefix . $property->getName(), $property->getName(), $subTarget); + })); + } + } + + return $instance; + } + + protected static function getClassAttributesRecursively($reflected) { + $attributes = []; + + while ($reflected) { + foreach ($reflected->getAttributes(Attribute::class, ReflectionAttribute::IS_INSTANCEOF) as $attribute) { + $attributes[] = $attribute; + } + + $reflected = $reflected->getParentClass(); + } + + return $attributes; + } +} diff --git a/portman/lib/Livewire/Features/SupportAttributes/AttributeLevel.php b/portman/lib/Livewire/Features/SupportAttributes/AttributeLevel.php new file mode 100644 index 00000000..c1c6fd23 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportAttributes/AttributeLevel.php @@ -0,0 +1,10 @@ +attributes ??= AttributeCollection::fromComponent($this); + } + + function setPropertyAttribute($property, $attribute) + { + $attribute->__boot($this, AttributeLevel::PROPERTY, $property); + + $this->mergeOutsideAttributes(new AttributeCollection([$attribute])); + } + + function mergeOutsideAttributes(AttributeCollection $attributes) + { + $this->attributes = $this->getAttributes()->concat($attributes); + } +} diff --git a/portman/lib/Livewire/Features/SupportAttributes/SupportAttributes.php b/portman/lib/Livewire/Features/SupportAttributes/SupportAttributes.php new file mode 100644 index 00000000..f4fce83f --- /dev/null +++ b/portman/lib/Livewire/Features/SupportAttributes/SupportAttributes.php @@ -0,0 +1,143 @@ +component + ->getAttributes() + ->whereInstanceOf(LivewireAttribute::class) + ->each(function ($attribute) use ($params) { + if (method_exists($attribute, 'boot')) { + $attribute->boot(...$params); + } + }); + } + + function mount(...$params) + { + $this->component + ->getAttributes() + ->whereInstanceOf(LivewireAttribute::class) + ->each(function ($attribute) use ($params) { + if (method_exists($attribute, 'mount')) { + $attribute->mount(...$params); + } + }); + } + + function hydrate(...$params) + { + $this->component + ->getAttributes() + ->whereInstanceOf(LivewireAttribute::class) + ->each(function ($attribute) use ($params) { + if (method_exists($attribute, 'hydrate')) { + $attribute->hydrate(...$params); + } + }); + } + + function update($propertyName, $fullPath, $newValue) + { + $callbacks = $this->component + ->getAttributes() + ->whereInstanceOf(LivewireAttribute::class) + ->filter(fn ($attr) => $attr->getLevel() === AttributeLevel::PROPERTY) + // Call "update" on the root property attribute even if it's a deep update... + ->filter(fn ($attr) => str($fullPath)->startsWith($attr->getName() . '.') || $fullPath === $attr->getName()) + ->map(function ($attribute) use ($fullPath, $newValue) { + if (method_exists($attribute, 'update')) { + return $attribute->update($fullPath, $newValue); + } + }); + + return function (...$params) use ($callbacks) { + foreach ($callbacks as $callback) { + if (is_callable($callback)) $callback(...$params); + } + }; + } + + function call($method, $params, $returnEarly) + { + $callbacks = $this->component + ->getAttributes() + ->whereInstanceOf(LivewireAttribute::class) + ->filter(fn ($attr) => $attr->getLevel() === AttributeLevel::METHOD) + ->filter(fn ($attr) => $attr->getName() === $method) + ->map(function ($attribute) use ($params, $returnEarly) { + if (method_exists($attribute, 'call')) { + return $attribute->call($params, $returnEarly); + } + }); + + return function (...$params) use ($callbacks) { + foreach ($callbacks as $callback) { + if (is_callable($callback)) $callback(...$params); + } + }; + } + + function render(...$params) + { + $callbacks = $this->component + ->getAttributes() + ->whereInstanceOf(LivewireAttribute::class) + ->map(function ($attribute) use ($params) { + if (method_exists($attribute, 'render')) { + return $attribute->render(...$params); + } + }); + + return function (...$params) use ($callbacks) { + foreach ($callbacks as $callback) { + if (is_callable($callback)) { + $callback(...$params); + } + } + }; + } + + function dehydrate(...$params) + { + $this->component + ->getAttributes() + ->whereInstanceOf(LivewireAttribute::class) + ->each(function ($attribute) use ($params) { + if (method_exists($attribute, 'dehydrate')) { + $attribute->dehydrate(...$params); + } + }); + } + + function destroy(...$params) + { + $this->component + ->getAttributes() + ->whereInstanceOf(LivewireAttribute::class) + ->each(function ($attribute) use ($params) { + if (method_exists($attribute, 'destroy')) { + $attribute->destroy(...$params); + } + }); + } + + function exception(...$params) + { + $this->component + ->getAttributes() + ->whereInstanceOf(LivewireAttribute::class) + ->each(function ($attribute) use ($params) { + if (method_exists($attribute, 'exception')) { + $attribute->exception(...$params); + } + }); + } + +} diff --git a/portman/lib/Livewire/Features/SupportAutoInjectedAssets/SupportAutoInjectedAssets.php b/portman/lib/Livewire/Features/SupportAutoInjectedAssets/SupportAutoInjectedAssets.php new file mode 100644 index 00000000..1aa606e7 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportAutoInjectedAssets/SupportAutoInjectedAssets.php @@ -0,0 +1,99 @@ +listen(RequestHandled::class, function ($handled) { + // If this is a successful HTML response... + if (! str($handled->response->headers->get('content-type'))->contains('text/html')) return; + if (! method_exists($handled->response, 'status') || $handled->response->status() !== 200) return; + + $assetsHead = ''; + $assetsBody = ''; + + // If `@assets` has been used outside of a Livewire component then we need + // to process those assets to be injected alongside the other assets... + SupportScriptsAndAssets::processNonLivewireAssets(); + + $assets = array_values(SupportScriptsAndAssets::getAssets()); + + // If there are additional head assets, inject those... + if (count($assets) > 0) { + foreach ($assets as $asset) { + $assetsHead .= $asset."\n"; + } + } + + // If we're injecting Livewire assets... + if (static::shouldInjectLivewireAssets()) { + $assetsHead .= FrontendAssets::styles()."\n"; + $assetsBody .= FrontendAssets::scripts()."\n"; + } + + if ($assetsHead === '' && $assetsBody === '') return; + + $html = $handled->response->getContent(); + + if (str($html)->contains('')) { + $originalContent = $handled->response->original; + $handled->response->setContent(static::injectAssets($html, $assetsHead, $assetsBody)); + $handled->response->original = $originalContent; + } + }); + } + + protected static function shouldInjectLivewireAssets() + { + if (! static::$forceAssetInjection && config('livewire.inject_assets', true) === false) return false; + if ((! static::$hasRenderedAComponentThisRequest) && (! static::$forceAssetInjection)) return false; + if (app(FrontendAssets::class)->hasRenderedScripts) return false; + + return true; + } + + protected static function getLivewireAssets() + { + $livewireStyles = FrontendAssets::styles(); + $livewireScripts = FrontendAssets::scripts(); + } + + public function dehydrate() + { + static::$hasRenderedAComponentThisRequest = true; + } + + static function injectAssets($html, $assetsHead, $assetsBody) + { + $html = str($html); + + if ($html->test('/<\s*\/\s*head\s*>/i') && $html->test('/<\s*\/\s*body\s*>/i')) { + return $html + ->replaceMatches('/(<\s*\/\s*head\s*>)/i', $assetsHead.'$1') + ->replaceMatches('/(<\s*\/\s*body\s*>)/i', $assetsBody.'$1') + ->toString(); + } + + return $html + ->replaceMatches('/(<\s*html(?:\s[^>])*>)/i', '$1'.$assetsHead) + ->replaceMatches('/(<\s*\/\s*html\s*>)/i', $assetsBody.'$1') + ->toString(); + } +} diff --git a/portman/lib/Livewire/Features/SupportBladeAttributes/SupportBladeAttributes.php b/portman/lib/Livewire/Features/SupportBladeAttributes/SupportBladeAttributes.php new file mode 100644 index 00000000..ba1fd499 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportBladeAttributes/SupportBladeAttributes.php @@ -0,0 +1,22 @@ +whereStartsWith('wire:'.$name)); + + $directive = head(array_keys($entries)); + $value = head(array_values($entries)); + + return new WireDirective($name, $directive, $value); + }); + } +} diff --git a/portman/lib/Livewire/Features/SupportChecksumErrorDebugging/SupportChecksumErrorDebugging.php b/portman/lib/Livewire/Features/SupportChecksumErrorDebugging/SupportChecksumErrorDebugging.php new file mode 100644 index 00000000..17cc578d --- /dev/null +++ b/portman/lib/Livewire/Features/SupportChecksumErrorDebugging/SupportChecksumErrorDebugging.php @@ -0,0 +1,82 @@ + [], 'failure' => null])); + + $this->info('Monitoring for checksum errors...'); + + while (true) { + $cache = json_decode(File::get($file), true); + + if ($cache['failure']) { + $this->info('Failure: '.$cache['failure']); + + $cache['failure'] = null; + } + + File::put($file, json_encode($cache)); + + sleep(1); + } + + })->purpose('Debug checksum errors in Livewire'); + + on('checksum.fail', function ($checksum, $comparitor, $tamperedSnapshot) use ($file) { + $cache = json_decode(File::get($file), true); + + if (! isset($cache['checksums'][$checksum])) return; + + $canonicalSnapshot = $cache['checksums'][$checksum]; + + $good = $this->array_diff_assoc_recursive($canonicalSnapshot, $tamperedSnapshot); + $bad = $this->array_diff_assoc_recursive($tamperedSnapshot, $canonicalSnapshot); + + $cache['failure'] = "\nBefore: ".json_encode($good)."\nAfter: ".json_encode($bad); + + File::put($file, json_encode($cache)); + }); + + on('checksum.generate', function ($checksum, $snapshot) use ($file) { + $cache = json_decode(File::get($file), true); + + $cache['checksums'][$checksum] = $snapshot; + + File::put($file, json_encode($cache)); + }); + } + + // https://www.php.net/manual/en/function.array-diff-assoc.php#111675 + function array_diff_assoc_recursive($array1, $array2) { + $difference=array(); + + foreach($array1 as $key => $value) { + if( is_array($value) ) { + if( !isset($array2[$key]) || !is_array($array2[$key]) ) { + $difference[$key] = $value; + } else { + $new_diff = $this->array_diff_assoc_recursive($value, $array2[$key]); + if( !empty($new_diff) ) + $difference[$key] = $new_diff; + } + } else if( !array_key_exists($key,$array2) || $array2[$key] !== $value ) { + $difference[$key] = $value; + } + } + + return $difference; + } +} diff --git a/portman/lib/Livewire/Features/SupportCompiledWireKeys/SupportCompiledWireKeys.php b/portman/lib/Livewire/Features/SupportCompiledWireKeys/SupportCompiledWireKeys.php new file mode 100644 index 00000000..f7020eee --- /dev/null +++ b/portman/lib/Livewire/Features/SupportCompiledWireKeys/SupportCompiledWireKeys.php @@ -0,0 +1,141 @@ + null, + 'index' => null, + 'key' => null, + ]; + + public static function provide() + { + on('flush-state', function () { + static::$loopStack = []; + static::$currentLoop = [ + 'count' => null, + 'index' => null, + 'key' => null, + ]; + }); + + if (! config('livewire.smart_wire_keys', true)) { + return; + } + + static::registerPrecompilers(); + } + + public static function registerPrecompilers() + { + Blade::precompiler(function ($contents) { + $contents = static::compile($contents); + + return $contents; + }); + } + + public static function compile($contents) + { + // Strip out all livewire tag components as we don't want to match any of them... + $placeholder = '<__livewire-component-placeholder__>'; + $cleanedContents = preg_replace('/]+?\/>/is', $placeholder, $contents); + + // Handle `wire:key` attributes on elements... + preg_match_all('/(?<=\s)wire:key\s*=\s*(?:"([^"\\\\]*(?:\\\\.[^"\\\\]*)*)"|\'([^\'\\\\]*(?:\\\\.[^\'\\\\]*)*)\')/', $cleanedContents, $keys); + + foreach ($keys[0] as $index => $key) { + $escapedKey = str_replace("'", "\'", $keys[1][$index]); + $prefix = ""; + $contents = str_replace($key, $prefix . $key, $contents); + } + + // Handle `wire:key` attributes on Blade components... + $contents = preg_replace( + '/(<\?php\s+\$component->withAttributes\(\[.*?\]\);\s*\?>)/s', + "$1\n\n", + $contents + ); + + return $contents; + } + + public static function openLoop() { + if (static::$currentLoop['count'] === null) { + static::$currentLoop['count'] = 0; + } else { + static::$currentLoop['count']++; + } + + static::$loopStack[] = static::$currentLoop; + + static::$currentLoop = [ + 'count' => null, + 'index' => null, + 'key' => null, + ]; + } + + public static function startLoop($index) { + static::$currentLoop['index'] = $index; + } + + public static function endLoop() { + static::$currentLoop = [ + 'count' => null, + 'index' => null, + 'key' => null, + ]; + } + + public static function closeLoop() { + static::$currentLoop = array_pop(static::$loopStack); + } + + public static function processElementKey($keyString, $data) + { + $key = Blade::render($keyString, $data); + + static::$currentLoop['key'] = $key; + } + + public static function processComponentKey($component) + { + if ($component->attributes->has('wire:key')) { + static::$currentLoop['key'] = $component->attributes->get('wire:key'); + } + } + + public static function generateKey($deterministicBladeKey, $key = null) + { + $finalKey = $deterministicBladeKey; + + $loops = array_merge(static::$loopStack, [static::$currentLoop]); + + foreach ($loops as $loop) { + if (isset($loop['key']) || isset($loop['index'])) { + $finalKey .= isset($loop['key']) + ? '-' . $loop['key'] + : '-' . $loop['index']; + } + + if (isset($loop['count'])) { + $finalKey .= '-' . $loop['count']; + } + } + + if (isset($key) && $key !== '') { + $finalKey .= '-' . $key; + } + + return $finalKey; + } +} diff --git a/portman/lib/Livewire/Features/SupportComputed/BaseComputed.php b/portman/lib/Livewire/Features/SupportComputed/BaseComputed.php new file mode 100644 index 00000000..6ab56298 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportComputed/BaseComputed.php @@ -0,0 +1,152 @@ +handleMagicGet(...)); + on('__get', $this->handleMagicGet(...)); + + off('__unset', $this->handleMagicUnset(...)); + on('__unset', $this->handleMagicUnset(...)); + } + + function call() + { + throw new CannotCallComputedDirectlyException( + $this->component->getName(), + $this->getName(), + ); + } + + protected function handleMagicGet($target, $property, $returnValue) + { + if ($target !== $this->component) return; + if ($this->generatePropertyName($property) !== $this->getName()) return; + + if ($this->persist) { + $returnValue($this->handlePersistedGet()); + + return; + } + + if ($this->cache) { + $returnValue($this->handleCachedGet()); + + return; + } + + $returnValue( + $this->requestCachedValue ??= $this->evaluateComputed() + ); + } + + protected function handleMagicUnset($target, $property) + { + if ($target !== $this->component) return; + if ($property !== $this->getName()) return; + + if ($this->persist) { + $this->handlePersistedUnset(); + + return; + } + + if ($this->cache) { + $this->handleCachedUnset(); + + return; + } + + unset($this->requestCachedValue); + } + + protected function handlePersistedGet() + { + $key = $this->generatePersistedKey(); + + $closure = fn () => $this->evaluateComputed(); + + return match(Cache::supportsTags() && !empty($this->tags)) { + true => Cache::tags($this->tags)->remember($key, $this->seconds, $closure), + default => Cache::remember($key, $this->seconds, $closure) + }; + } + + protected function handleCachedGet() + { + $key = $this->generateCachedKey(); + + $closure = fn () => $this->evaluateComputed(); + + return match(Cache::supportsTags() && !empty($this->tags)) { + true => Cache::tags($this->tags)->remember($key, $this->seconds, $closure), + default => Cache::remember($key, $this->seconds, $closure) + }; + } + + protected function handlePersistedUnset() + { + $key = $this->generatePersistedKey(); + + Cache::forget($key); + } + + protected function handleCachedUnset() + { + $key = $this->generateCachedKey(); + + Cache::forget($key); + } + + protected function generatePersistedKey() + { + if ($this->key) return $this->key; + + return 'lw_computed.'.$this->component->getId().'.'.$this->getName(); + } + + protected function generateCachedKey() + { + if ($this->key) return $this->key; + + return 'lw_computed.'.$this->component->getName().'.'.$this->getName(); + } + + protected function evaluateComputed() + { + return invade($this->component)->{parent::getName()}(); + } + + public function getName() + { + return $this->generatePropertyName(parent::getName()); + } + + private function generatePropertyName($value) + { + return str($value)->camel()->toString(); + } + + +} diff --git a/portman/lib/Livewire/Features/SupportComputed/CannotCallComputedDirectlyException.php b/portman/lib/Livewire/Features/SupportComputed/CannotCallComputedDirectlyException.php new file mode 100644 index 00000000..e7640e67 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportComputed/CannotCallComputedDirectlyException.php @@ -0,0 +1,15 @@ +unset('computedProperties', $property); + } + }); + } + + public static function getComputedProperties($target) + { + return collect(static::getComputedPropertyNames($target)) + ->mapWithKeys(function ($property) use ($target) { + return [$property => static::getComputedProperty($target, $property)]; + }) + ->all(); + } + + public static function hasComputedProperty($target, $property) + { + return array_search((string) str($property)->camel(), static::getComputedPropertyNames($target)) !== false; + } + + public static function getComputedProperty($target, $property) + { + if (! static::hasComputedProperty($target, $property)) { + throw new \Exception('No computed property found: $'.$property); + } + + $method = 'get'.str($property)->studly().'Property'; + + store($target)->push( + 'computedProperties', + $value = store($target)->find('computedProperties', $property, fn() => wrap($target)->$method()), + $property, + ); + + return $value; + } + + public static function getComputedPropertyNames($target) + { + $methodNames = SyntheticUtils::getPublicMethodsDefinedBySubClass($target); + + return collect($methodNames) + ->filter(function ($method) { + return str($method)->startsWith('get') + && str($method)->endsWith('Property'); + }) + ->map(function ($method) { + return (string) str($method)->between('get', 'Property')->camel(); + }) + ->all(); + } +} diff --git a/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/AttributeCommand.php b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/AttributeCommand.php new file mode 100644 index 00000000..22e8b88b --- /dev/null +++ b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/AttributeCommand.php @@ -0,0 +1,57 @@ +baseClassNamespace = $classNamespace; + $this->baseTestNamespace = 'Tests\Feature\Livewire'; + + $classPath = static::generatePathFromNamespace($classNamespace); + $testPath = static::generateTestPathFromNamespace($this->baseTestNamespace); + + $this->baseClassPath = rtrim($classPath, DIRECTORY_SEPARATOR).'/'; + $this->baseViewPath = rtrim($viewPath, DIRECTORY_SEPARATOR).'/'; + $this->baseTestPath = rtrim($testPath, DIRECTORY_SEPARATOR).'/'; + + if(! empty($stubSubDirectory) && str($stubSubDirectory)->startsWith('..')) { + $this->stubDirectory = rtrim(str($stubSubDirectory)->replaceFirst('..' . DIRECTORY_SEPARATOR, ''), DIRECTORY_SEPARATOR).'/'; + } else { + $this->stubDirectory = rtrim('stubs'.DIRECTORY_SEPARATOR.$stubSubDirectory, DIRECTORY_SEPARATOR).'/'; + } + + $directories = preg_split('/[.\/(\\\\)]+/', $rawCommand); + + $camelCase = str(array_pop($directories))->camel(); + $kebabCase = str($camelCase)->kebab(); + + $this->component = $kebabCase; + $this->componentClass = str($this->component)->studly(); + + $this->directories = array_map([Str::class, 'studly'], $directories); + } + + public function component() + { + return $this->component; + } + + public function classPath() + { + return $this->baseClassPath.collect() + ->concat($this->directories) + ->push($this->classFile()) + ->implode('/'); + } + + public function relativeClassPath() : string + { + return str($this->classPath())->replaceFirst(base_path().DIRECTORY_SEPARATOR, ''); + } + + public function classFile() + { + return $this->componentClass.'.php'; + } + + public function classNamespace() + { + return empty($this->directories) + ? $this->baseClassNamespace + : $this->baseClassNamespace.'\\'.collect() + ->concat($this->directories) + ->map([Str::class, 'studly']) + ->implode('\\'); + } + + public function className() + { + return $this->componentClass; + } + + public function classContents($inline = false) + { + $stubName = $inline ? 'livewire.inline.stub' : 'livewire.stub'; + + if (File::exists($stubPath = base_path($this->stubDirectory.$stubName))) { + $template = file_get_contents($stubPath); + } else { + $template = file_get_contents(__DIR__.DIRECTORY_SEPARATOR.$stubName); + } + + if ($inline) { + $template = preg_replace('/\[quote\]/', $this->wisdomOfTheTao(), $template); + } + + return preg_replace( + ['/\[namespace\]/', '/\[class\]/', '/\[view\]/'], + [$this->classNamespace(), $this->className(), $this->viewName()], + $template + ); + } + + public function viewPath() + { + return $this->baseViewPath.collect() + ->concat($this->directories) + ->map([Str::class, 'kebab']) + ->push($this->viewFile()) + ->implode(DIRECTORY_SEPARATOR); + } + + public function relativeViewPath() : string + { + return str($this->viewPath())->replaceFirst(base_path().'/', ''); + } + + public function viewFile() + { + return $this->component.'.blade.php'; + } + + public function viewName() + { + return collect() + ->when(config('livewire.view_path') !== resource_path(), function ($collection) { + return $collection->concat(explode('/',str($this->baseViewPath)->after(resource_path('views')))); + }) + ->filter() + ->concat($this->directories) + ->map([Str::class, 'kebab']) + ->push($this->component) + ->implode('.'); + } + + public function viewContents() + { + if( ! File::exists($stubPath = base_path($this->stubDirectory.'livewire.view.stub'))) { + $stubPath = __DIR__.DIRECTORY_SEPARATOR.'livewire.view.stub'; + } + + return preg_replace( + '/\[quote\]/', + $this->wisdomOfTheTao(), + file_get_contents($stubPath) + ); + } + + public function testNamespace() + { + return empty($this->directories) + ? $this->baseTestNamespace + : $this->baseTestNamespace.'\\'.collect() + ->concat($this->directories) + ->map([Str::class, 'studly']) + ->implode('\\'); + } + + public function testClassName() + { + return $this->componentClass.'Test'; + } + + public function testFile() + { + return $this->componentClass.'Test.php'; + } + + public function testPath() + { + return $this->baseTestPath.collect() + ->concat($this->directories) + ->push($this->testFile()) + ->implode('/'); + } + + public function relativeTestPath() : string + { + return str($this->testPath())->replaceFirst(base_path().'/', ''); + } + + public function testContents($testType = 'phpunit') + { + $stubName = $testType === 'pest' ? 'livewire.pest.stub' : 'livewire.test.stub'; + + if(File::exists($stubPath = base_path($this->stubDirectory.$stubName))) { + $template = file_get_contents($stubPath); + } else { + $template = file_get_contents(__DIR__.DIRECTORY_SEPARATOR.$stubName); + } + + return preg_replace( + ['/\[testnamespace\]/', '/\[classwithnamespace\]/', '/\[testclass\]/', '/\[class\]/'], + [$this->testNamespace(), $this->classNamespace() . '\\' . $this->className(), $this->testClassName(), $this->className()], + $template + ); + } + + public function wisdomOfTheTao() + { + $wisdom = require __DIR__.DIRECTORY_SEPARATOR.'the-tao.php'; + + return Arr::random($wisdom); + } + + public static function generatePathFromNamespace($namespace) + { + $name = str($namespace)->finish('\\')->replaceFirst(app()->getNamespace(), ''); + return app('path').'/'.str_replace('\\', '/', $name); + } + + public static function generateTestPathFromNamespace($namespace) + { + return base_path(str($namespace) + ->replace('\\', '/', $namespace) + ->replaceFirst('T', 't')); + } +} diff --git a/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/ComponentParserFromExistingComponent.php b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/ComponentParserFromExistingComponent.php new file mode 100644 index 00000000..d67fbd93 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/ComponentParserFromExistingComponent.php @@ -0,0 +1,45 @@ +existingParser = $existingParser; + + parent::__construct($classNamespace, $viewPath, $rawCommand); + } + + public function classContents($inline = false) + { + $originalFile = file_get_contents($this->existingParser->classPath()); + + $escapedClassNamespace = preg_replace('/\\\/', '\\\\\\', $this->existingParser->classNamespace()); + + return preg_replace_array( + ["/namespace {$escapedClassNamespace}/", "/class {$this->existingParser->className()}/", "/{$this->existingParser->viewName()}/"], + ["namespace {$this->classNamespace()}", "class {$this->className()}", $this->viewName()], + $originalFile + ); + } + + public function testContents($testType = 'phpunit') + { + $file_content = file_get_contents($this->existingParser->testPath()); + + $escapedTestNamespace = preg_replace('/\\\/', '\\\\\\', $this->existingParser->testNamespace()); + $escapedClassWithNamespace = preg_replace('/\\\/', '\\\\\\', $this->existingParser->classNamespace() . '\\' . $this->existingParser->className()); + + $replaces = [ + "/namespace {$escapedTestNamespace}/" => 'namespace ' . $this->testNamespace(), + "/use {$escapedClassWithNamespace}/" => 'use ' . $this->classNamespace() . '\\' . $this->className(), + "/class {$this->existingParser->testClassName()}/" => 'class ' . $this->testClassName(), + "/{$this->existingParser->className()}::class/" => $this->className() . '::class', + ]; + + return preg_replace(array_keys($replaces), array_values($replaces), $file_content); + } +} diff --git a/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/CopyCommand.php b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/CopyCommand.php new file mode 100644 index 00000000..fc923c48 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/CopyCommand.php @@ -0,0 +1,86 @@ +parser = new ComponentParser( + config('livewire.class_namespace'), + config('livewire.view_path'), + $this->argument('name') + ); + + $this->newParser = new ComponentParserFromExistingComponent( + config('livewire.class_namespace'), + config('livewire.view_path'), + $this->argument('new-name'), + $this->parser + ); + + $force = $this->option('force'); + $inline = $this->option('inline'); + $test = $this->option('test'); + + $class = $this->copyClass($force, $inline); + if (! $inline) $view = $this->copyView($force); + if ($test){ + $test = $this->copyTest($force); + } + $this->line(" COMPONENT COPIED 🤙\n"); + $class && $this->line("CLASS: {$this->parser->relativeClassPath()} => {$this->newParser->relativeClassPath()}"); + if (! $inline) $view && $this->line("VIEW: {$this->parser->relativeViewPath()} => {$this->newParser->relativeViewPath()}"); + if ($test) $test && $this->line("Test: {$this->parser->relativeTestPath()} => {$this->newParser->relativeTestPath()}"); + } + + protected function copyTest($force) + { + if (File::exists($this->newParser->testPath()) && ! $force) { + $this->line(" WHOOPS-IE-TOOTLES 😳 \n"); + $this->line("Test already exists: {$this->newParser->relativeTestPath()}"); + return false; + } + + $this->ensureDirectoryExists($this->newParser->testPath()); + + return File::copy("{$this->parser->testPath()}", $this->newParser->testPath()); + } + + protected function copyClass($force, $inline) + { + if (File::exists($this->newParser->classPath()) && ! $force) { + $this->line(" WHOOPS-IE-TOOTLES 😳 \n"); + $this->line("Class already exists: {$this->newParser->relativeClassPath()}"); + + return false; + } + + $this->ensureDirectoryExists($this->newParser->classPath()); + + return File::put($this->newParser->classPath(), $this->newParser->classContents($inline)); + } + + protected function copyView($force) + { + if (File::exists($this->newParser->viewPath()) && ! $force) { + $this->line("View already exists: {$this->newParser->relativeViewPath()}"); + + return false; + } + + $this->ensureDirectoryExists($this->newParser->viewPath()); + + return File::copy("{$this->parser->viewPath()}", $this->newParser->viewPath()); + } +} diff --git a/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/CpCommand.php b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/CpCommand.php new file mode 100644 index 00000000..8601cb6e --- /dev/null +++ b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/CpCommand.php @@ -0,0 +1,13 @@ +setHidden(true); + } +} diff --git a/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/DeleteCommand.php b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/DeleteCommand.php new file mode 100644 index 00000000..6b787097 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/DeleteCommand.php @@ -0,0 +1,91 @@ +parser = new ComponentParser( + config('livewire.class_namespace'), + config('livewire.view_path'), + $this->argument('name') + ); + + if (! $force = $this->option('force')) { + $shouldContinue = $this->confirm( + "Are you sure you want to delete the following files?\n\n{$this->parser->relativeClassPath()}\n{$this->parser->relativeViewPath()}\n" + ); + + if (! $shouldContinue) { + return; + } + } + + $inline = $this->option('inline'); + $test = $this->option('test'); + + $class = $this->removeClass($force); + if (! $inline) $view = $this->removeView($force); + if ($test) $test = $this->removeTest($force); + + $this->line(" COMPONENT DESTROYED 🦖💫\n"); + $class && $this->line("CLASS: {$this->parser->relativeClassPath()}"); + if (! $inline) $view && $this->line("VIEW: {$this->parser->relativeViewPath()}"); + if ($test) $test && $this->line("Test: {$this->parser->relativeTestPath()}"); + } + + protected function removeTest($force = false) + { + $testPath = $this->parser->testPath(); + + if (! File::exists($testPath) && ! $force) { + $this->line(" WHOOPS-IE-TOOTLES 😳 \n"); + $this->line("Test doesn't exist: {$this->parser->relativeTestPath()}"); + return false; + } + + File::delete($testPath); + + return $testPath; + } + + protected function removeClass($force = false) + { + $classPath = $this->parser->classPath(); + + if (! File::exists($classPath) && ! $force) { + $this->line(" WHOOPS-IE-TOOTLES 😳 \n"); + $this->line("Class doesn't exist: {$this->parser->relativeClassPath()}"); + + return false; + } + + File::delete($classPath); + + return $classPath; + } + + protected function removeView($force = false) + { + $viewPath = $this->parser->viewPath(); + + if (! File::exists($viewPath) && ! $force) { + $this->line("View doesn't exist: {$this->parser->relativeViewPath()}"); + + return false; + } + + File::delete($viewPath); + + return $viewPath; + } +} diff --git a/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/FileManipulationCommand.php b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/FileManipulationCommand.php new file mode 100644 index 00000000..f5cf2695 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/FileManipulationCommand.php @@ -0,0 +1,45 @@ +replaceFirst(app()->getNamespace(), ''); + + $livewireFolder = app_path($namespace->explode('\\')->implode(DIRECTORY_SEPARATOR)); + + return ! File::isDirectory($livewireFolder); + } + + public function writeWelcomeMessage() + { + $asciiLogo = << _._ +/ /o\ \ || () () __ +|_\ /_| || || \\\// /_\ \\\ // || |~~ /_\ + |`|`| || || \/ \\\_ \^/ || || \\\_ +EOT; +// _._ + // / /o\ \ || () () __ + // |_\ /_| || || \\\// /_\ \\\ // || |~~ /_\ +// |`|`| || || \/ \\\_ \^/ || || \\\_ + $this->line("\n".$asciiLogo."\n"); + $this->line("\nCongratulations, you've created your first Livewire component! 🎉🎉🎉\n"); + } +} diff --git a/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/FormCommand.php b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/FormCommand.php new file mode 100644 index 00000000..fd2611a6 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/FormCommand.php @@ -0,0 +1,57 @@ +layoutPath($baseViewPath, $layout); + + $relativeLayoutPath = $this->relativeLayoutPath($layoutPath); + + $force = $this->option('force'); + + $stubPath = $this->stubPath($this->option('stub')); + + if (File::exists($layoutPath) && ! $force) { + $this->line("View already exists: {$relativeLayoutPath}"); + + return false; + } + + $this->ensureDirectoryExists($layoutPath); + + $result = File::copy($stubPath, $layoutPath); + + if ($result) { + $this->line(" LAYOUT CREATED 🤙\n"); + $this->line("CLASS: {$relativeLayoutPath}"); + } + } + + protected function stubPath($stubSubDirectory = '') + { + $stubName = 'livewire.layout.stub'; + + if (! empty($stubSubDirectory) && str($stubSubDirectory)->startsWith('..')) { + $stubDirectory = rtrim(str($stubSubDirectory)->replaceFirst('..' . DIRECTORY_SEPARATOR, ''), DIRECTORY_SEPARATOR) . '/'; + } else { + $stubDirectory = rtrim('stubs' . DIRECTORY_SEPARATOR . $stubSubDirectory, DIRECTORY_SEPARATOR) . '/'; + } + + if (File::exists($stubPath = base_path($stubDirectory . $stubName))) { + return $stubPath; + } + + return __DIR__ . DIRECTORY_SEPARATOR . $stubName; + } + + protected function layoutPath($baseViewPath, $layout) + { + $directories = $layout->explode('.'); + + $name = Str::kebab($directories->pop()); + + return $baseViewPath . DIRECTORY_SEPARATOR . collect() + ->concat($directories) + ->map([Str::class, 'kebab']) + ->push("{$name}.blade.php") + ->implode(DIRECTORY_SEPARATOR); + } + + protected function relativeLayoutPath($layoutPath) + { + return (string) str($layoutPath)->replaceFirst(base_path() . '/', ''); + } +} diff --git a/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/MakeCommand.php b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/MakeCommand.php new file mode 100644 index 00000000..b0bde04d --- /dev/null +++ b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/MakeCommand.php @@ -0,0 +1,257 @@ +parser = new ComponentParser( + config('livewire.class_namespace'), + config('livewire.view_path'), + $this->argument('name'), + $this->option('stub') + ); + + if (!$this->isClassNameValid($name = $this->parser->className())) { + $this->line(" WHOOPS! 😳 \n"); + $this->line("Class is invalid: {$name}"); + + return; + } + + if ($this->isReservedClassName($name)) { + $this->line(" WHOOPS! 😳 \n"); + $this->line("Class is reserved: {$name}"); + + return; + } + + $force = $this->option('force'); + $inline = $this->option('inline'); + $test = $this->option('test') || $this->option('pest'); + $testType = $this->option('pest') ? 'pest' : 'phpunit'; + + $showWelcomeMessage = $this->isFirstTimeMakingAComponent(); + + $class = $this->createClass($force, $inline); + $view = $this->createView($force, $inline); + + if ($test) { + $test = $this->createTest($force, $testType); + } + + if($class || $view) { + $this->line(" COMPONENT CREATED 🤙\n"); + $class && $this->line("CLASS: {$this->parser->relativeClassPath()}"); + + if (! $inline) { + $view && $this->line("VIEW: {$this->parser->relativeViewPath()}"); + } + + if ($test) { + $test && $this->line("TEST: {$this->parser->relativeTestPath()}"); + } + + if ($showWelcomeMessage && ! app()->runningUnitTests()) { + $this->writeWelcomeMessage(); + } + } + } + + protected function createClass($force = false, $inline = false) + { + $classPath = $this->parser->classPath(); + + if (File::exists($classPath) && ! $force) { + $this->line(" WHOOPS-IE-TOOTLES 😳 \n"); + $this->line("Class already exists: {$this->parser->relativeClassPath()}"); + + return false; + } + + $this->ensureDirectoryExists($classPath); + + File::put($classPath, $this->parser->classContents($inline)); + + return $classPath; + } + + protected function createView($force = false, $inline = false) + { + if ($inline) { + return false; + } + $viewPath = $this->parser->viewPath(); + + if (File::exists($viewPath) && ! $force) { + $this->line("View already exists: {$this->parser->relativeViewPath()}"); + + return false; + } + + $this->ensureDirectoryExists($viewPath); + + File::put($viewPath, $this->parser->viewContents()); + + return $viewPath; + } + + protected function createTest($force = false, $testType = 'phpunit') + { + $testPath = $this->parser->testPath(); + + if (File::exists($testPath) && ! $force) { + $this->line(" WHOOPS-IE-TOOTLES 😳 \n"); + $this->line("Test class already exists: {$this->parser->relativeTestPath()}"); + + return false; + } + + $this->ensureDirectoryExists($testPath); + + File::put($testPath, $this->parser->testContents($testType)); + + return $testPath; + } + + public function isClassNameValid($name) + { + return preg_match("/^[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*$/", $name); + } + + public function isReservedClassName($name) + { + return array_search(strtolower($name), $this->getReservedName()) !== false; + } + + protected function afterPromptingForMissingArguments(InputInterface $input, OutputInterface $output) + { + if ($this->didReceiveOptions($input)) { + return; + } + + if( + confirm( + label: 'Do you want to make this an inline component?', + default: false + ) + ) + { + $input->setOption('inline', true); + } + + if( + $testSuite = select( + label: 'Do you want to create a test file?', + options: [ + false => 'No', + 'phpunit' => 'PHPUnit', + 'pest' => 'Pest', + ], + ) + ) + { + $input->setOption('test', true); + + if($testSuite === 'pest') { + $input->setOption('pest', true); + } + } + } + + private function getReservedName() + { + return [ + 'parent', + 'component', + 'interface', + '__halt_compiler', + 'abstract', + 'and', + 'array', + 'as', + 'break', + 'callable', + 'case', + 'catch', + 'class', + 'clone', + 'const', + 'continue', + 'declare', + 'default', + 'die', + 'do', + 'echo', + 'else', + 'elseif', + 'empty', + 'enddeclare', + 'endfor', + 'endforeach', + 'endif', + 'endswitch', + 'endwhile', + 'enum', + 'eval', + 'exit', + 'extends', + 'final', + 'finally', + 'fn', + 'for', + 'foreach', + 'function', + 'global', + 'goto', + 'if', + 'implements', + 'include', + 'include_once', + 'instanceof', + 'insteadof', + 'interface', + 'isset', + 'self', + 'list', + 'match', + 'namespace', + 'new', + 'or', + 'print', + 'private', + 'protected', + 'public', + 'readonly', + 'require', + 'require_once', + 'return', + 'static', + 'switch', + 'throw', + 'trait', + 'try', + 'unset', + 'use', + 'var', + 'while', + 'xor', + 'yield', + ]; + } + +} diff --git a/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/MakeLivewireCommand.php b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/MakeLivewireCommand.php new file mode 100644 index 00000000..d31c377a --- /dev/null +++ b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/MakeLivewireCommand.php @@ -0,0 +1,8 @@ +parser = new ComponentParser( + config('livewire.class_namespace'), + config('livewire.view_path'), + $this->argument('name') + ); + + $this->newParser = new ComponentParserFromExistingComponent( + config('livewire.class_namespace'), + config('livewire.view_path'), + $this->argument('new-name'), + $this->parser + ); + + $inline = $this->option('inline'); + + $class = $this->renameClass(); + if (! $inline) $view = $this->renameView(); + + $test = $this->renameTest(); + + if ($class) $this->line(" COMPONENT MOVED 🤙\n"); + $class && $this->line("CLASS: {$this->parser->relativeClassPath()} => {$this->newParser->relativeClassPath()}"); + if (! $inline) $view && $this->line("VIEW: {$this->parser->relativeViewPath()} => {$this->newParser->relativeViewPath()}"); + if ($test) $test && $this->line("Test: {$this->parser->relativeTestPath()} => {$this->newParser->relativeTestPath()}"); + } + + protected function renameClass() + { + if (File::exists($this->newParser->classPath())) { + $this->line(" WHOOPS-IE-TOOTLES 😳 \n"); + $this->line("Class already exists: {$this->newParser->relativeClassPath()}"); + + return false; + } + + $this->ensureDirectoryExists($this->newParser->classPath()); + + File::put($this->newParser->classPath(), $this->newParser->classContents()); + + return File::delete($this->parser->classPath()); + } + + protected function renameView() + { + $newViewPath = $this->newParser->viewPath(); + + if (File::exists($newViewPath)) { + $this->line("View already exists: {$this->newParser->relativeViewPath()}"); + + return false; + } + + $this->ensureDirectoryExists($newViewPath); + + File::move($this->parser->viewPath(), $newViewPath); + + return $newViewPath; + } + + protected function renameTest() + { + $oldTestPath = $this->parser->testPath(); + $newTestPath = $this->newParser->testPath(); + + if (! File::exists($oldTestPath) || File::exists($newTestPath)) { + return false; + } + + $this->ensureDirectoryExists($newTestPath); + + File::put($newTestPath, $this->newParser->testContents()); + + File::delete($oldTestPath); + + return $newTestPath; + } +} diff --git a/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/MvCommand.php b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/MvCommand.php new file mode 100644 index 00000000..d3a80991 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/MvCommand.php @@ -0,0 +1,13 @@ +setHidden(true); + } +} diff --git a/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/PublishCommand.php b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/PublishCommand.php new file mode 100644 index 00000000..163c05a0 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/PublishCommand.php @@ -0,0 +1,46 @@ +option('assets')) { + $this->publishAssets(); + } elseif ($this->option('config')) { + $this->publishConfig(); + } elseif ($this->option('pagination')) { + $this->publishPagination(); + } else { + $this->publishConfig(); + $this->publishPagination(); + } + } + + public function publishAssets() + { + $this->call('vendor:publish', ['--tag' => 'livewire:assets', '--force' => true]); + } + + public function publishConfig() + { + $this->call('vendor:publish', ['--tag' => 'livewire:config', '--force' => true]); + } + + public function publishPagination() + { + $this->call('vendor:publish', ['--tag' => 'livewire:pagination', '--force' => true]); + } +} diff --git a/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/RmCommand.php b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/RmCommand.php new file mode 100644 index 00000000..8894b942 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/RmCommand.php @@ -0,0 +1,13 @@ +setHidden(true); + } +} diff --git a/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/S3CleanupCommand.php b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/S3CleanupCommand.php new file mode 100644 index 00000000..a93fd3a4 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/S3CleanupCommand.php @@ -0,0 +1,98 @@ +error("Configuration ['livewire.temporary_file_upload.disk'] is not set to a disk with an S3 driver."); + + return; + } + + $driver = FileUploadConfiguration::storage()->getDriver(); + + // Flysystem V2+ doesn't allow direct access to adapter, so we need to invade instead. + $adapter = invade($driver)->adapter; + + // Flysystem V2+ doesn't allow direct access to client, so we need to invade instead. + $client = invade($adapter)->client; + + // Flysystem V2+ doesn't allow direct access to bucket, so we need to invade instead. + $bucket = invade($adapter)->bucket; + + $prefix = FileUploadConfiguration::path(); + + $rules[] = [ + 'Filter' => [ + 'Prefix' => $prefix, + ], + 'Expiration' => [ + 'Days' => 1, + ], + 'Status' => 'Enabled', + ]; + + $rules = $this->mergeRulesWithExistingConfiguration($client, $bucket, $prefix, $rules); + + try { + $client->putBucketLifecycleConfiguration([ + 'Bucket' => $bucket, + 'LifecycleConfiguration' => [ + 'Rules' => $rules + ], + ]); + } catch (\Exception $e) { + $this->error('Failed to configure S3 bucket ['.$bucket.'] to automatically cleanup files older than 24hrs!'); + $this->error($e->getMessage()); + + return; + } + + $this->info('Livewire temporary S3 upload directory ['.$prefix.'] set to automatically cleanup files older than 24hrs!'); + } + + private function checkIfLivewireConfigurationIsAlreadySet(array $existingConfigurationRules, string $bucket, S3Client $client, string $prefix) { + $existingConfigurationHasLivewire = collect($existingConfigurationRules)->contains('Filter.Prefix', $prefix); + + if($existingConfigurationHasLivewire) { + $this->info('Livewire temporary S3 upload directory ['.$prefix.'] already set to automatically cleanup files older than 24hrs!'); + $this->info('No changes made to S3 bucket ['.$bucket.'] configuration.'); + exit; + } + } + + private function mergeRulesWithExistingConfiguration(S3Client $client, string $bucket, string $prefix, array $rules): array + { + try { + $existingConfiguration = $client->getBucketLifecycleConfiguration([ + 'Bucket' => $bucket, + ]); + } catch (\Exception $e) { + // if no configuration exists, we'll just ignore the error and continue. + $existingConfiguration = null; + } + + if ($existingConfiguration) { + $this->checkIfLivewireConfigurationIsAlreadySet($existingConfiguration['Rules'], $bucket, $client, $prefix); + $existingConfiguration = $existingConfiguration['Rules']; + $rules = array_merge($existingConfiguration, $rules); + } + + return $rules; + } +} diff --git a/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/StubsCommand.php b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/StubsCommand.php new file mode 100644 index 00000000..19743bbc --- /dev/null +++ b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/StubsCommand.php @@ -0,0 +1,61 @@ +makeDirectory($stubsPath); + } + + file_put_contents( + $stubsPath.'/livewire.stub', + file_get_contents(__DIR__.'/livewire.stub') + ); + + file_put_contents( + $stubsPath.'/livewire.inline.stub', + file_get_contents(__DIR__.'/livewire.inline.stub') + ); + + file_put_contents( + $stubsPath.'/livewire.view.stub', + file_get_contents(__DIR__.'/livewire.view.stub') + ); + + file_put_contents( + $stubsPath.'/livewire.test.stub', + file_get_contents(__DIR__.'/livewire.test.stub') + ); + + file_put_contents( + $stubsPath.'/livewire.pest.stub', + file_get_contents(__DIR__.'/livewire.pest.stub') + ); + + file_put_contents( + $stubsPath.'/livewire.form.stub', + file_get_contents(__DIR__.'/livewire.form.stub') + ); + + file_put_contents( + $stubsPath.'/livewire.attribute.stub', + file_get_contents(__DIR__.'/livewire.attribute.stub') + ); + + $this->info('Stubs published successfully.'); + } +} diff --git a/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/TouchCommand.php b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/TouchCommand.php new file mode 100644 index 00000000..a3a2e157 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/TouchCommand.php @@ -0,0 +1,13 @@ +setHidden(true); + } +} diff --git a/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/Upgrade/AddLiveModifierToEntangleDirectives.php b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/Upgrade/AddLiveModifierToEntangleDirectives.php new file mode 100644 index 00000000..43f806b6 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/Upgrade/AddLiveModifierToEntangleDirectives.php @@ -0,0 +1,32 @@ +interactiveReplacement( + console: $console, + title: 'The @entangle(...) directive is now deferred by default.', + before: '@entangle(...)', + after: '@entangle(...).live', + pattern: '/@entangle\(((?:[^)(]|\((?:[^)(]|\((?:[^)(]|\([^)(]*\))*\))*\))*)\)(?!\.(?:defer|live))/', + replacement: '@entangle($1).live', + ); + + $this->interactiveReplacement( + console: $console, + title: 'The $wire.entangle function is now deferred by default and has been changed to $wire.$entangle.', + before: '$wire.entangle(...)', + after: '$wire.$entangle(..., true)', + pattern: '/\$wire\.entangle\(((?:[^)(]|\((?:[^)(]|\((?:[^)(]|\([^)(]*\))*\))*\))*)\)(?!\.(?:defer))/', + replacement: '$wire.$entangle($1, true)', + directories: ['resources/views', 'resources/js'] + ); + + return $next($console); + } +} diff --git a/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/Upgrade/AddLiveModifierToWireModelDirectives.php b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/Upgrade/AddLiveModifierToWireModelDirectives.php new file mode 100644 index 00000000..e9e6c362 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/Upgrade/AddLiveModifierToWireModelDirectives.php @@ -0,0 +1,22 @@ +interactiveReplacement( + console: $console, + title: 'The wire:model directive is now deferred by default.', + before: 'wire:model', + after: 'wire:model.live', + pattern: '/wire:model(?!\.(?:defer|lazy|live))/', + replacement: 'wire:model.live', + ); + + return $next($console); + } +} diff --git a/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/Upgrade/ChangeDefaultLayoutView.php b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/Upgrade/ChangeDefaultLayoutView.php new file mode 100644 index 00000000..bcac0adc --- /dev/null +++ b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/Upgrade/ChangeDefaultLayoutView.php @@ -0,0 +1,47 @@ +hasOldLayout()) + { + $console->line(' The Livewire default layout has changed. '); + $console->newLine(); + + $console->line('When rendering full-page components Livewire would use the "layouts.app" view as the default layout. This has been changed to "components.layouts.app".'); + + $choice = $console->choice('Would you like to migrate or keep the old layout?', [ + 'migrate', + 'keep', + ], 'migrate'); + + if($choice == 'keep') { + $console->line('Keeping the old default layout...'); + + $this->publishConfigIfMissing($console); + + $console->line('Setting the default layout to "layouts.app"...'); + + $this->patternReplacement('/components\.layouts\.app/', 'layouts.app', 'config'); + + return $next($console); + } + + $console->line('Setting the default layout to "components.layouts.app"...'); + + $this->patternReplacement('/layouts\.app/', 'components.layouts.app', 'config'); + } + + return $next($console); + } + + protected function hasOldLayout() + { + return config('livewire.class_namespace') === 'layouts.app' || $this->filesystem()->exists('resources/views/layouts/app.blade.php'); + } +} diff --git a/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/Upgrade/ChangeDefaultNamespace.php b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/Upgrade/ChangeDefaultNamespace.php new file mode 100644 index 00000000..99b56f99 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/Upgrade/ChangeDefaultNamespace.php @@ -0,0 +1,115 @@ +hasOldNamespace()) + { + $console->line(' The Livewire namespace has changed. '); + $console->newLine(); + + $console->line('The App\\Http\\Livewire namespace was detected and is no longer the default in Livewire v3. Livewire v3 now uses the App\\Livewire namespace.'); + + $choice = $console->choice('Would you like to migrate or keep the old namespace?', [ + 'migrate', + 'keep', + ], 'migrate'); + + if($choice === 'keep') { + $console->line('Keeping the old namespace...'); + + $this->publishConfigIfMissing($console); + + $console->line('Setting the default namespace to "App\\Http\\Livewire"...'); + + $config = $this->filesystem()->get('config/livewire.php'); + $config = str_replace('App\\\\Livewire', 'App\\\\Http\\\\Livewire', $config); + $this->filesystem()->put('config/livewire.php', $config); + + return $next($console); + } + + $componentNames = []; + + $results = collect($this->filesystem()->allFiles('app/Http/Livewire')) + ->filter(function($file) { + return str($file)->endsWith('.php'); + }) + ->map(function($file) { + return str($file)->after('app/Http/Livewire/')->before('.php')->__toString(); + })->map(function($component) use (&$componentNames) { + + // Track component names to update namespace references later on. + $componentNames[] = $component; + + $parser = new ComponentParser( + 'App\\Http\\Livewire', + config('livewire.view_path'), + $component, + ); + + $newParser = new ComponentParserFromExistingComponent( + 'App\\Livewire', + config('livewire.view_path'), + $component, + $parser + ); + + if ($this->filesystem()->exists($newParser->relativeClassPath())) { + return ['Skipped', $component, 'Already exists']; + } + + if($this->filesystem()->directoryMissing(dirname($newParser->relativeClassPath()))) { + $this->filesystem()->createDirectory(dirname($newParser->relativeClassPath())); + } + + $this->filesystem()->put($newParser->relativeClassPath(), $newParser->classContents()); + $this->filesystem()->delete($parser->relativeClassPath()); + + return ['Migrated', $component]; + }); + + foreach($componentNames as $name) { + $name = str($name)->replace('/', '\\\\')->toString(); + + // Update any namespace references + $this->patternReplacement( + pattern: "/App\\\Http\\\Livewire\\\({$name})/", + replacement: 'App\Livewire\\\$1', + directories: [ + 'app', + 'resources/views', + 'routes', + 'tests', + ] + ); + } + + // Update vite config + $this->patternReplacement( + pattern: '/App\/Http\/Livewire/', + replacement: 'App/Livewire', + mode: 'manual', + files: 'vite.config.js' + ); + + $console->table( + ['Status', 'Component', 'Remark'], $results + ); + } + + return $next($console); + } + + protected function hasOldNamespace() + { + return $this->filesystem()->exists('app/Http/Livewire') || config('livewire.class_namespace') === 'App\\Http\\Livewire'; + } +} diff --git a/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/Upgrade/ChangeForgetComputedToUnset.php b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/Upgrade/ChangeForgetComputedToUnset.php new file mode 100644 index 00000000..8176113b --- /dev/null +++ b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/Upgrade/ChangeForgetComputedToUnset.php @@ -0,0 +1,39 @@ +interactiveReplacement( + console: $console, + title: 'The forgetComputed component method is replaced by PHP\'s unset function', + before: '$this->forgetComputed(\'title\')', + after: 'unset($this->title)', + pattern: '/\$this->forgetComputed\((.*?)\);/', + replacement: function($matches) { + $replacement = ''; + + if(isset($matches[1])) { + preg_match_all('/(?:\'|")(.*?)(?:\'|")/', $matches[1], $keys); + + $replacement .= 'unset('; + foreach($keys[1] ?? [] as $key) { + $replacement .= '$this->' . $key . ', '; + } + $replacement = rtrim($replacement, ', '); + $replacement .= ');'; + } + + // Leave unchanged if no replacement was possible. + return $replacement ?: $matches[0]; + }, + directories: 'app', + ); + + return $next($console); + } +} diff --git a/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/Upgrade/ChangeLazyToBlurModifierOnWireModelDirectives.php b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/Upgrade/ChangeLazyToBlurModifierOnWireModelDirectives.php new file mode 100644 index 00000000..46ba20ab --- /dev/null +++ b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/Upgrade/ChangeLazyToBlurModifierOnWireModelDirectives.php @@ -0,0 +1,22 @@ +interactiveReplacement( + console: $console, + title: 'The wire:model.lazy modifier is now wire:model.blur.', + before: 'wire:model.lazy', + after: 'wire:model.blur', + pattern: '/wire:model.lazy/', + replacement: 'wire:model.blur', + ); + + return $next($console); + } +} diff --git a/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/Upgrade/ChangeTestAssertionMethods.php b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/Upgrade/ChangeTestAssertionMethods.php new file mode 100644 index 00000000..dd5b0f6f --- /dev/null +++ b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/Upgrade/ChangeTestAssertionMethods.php @@ -0,0 +1,53 @@ +interactiveReplacement( + console: $console, + title: 'assertEmitted is now assertDispatched.', + before: 'assertEmitted', + after: 'assertDispatched', + pattern: '/assertEmitted\((.*)\)/', + replacement: 'assertDispatched($1)', + directories: 'tests' + ); + + $this->interactiveReplacement( + console: $console, + title: 'assertEmittedTo is now assertDispatchedTo.', + before: 'assertEmittedTo', + after: 'assertDispatchedTo', + pattern: '/assertEmittedTo\((.*)\)/', + replacement: 'assertDispatchedTo($1)', + directories: 'tests' + ); + + $this->interactiveReplacement( + console: $console, + title: 'assertNotEmitted is now assertNotDispatched.', + before: 'assertNotEmitted', + after: 'assertNotDispatched', + pattern: '/assertNotEmitted\((.*)\)/', + replacement: 'assertNotDispatched($1)', + directories: 'tests' + ); + + $this->interactiveReplacement( + console: $console, + title: 'assertEmittedUp is no longer available.', + before: 'assertEmittedUp', + after: '', + pattern: '/->assertEmittedUp\(.*\)/', + replacement: '', + directories: 'tests' + ); + + return $next($console); + } +} diff --git a/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/Upgrade/ChangeWireLoadDirectiveToWireInit.php b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/Upgrade/ChangeWireLoadDirectiveToWireInit.php new file mode 100644 index 00000000..7bdc07b5 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/Upgrade/ChangeWireLoadDirectiveToWireInit.php @@ -0,0 +1,23 @@ +interactiveReplacement( + console: $console, + title: 'The livewire:load is now livewire:init.', + before: 'livewire:load', + after: 'livewire:init', + pattern: '/livewire:load/', + replacement: 'livewire:init', + directories: ['resources/views', 'resources/js'] + ); + + return $next($console); + } +} diff --git a/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/Upgrade/ClearViewCache.php b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/Upgrade/ClearViewCache.php new file mode 100644 index 00000000..5a5c96b8 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/Upgrade/ClearViewCache.php @@ -0,0 +1,15 @@ +call('view:clear'); + + return $next($console); + } +} diff --git a/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/Upgrade/RemoveDeferModifierFromEntangleDirectives.php b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/Upgrade/RemoveDeferModifierFromEntangleDirectives.php new file mode 100644 index 00000000..ffae62f8 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/Upgrade/RemoveDeferModifierFromEntangleDirectives.php @@ -0,0 +1,22 @@ +interactiveReplacement( + console: $console, + title: 'The @entangle(...) directive is now deferred by default.', + before: '@entangle(...).defer', + after: '@entangle(...)', + pattern: '/@entangle\(((?:[^)(]|\((?:[^)(]|\((?:[^)(]|\([^)(]*\))*\))*\))*)\)\.defer/', + replacement: '@entangle($1)', + ); + + return $next($console); + } +} diff --git a/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/Upgrade/RemoveDeferModifierFromWireModelDirectives.php b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/Upgrade/RemoveDeferModifierFromWireModelDirectives.php new file mode 100644 index 00000000..0e56d4c8 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/Upgrade/RemoveDeferModifierFromWireModelDirectives.php @@ -0,0 +1,22 @@ +interactiveReplacement( + console: $console, + title: 'The wire:model directive is now deferred by default.', + before: 'wire:model.defer', + after: 'wire:model', + pattern: '/wire:model\.defer/', + replacement: 'wire:model', + ); + + return $next($console); + } +} diff --git a/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/Upgrade/RemovePrefetchModifierFromWireClickDirective.php b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/Upgrade/RemovePrefetchModifierFromWireClickDirective.php new file mode 100644 index 00000000..afef29e3 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/Upgrade/RemovePrefetchModifierFromWireClickDirective.php @@ -0,0 +1,22 @@ +interactiveReplacement( + console: $console, + title: 'The wire:click.prefetch modifier has been removed.', + before: 'wire:click.prefetch', + after: 'wire:click', + pattern: '/wire:click\.prefetch/', + replacement: 'wire:click', + ); + + return $next($console); + } +} diff --git a/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/Upgrade/RemovePreventModifierFromWireSubmitDirective.php b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/Upgrade/RemovePreventModifierFromWireSubmitDirective.php new file mode 100644 index 00000000..3077eaec --- /dev/null +++ b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/Upgrade/RemovePreventModifierFromWireSubmitDirective.php @@ -0,0 +1,22 @@ +interactiveReplacement( + console: $console, + title: 'The wire:submit directive now prevents submission by default.', + before: 'wire:submit.prevent', + after: 'wire:submit', + pattern: '/wire:submit\.prevent/', + replacement: 'wire:submit', + ); + + return $next($console); + } +} diff --git a/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/Upgrade/ReplaceEmitWithDispatch.php b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/Upgrade/ReplaceEmitWithDispatch.php new file mode 100644 index 00000000..7872500c --- /dev/null +++ b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/Upgrade/ReplaceEmitWithDispatch.php @@ -0,0 +1,126 @@ +newLine(2); + $console->line(' Partial Manual Upgrade: Event dispatching '); + $console->newLine(); + $console->line('In v2 you could use the emit() and dispatchBrowserEvent() methods in PHP.'); + $console->line('For version 3, Livewire has unified these two methods into a single method: dispatch()'); + $console->line('This step is partially automated, given parameters must now be named parameters, you will have to do this manually.'); + $console->confirm('Ready to continue?'); + + $this->interactiveReplacement( + console: $console, + title: '$this->emit is now $this->dispatch.', + before: '$this->emit(\'post-created\');', + after: '$this->dispatch(\'post-created\')', + pattern: '/\$this->emit\((.*)\)/', + replacement: '$this->dispatch($1)', + directories: ['app', 'tests'] + ); + + $this->manualUpgradeWarning( + console: $console, + warning: 'Please update the named parameters manually', + before: '$this->dispatch(\'post-created\', $post->id);', + after: '$this->dispatch(\'post-created\', postId: $post->id);' + ); + + $this->interactiveReplacement( + console: $console, + title: '$this->emitTo() is now $this->dispatch()->to().', + before: '$this->emitTo(\'foo\', \'post-created\');', + after: '$this->dispatch(\'post-created\')->to(\'foo\')', + pattern: '/\$this->emitTo\((["\'][a-z0-9-.]*["\']),\s?([|"\'][a-zA-Z0-9-.]*["\'])(?:[,])?\s?(.*)\);/', + replacement: function($matches) { + $component = $matches[1]; + $eventName = $matches[2]; + $eventData = $matches[3]; + + if (empty($eventData)) { + return "\$this->dispatch({$eventName})->to($component);"; + } + + return "\$this->dispatch({$eventName}, $eventData)->to($component);"; + }, + directories: ['app', 'tests'] + ); + + $this->manualUpgradeWarning( + console: $console, + warning: 'Please update the named parameters manually', + before: '$this->dispatch(\'post-created\', $post->id)->to(\'foo\');', + after: '$this->dispatch(\'post-created\', postId: $post->id)->to(\'foo\');' + ); + + $this->interactiveReplacement( + console: $console, + title: '$this->emitSelf() is now $this->dispatch()->self().', + before: '$this->emitSelf(\'post-created\');', + after: '$this->dispatch(\'post-created\')->self();', + pattern: '/\$this->emitSelf\((.*)\)/', + replacement: '\$this->dispatch($1)->self()', + directories: ['app', 'tests'] + ); + + $this->manualUpgradeWarning( + console: $console, + warning: 'Please update the named parameters manually', + before: '$this->dispatch(\'post-created\', $post->id)->self();', + after: '$this->dispatch(\'post-created\', postId: $post->id)->self();' + ); + + $this->interactiveReplacement( + console: $console, + title: '$this->dispatchBrowserEvent() is now $this->dispatch().', + before: '$this->dispatchBrowserEvent(\'post-created\');', + after: '$this->dispatch(\'post-created\');', + pattern: '/\$this->dispatchBrowserEvent\((.*)\)/', + replacement: '\$this->dispatch($1)', + directories: ['app', 'tests'] + ); + + $this->manualUpgradeWarning( + console: $console, + warning: 'Please update the named parameters manually', + before: '$this->dispatch(\'post-created\', [\'postId\' => $post->id]);', + after: '$this->dispatch(\'post-created\', postId: $post->id);' + ); + + $this->interactiveReplacement( + console: $console, + title: 'The $emit helper is now $dispatch.', + before: '$emit(\'post-created\');', + after: '$dispatch(\'post-created\')', + pattern: '/\$emit\((.*)\)/', + replacement: '\$dispatch($1)', + directories: ['resources'] + ); + + $this->manualUpgradeWarning( + console: $console, + warning: 'Please update the named parameters manually', + before: '$dispatch(\'post-created\', 1);', + after: '$dispatch(\'post-created\', {postId: 1});' + ); + + $this->manualUpgradeWarning( + console: $console, + warning: 'The concept of `emitUp` has been removed entirely. Events are now dispatched as actual browser events and therefore "bubble up" by default.', + before: ['$this->emitUp(\'post-created\');', '$emitUp(\'post-created\', 1)'], + after: ['', ''], + ); + + if($console->confirm('Continue?', true)) + { + return $next($console); + } + } +} diff --git a/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/Upgrade/ReplaceTemporaryUploadedFileNamespace.php b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/Upgrade/ReplaceTemporaryUploadedFileNamespace.php new file mode 100644 index 00000000..98b55c1a --- /dev/null +++ b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/Upgrade/ReplaceTemporaryUploadedFileNamespace.php @@ -0,0 +1,24 @@ +interactiveReplacement( + console: $console, + title: 'Livewire\TemporaryUploadedFile is now Livewire\Features\SupportFileUploads\TemporaryUploadedFile.', + before: 'Livewire\TemporaryUploadedFile', + after: 'Livewire\Features\SupportFileUploads\TemporaryUploadedFile', + pattern: '/Livewire\\\\TemporaryUploadedFile/', + replacement: "Livewire\\Features\\SupportFileUploads\\TemporaryUploadedFile", + directories: ['app', 'tests', 'resources/views'] + ); + if ($console->confirm('Continue?', true)) { + return $next($console); + } + } +} diff --git a/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/Upgrade/RepublishNavigation.php b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/Upgrade/RepublishNavigation.php new file mode 100644 index 00000000..e689ff46 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/Upgrade/RepublishNavigation.php @@ -0,0 +1,29 @@ +filesystem()->directoryExists('resources/views/vendor/livewire')) { + $console->line(' The Livewire pagination views have changed. '); + $console->newLine(); + + $console->line('Republishing of the pagination views is required.'); + + $confirm = $console->confirm('Do you want to republish the pagination views?', true); + + if($confirm) { + $console->call('vendor:publish', [ + '--tag' => 'livewire:pagination', + '--force' => true, + ]); + } + } + + return $next($console); + } +} diff --git a/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/Upgrade/ThirdPartyUpgradeNotice.php b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/Upgrade/ThirdPartyUpgradeNotice.php new file mode 100644 index 00000000..d4ebfc68 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/Upgrade/ThirdPartyUpgradeNotice.php @@ -0,0 +1,23 @@ +line(' Third-party package upgrade 🚀 '); + $console->newLine(); + $console->comment('!! Please be aware that the following upgrade steps are registered by third-parties !!'); + $console->newLine(); + $console->newLine(); + $console->line('You can abort this command at any time by pressing ctrl+c.'); + + if($console->confirm('Ready to continue?', true)) + { + return $next($console); + } + } +} diff --git a/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/Upgrade/UpgradeAlpineInstructions.php b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/Upgrade/UpgradeAlpineInstructions.php new file mode 100644 index 00000000..6f38fdd8 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/Upgrade/UpgradeAlpineInstructions.php @@ -0,0 +1,34 @@ +line(' Manual Upgrade: Remove Alpine references '); + $console->newLine(); + $console->line('Livewire version 3 ships with AlpineJS by default.'); + $console->line('If you use Alpine in your Livewire application, you will need to remove it and any of the plugins listed below so that Livewire\'s built-in version doesn\'t conflict with it.'); + $console->line('Livewire version 3 now also ships with the following Alpine plugins:'); + $console->line('- Intersect'); + $console->line('- Collapse'); + $console->line('- Persist'); + $console->line('- Morph'); + $console->line('- Focus'); + $console->line('- Mask'); + $console->newLine(); + $console->line('If you were accessing Alpine via JS bundle you can now import Livewire\'s ESM module instead and call Livewire.start() when ready, for example:'); + $console->newLine(); + $console->line('import { Livewire, Alpine } from \'../../vendor/livewire/livewire/dist/livewire.esm\';'); + $console->line('Alpine.plugin(yourCustomPlugin);'); + $console->line('Livewire.start();'); + + if($console->confirm('Continue?', true)) + { + return $next($console); + } + } +} diff --git a/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/Upgrade/UpgradeConfigInstructions.php b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/Upgrade/UpgradeConfigInstructions.php new file mode 100644 index 00000000..fd204d7d --- /dev/null +++ b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/Upgrade/UpgradeConfigInstructions.php @@ -0,0 +1,24 @@ +line(' Manual Upgrade: New configuration '); + $console->newLine(); + $console->line('Livewire V3 has both added and removed certain configuration items.'); + $console->line('If your application has a published configuration file `config/livewire.php`, you will need to update it to account for the following changes.'); + $console->line('Added options: legacy_model_binding, inject_assets, inject_morph_markers, and navigate'); + $console->line('Removed options: app_url, middleware_group, manifest_path, back_button_cache'); + $console->newLine(); + + if($console->confirm('Continue?', true)) + { + return $next($console); + } + } +} diff --git a/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/Upgrade/UpgradeIntroduction.php b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/Upgrade/UpgradeIntroduction.php new file mode 100644 index 00000000..2f247a61 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/Upgrade/UpgradeIntroduction.php @@ -0,0 +1,27 @@ +line(' LIVEWIRE v2 to v3 UPGRADE 🚀 '); + $console->newLine(); + $console->comment('!! Running this command multiple times may result in incorrect replacements !!'); + $console->newLine(); + $console->line('This command will help you upgrade from Livewire v2 to v3.'); + $console->newLine(); + $console->line('Files will be modified in-place, so make sure you have a backup of your project before continuing.'); + $console->newLine(); + $console->newLine(); + $console->line('You can abort this command at any time by pressing ctrl+c.'); + + if($console->confirm('Ready to continue?', true)) + { + return $next($console); + } + } +} diff --git a/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/Upgrade/UpgradeStep.php b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/Upgrade/UpgradeStep.php new file mode 100644 index 00000000..d053e2f5 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/Upgrade/UpgradeStep.php @@ -0,0 +1,142 @@ + 'local', + 'root' => base_path(), + ]); + } + + public function publishConfigIfMissing($console): bool + { + if($this->filesystem()->missing('config/livewire.php')) { + $console->line('Publishing Livewire config file...'); + $console->newLine(); + + $console->call('vendor:publish', [ + '--tag' => 'livewire:config', + ]); + + return true; + } + + return false; + } + + public function manualUpgradeWarning($console, $warning, $before, $after) + { + $console->newLine(); + $console->error($warning); + $console->newLine(); + + $this->beforeAfterView( + console: $console, + before: $before, + after: $after, + ); + + $console->confirm('Ready to continue?'); + } + + public function beforeAfterView($console, $before, $after, $title = 'Before/After example') + { + $console->table( + [$title], + [ + array_map(fn($line) => "- {$line}", Arr::wrap($before)), + array_map(fn($line) => "+ {$line}", Arr::wrap($after)) + ], + ); + } + + public function interactiveReplacement(Command $console, $title, $before, $after, $pattern, $replacement, $directories = ['resources/views']) + { + $console->newLine(4); + $console->line(" {$title} "); + $console->newLine(); + $console->line('Please review the example below and confirm if you would like to apply this change.'); + $console->newLine(); + + $this->beforeAfterView($console, $before, $after); + + $confirm = $console->confirm('Would you like to apply these changes?', true); + + if ($confirm) { + $console->newLine(); + + $replacements = $this->patternReplacement($pattern, $replacement, $directories); + + if($replacements->isEmpty()) + { + $console->line('No occurrences of were found.'); + } + + if($replacements->isNotEmpty()) { + $console->table(['File', 'Occurrences'], $replacements); + } + } + + $console->newLine(4); + } + + public function patternReplacement( + $pattern, + $replacement, + $directories = 'resources/views', + $files = [], + $mode = 'auto') + { + // If the mode is auto, we'll just get all the files in the directories + if($mode == 'auto') { + $files = collect(Arr::wrap($directories))->map(function($directory) { + return collect($this->filesystem()->allFiles($directory))->map(function ($path) { + return [ + 'path' => $path, + 'content' => $this->filesystem()->get($path), + ]; + }); + })->flatten(1); + } + + // If the mode is manual, we'll just use the files passed in + if($mode == 'manual') { + $files = collect(Arr::wrap($files))->map(function($path) { + return [ + 'path' => $path, + 'content' => $this->filesystem()->get($path), + ]; + }); + } + + return $files->map(function($file) use ($pattern, $replacement) { + if($replacement instanceof Closure) { + $file['content'] = preg_replace_callback($pattern, $replacement, $file['content'], -1, $count); + } else { + $file['content'] = preg_replace($pattern, $replacement, $file['content'], -1, $count); + } + $file['occurrences'] = $count; + + return $count > 0 ? $file : null; + }) + ->filter() + ->values() + ->map(function($file) { + $this->filesystem()->put($file['path'], $file['content']); + + return [ + $file['path'], $file['occurrences'], + ]; + }); + } +} diff --git a/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/UpgradeCommand.php b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/UpgradeCommand.php new file mode 100644 index 00000000..15b19cd4 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/UpgradeCommand.php @@ -0,0 +1,84 @@ +send($this)->through(collect([ + UpgradeIntroduction::class, + + // Automated steps + ChangeDefaultNamespace::class, + ChangeDefaultLayoutView::class, + AddLiveModifierToWireModelDirectives::class, + RemoveDeferModifierFromWireModelDirectives::class, + ChangeLazyToBlurModifierOnWireModelDirectives::class, + AddLiveModifierToEntangleDirectives::class, + RemoveDeferModifierFromEntangleDirectives::class, + RemovePreventModifierFromWireSubmitDirective::class, + RemovePrefetchModifierFromWireClickDirective::class, + ChangeWireLoadDirectiveToWireInit::class, + RepublishNavigation::class, + ChangeTestAssertionMethods::class, + ChangeForgetComputedToUnset::class, + ReplaceTemporaryUploadedFileNamespace::class, + + // Partially automated steps + ReplaceEmitWithDispatch::class, + + // Manual steps + UpgradeConfigInstructions::class, + UpgradeAlpineInstructions::class, + + // Third-party steps + ... static::$thirdPartyUpgradeSteps, + + ClearViewCache::class, + ])->when($this->option('run-only'), function($collection) { + return $collection->filter(fn($step) => str($step)->afterLast('\\')->kebab()->is($this->option('run-only'))); + })->toArray()) + ->thenReturn(); + } + + public static function addThirdPartyUpgradeStep($step) + { + if(empty(static::$thirdPartyUpgradeSteps)) { + static::$thirdPartyUpgradeSteps[] = ThirdPartyUpgradeNotice::class; + } + + static::$thirdPartyUpgradeSteps[] = $step; + } +} diff --git a/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/livewire.attribute.stub b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/livewire.attribute.stub new file mode 100644 index 00000000..8c299d2d --- /dev/null +++ b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/livewire.attribute.stub @@ -0,0 +1,11 @@ + + {{-- [quote] --}} + + HTML; + } +} diff --git a/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/livewire.layout.stub b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/livewire.layout.stub new file mode 100644 index 00000000..68ba6384 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/livewire.layout.stub @@ -0,0 +1,12 @@ + + + + + + + {{ $title ?? 'Page Title' }} + + + {{ $slot }} + + diff --git a/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/livewire.pest.stub b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/livewire.pest.stub new file mode 100644 index 00000000..a937e61b --- /dev/null +++ b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/livewire.pest.stub @@ -0,0 +1,9 @@ +assertStatus(200); +}); diff --git a/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/livewire.stub b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/livewire.stub new file mode 100644 index 00000000..75398851 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/livewire.stub @@ -0,0 +1,13 @@ +assertStatus(200); + } +} diff --git a/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/livewire.view.stub b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/livewire.view.stub new file mode 100644 index 00000000..8f4e3fa9 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/livewire.view.stub @@ -0,0 +1,3 @@ +
+ {{-- [quote] --}} +
diff --git a/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/the-tao.php b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/the-tao.php new file mode 100644 index 00000000..85360330 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportConsoleCommands/Commands/the-tao.php @@ -0,0 +1,21 @@ +runningInConsole()) return; + + static::commands([ + Commands\MakeLivewireCommand::class, // make:livewire + Commands\MakeCommand::class, // livewire:make + Commands\FormCommand::class, // livewire:form + Commands\AttributeCommand::class, // livewire:attribute + Commands\TouchCommand::class, // livewire:touch + Commands\CopyCommand::class, // livewire:copy + Commands\CpCommand::class, // livewire:cp + Commands\DeleteCommand::class, // livewire:delete + Commands\LayoutCommand::class, // livewire:layout + Commands\RmCommand::class, // livewire:rm + Commands\MoveCommand::class, // livewire:move + Commands\MvCommand::class, // livewire:mv + Commands\StubsCommand::class, // livewire:stubs + Commands\S3CleanupCommand::class, // livewire:configure-s3-upload-cleanup + Commands\PublishCommand::class, // livewire:publish + Commands\UpgradeCommand::class, // livewire:upgrade + ]); + } + + static function commands($commands) + { + $commands = is_array($commands) ? $commands : func_get_args(); + + Artisan::starting(fn(Artisan $artisan) => $artisan->resolveCommands($commands)); + } +} diff --git a/portman/lib/Livewire/Features/SupportDisablingBackButtonCache/DisableBackButtonCacheMiddleware.php b/portman/lib/Livewire/Features/SupportDisablingBackButtonCache/DisableBackButtonCacheMiddleware.php new file mode 100644 index 00000000..ad5a4608 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportDisablingBackButtonCache/DisableBackButtonCacheMiddleware.php @@ -0,0 +1,35 @@ +headers->add([ + 'Pragma' => 'no-cache', + 'Expires' => 'Fri, 01 Jan 1990 00:00:00 GMT', + 'Cache-Control' => 'no-cache, must-revalidate, no-store, max-age=0, private', + ]); + + // We do flush this in the `SupportDisablingBackButtonCache` hook, but we + // need to do it here as well to ensure that unit tests still work... + SupportDisablingBackButtonCache::$disableBackButtonCache = false; + } + + return $response; + } +} diff --git a/portman/lib/Livewire/Features/SupportDisablingBackButtonCache/HandlesDisablingBackButtonCache.php b/portman/lib/Livewire/Features/SupportDisablingBackButtonCache/HandlesDisablingBackButtonCache.php new file mode 100644 index 00000000..9be1a22b --- /dev/null +++ b/portman/lib/Livewire/Features/SupportDisablingBackButtonCache/HandlesDisablingBackButtonCache.php @@ -0,0 +1,16 @@ +make(\Illuminate\Contracts\Http\Kernel::class); + + if ($kernel->hasMiddleware(DisableBackButtonCacheMiddleware::class)) { + return; + } + + $kernel->pushMiddleware(DisableBackButtonCacheMiddleware::class); + } + + public function boot() + { + static::$disableBackButtonCache = true; + } +} diff --git a/portman/lib/Livewire/Features/SupportEntangle/SupportEntangle.php b/portman/lib/Livewire/Features/SupportEntangle/SupportEntangle.php new file mode 100644 index 00000000..8a8a286d --- /dev/null +++ b/portman/lib/Livewire/Features/SupportEntangle/SupportEntangle.php @@ -0,0 +1,18 @@ +window.Livewire.find('{{ \$__livewire->getId() }}').entangle('{{ {$expression}->value() }}'){{ {$expression}->hasModifier('live') ? '.live' : '' }}window.Livewire.find('{{ \$__livewire->getId() }}').entangle('{{ {$expression} }}') + EOT; + }); + } +} diff --git a/portman/lib/Livewire/Features/SupportEvents/BaseOn.php b/portman/lib/Livewire/Features/SupportEvents/BaseOn.php new file mode 100644 index 00000000..7b150989 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportEvents/BaseOn.php @@ -0,0 +1,26 @@ +event) as $event) { + store($this->component)->push( + 'listenersFromAttributes', + $this->getName() ?? '$refresh', + $event, + ); + } + } +} diff --git a/portman/lib/Livewire/Features/SupportEvents/Event.php b/portman/lib/Livewire/Features/SupportEvents/Event.php new file mode 100644 index 00000000..86558f50 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportEvents/Event.php @@ -0,0 +1,51 @@ +name = $name; + $this->params = $params; + } + + public function self() + { + $this->self = true; + + return $this; + } + + public function component($name) + { + $this->component = $name; + + return $this; + } + + public function to($name) + { + return $this->component($name); + } + + public function serialize() + { + $output = [ + 'name' => $this->name, + 'params' => $this->params, + ]; + + if ($this->self) $output['self'] = true; + if ($this->component) $output['to'] = app(ComponentRegistry::class)->getName($this->component); + + return $output; + } +} diff --git a/portman/lib/Livewire/Features/SupportEvents/HandlesEvents.php b/portman/lib/Livewire/Features/SupportEvents/HandlesEvents.php new file mode 100644 index 00000000..c630e525 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportEvents/HandlesEvents.php @@ -0,0 +1,23 @@ +listeners; + } + + public function dispatch($event, ...$params) + { + $event = new Event($event, $params); + + store($this)->push('dispatched', $event); + + return $event; + } +} diff --git a/portman/lib/Livewire/Features/SupportEvents/SupportEvents.php b/portman/lib/Livewire/Features/SupportEvents/SupportEvents.php new file mode 100644 index 00000000..90289f5a --- /dev/null +++ b/portman/lib/Livewire/Features/SupportEvents/SupportEvents.php @@ -0,0 +1,122 @@ +component); + + if (! in_array($name, $names)) { + throw new EventHandlerDoesNotExist($name); + } + + $method = static::getListenerMethodName($this->component, $name); + + $returnEarly( + wrap($this->component)->$method(...$params) + ); + + // Here we have to manually check to see if the event listener method + // is "renderless" as it's normal "call" hook doesn't get run when + // the method is called as an event listener... + $isRenderless = $this->component->getAttributes() + ->filter(fn ($i) => is_subclass_of($i, BaseRenderless::class)) + ->filter(fn ($i) => $i->getName() === $method) + ->filter(fn ($i) => $i->getLevel() === AttributeLevel::METHOD) + ->count() > 0; + + if ($isRenderless) $this->component->skipRender(); + } + } + + function dehydrate($context) + { + if ($context->mounting) { + $listeners = static::getListenerEventNames($this->component); + + $listeners && $context->addEffect('listeners', $listeners); + } + + $dispatches = $this->getServerDispatchedEvents($this->component); + + $dispatches && $context->addEffect('dispatches', $dispatches); + } + + static function getListenerEventNames($component) + { + $listeners = static::getComponentListeners($component); + + return collect($listeners) + ->map(fn ($value, $key) => is_numeric($key) ? $value : $key) + ->values() + ->toArray(); + } + + static function getListenerMethodName($component, $name) + { + $listeners = static::getComponentListeners($component); + + foreach ($listeners as $event => $method) { + if (is_numeric($event)) $event = $method; + + if ($name === $event) return $method; + } + + throw new \Exception('Event method not found'); + } + + static function getComponentListeners($component) + { + $fromClass = invade($component)->getListeners(); + + $fromAttributes = store($component)->get('listenersFromAttributes', []); + + $listeners = array_merge($fromClass, $fromAttributes); + + return static::replaceDynamicEventNamePlaceholders($listeners, $component); + } + + function getServerDispatchedEvents($component) + { + return collect(store($component)->get('dispatched', [])) + ->map(fn ($event) => $event->serialize()) + ->toArray(); + } + + static function replaceDynamicEventNamePlaceholders($listeners, $component) + { + foreach ($listeners as $event => $method) { + if (is_numeric($event)) continue; + + $replaced = static::replaceDynamicPlaceholders($event, $component); + + unset($listeners[$event]); + + $listeners[$replaced] = $method; + } + + return $listeners; + } + + static function replaceDynamicPlaceholders($event, $component) + { + return preg_replace_callback('/\{(.*)\}/U', function ($matches) use ($component) { + return data_get($component, $matches[1], function () use ($matches) { + throw new \Exception('Unable to evaluate dynamic event name placeholder: '.$matches[0]); + }); + }, $event); + } +} diff --git a/portman/lib/Livewire/Features/SupportEvents/TestsEvents.php b/portman/lib/Livewire/Features/SupportEvents/TestsEvents.php new file mode 100644 index 00000000..1911e14f --- /dev/null +++ b/portman/lib/Livewire/Features/SupportEvents/TestsEvents.php @@ -0,0 +1,90 @@ +call('__dispatch', $event, $parameters); + } + + public function fireEvent($event, ...$parameters) + { + return $this->dispatch($event, ...$parameters); + } + + public function assertDispatched($event, ...$params) + { + $result = $this->testDispatched($event, $params); + + PHPUnit::assertTrue($result['test'], "Failed asserting that an event [{$event}] was fired{$result['assertionSuffix']}"); + + return $this; + } + + public function assertNotDispatched($event, ...$params) + { + $result = $this->testDispatched($event, $params); + + PHPUnit::assertFalse($result['test'], "Failed asserting that an event [{$event}] was not fired{$result['assertionSuffix']}"); + + return $this; + } + + public function assertDispatchedTo($target, $event, ...$params) + { + $this->assertDispatched($event, ...$params); + $result = $this->testDispatchedTo($target, $event); + + PHPUnit::assertTrue($result, "Failed asserting that an event [{$event}] was fired to {$target}."); + + return $this; + } + + protected function testDispatched($value, $params) + { + $assertionSuffix = '.'; + + if (empty($params)) { + $test = collect(data_get($this->effects, 'dispatches'))->contains('name', '=', $value); + } elseif (isset($params[0]) && ! is_string($params[0]) && is_callable($params[0])) { + $event = collect(data_get($this->effects, 'dispatches'))->first(function ($item) use ($value) { + return $item['name'] === $value; + }); + + $test = $event && $params[0]($event['name'], $event['params']); + } else { + $test = (bool) collect(data_get($this->effects, 'dispatches'))->first(function ($item) use ($value, $params) { + $commonParams = array_intersect_key($item['params'], $params); + + ksort($commonParams); + ksort($params); + + return $item['name'] === $value + && $commonParams === $params; + }); + + $encodedParams = json_encode($params); + $assertionSuffix = " with parameters: {$encodedParams}"; + } + + return [ + 'test' => $test, + 'assertionSuffix' => $assertionSuffix, + ]; + } + + protected function testDispatchedTo($target, $value) + { + $name = app(ComponentRegistry::class)->getName($target); + + return (bool) collect(data_get($this->effects, 'dispatches'))->first(function ($item) use ($name, $value) { + return $item['name'] === $value + && $item['to'] === $name; + }); + } +} diff --git a/portman/lib/Livewire/Features/SupportEvents/fake-echo.js b/portman/lib/Livewire/Features/SupportEvents/fake-echo.js new file mode 100644 index 00000000..0dcd8943 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportEvents/fake-echo.js @@ -0,0 +1,108 @@ + +window.fakeEchoListeners = [] + +class FakeChannel { + constructor(channel) { + this.channel = channel + + this.type = 'public' + } + + listen(eventName, callback) { + window.fakeEchoListeners.push({ + channel: this.channel, + event: eventName, + type: this.type, + callback, + }) + + return this + } + + stopListening(eventName, callback) { + window.fakeEchoListeners = window.fakeEchoListeners.filter(i => { + if (callback) { + return ! (i.event === eventName && i.callback === callback) + } + + return ! (i.event === eventName) + }) + + + return this + } +} + +class FakePrivateChannel extends FakeChannel { + constructor(channel) { + super(channel) + + this.type = 'private' + } + + whisper(eventName, data) { + return this + } +} + +class FakePresenceChannel extends FakeChannel { + constructor(channel) { + super(channel) + + this.type = 'presence' + } + + here(callback) { + return this + } + + joining(callback) { + return this + } + + whisper(eventName, data) { + return this + } + + leaving(callback) { + return this + } +} + +class FakeEcho { + join(channel) { + return new FakePresenceChannel(channel); + } + + channel(channel) { + return new FakeChannel(channel); + } + + private(channel) { + return new FakePrivateChannel(channel); + } + + encryptedPrivate(channel) { + return new FakePrivateChannel(channel); + } + + socketId() { + return 'fake-socked-id' + } + + // For dusk to trigger listeners... + + fakeTrigger({ channel, event, type, payload = {} }) { + window.fakeEchoListeners.filter(listener => { + if (event !== listener.event) return false + if (channel !== listener.channel) return false + if (type !== undefined && type !== listener.type) return false + + return true + }).forEach(({ callback }) => { + callback(payload) + }) + } +} + +window.Echo = new FakeEcho diff --git a/portman/lib/Livewire/Features/SupportFileDownloads/SupportFileDownloads.php b/portman/lib/Livewire/Features/SupportFileDownloads/SupportFileDownloads.php new file mode 100644 index 00000000..cea7de06 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportFileDownloads/SupportFileDownloads.php @@ -0,0 +1,90 @@ +toResponse(request()); + } + + if ($this->valueIsntAFileResponse($return)) return; + + $response = $return; + + $name = $this->getFilenameFromContentDispositionHeader( + $response->headers->get('Content-Disposition') + ); + + $binary = $this->captureOutput(function () use ($response) { + $response->sendContent(); + }); + + $content = base64_encode($binary); + + $this->storeSet('download', [ + 'name' => $name, + 'content' => $content, + 'contentType' => $response->headers->get('Content-Type'), + ]); + }; + } + + function dehydrate($context) + { + if (! $download = $this->storeGet('download')) return; + + $context->addEffect('download', $download); + } + + function valueIsntAFileResponse($value) + { + return ! $value instanceof StreamedResponse + && ! $value instanceof BinaryFileResponse; + } + + function captureOutput($callback) + { + ob_start(); + + $callback(); + + return ob_get_clean(); + } + + function getFilenameFromContentDispositionHeader($header) + { + /** + * The following conditionals are here to allow for quoted and + * non quoted filenames in the Content-Disposition header. + * + * Both of these values should return the correct filename without quotes. + * + * Content-Disposition: attachment; filename=filename.jpg + * Content-Disposition: attachment; filename="test file.jpg" + */ + + // Support foreign-language filenames (japanese, greek, etc..)... + if (preg_match('/filename\*=utf-8\'\'(.+)$/i', $header, $matches)) { + return rawurldecode($matches[1]); + } + + if (preg_match('/.*?filename="(.+?)"/', $header, $matches)) { + return $matches[1]; + } + + if (preg_match('/.*?filename=([^; ]+)/', $header, $matches)) { + return $matches[1]; + } + + return 'download'; + } +} diff --git a/portman/lib/Livewire/Features/SupportFileDownloads/TestsFileDownloads.php b/portman/lib/Livewire/Features/SupportFileDownloads/TestsFileDownloads.php new file mode 100644 index 00000000..0d70f26f --- /dev/null +++ b/portman/lib/Livewire/Features/SupportFileDownloads/TestsFileDownloads.php @@ -0,0 +1,49 @@ +effects, 'download'); + + if ($filename) { + PHPUnit::assertEquals( + $filename, + data_get($downloadEffect, 'name') + ); + } else { + PHPUnit::assertNotNull($downloadEffect); + } + + if ($content) { + $downloadedContent = data_get($this->effects, 'download.content'); + + PHPUnit::assertEquals( + $content, + base64_decode($downloadedContent) + ); + } + + if ($contentType) { + PHPUnit::assertEquals( + $contentType, + data_get($this->effects, 'download.contentType') + ); + } + + return $this; + } + + public function assertNoFileDownloaded() + { + $downloadEffect = data_get($this->effects, 'download'); + + PHPUnit::assertNull($downloadEffect); + + return $this; + } +} diff --git a/portman/lib/Livewire/Features/SupportFileUploads/FileNotPreviewableException.php b/portman/lib/Livewire/Features/SupportFileUploads/FileNotPreviewableException.php new file mode 100644 index 00000000..89e93ce6 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportFileUploads/FileNotPreviewableException.php @@ -0,0 +1,17 @@ +guessExtension()}\" is not previewable. See the livewire.temporary_file_upload.preview_mimes config." + ); + } +} diff --git a/portman/lib/Livewire/Features/SupportFileUploads/FilePreviewController.php b/portman/lib/Livewire/Features/SupportFileUploads/FilePreviewController.php new file mode 100644 index 00000000..9dbf0fb0 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportFileUploads/FilePreviewController.php @@ -0,0 +1,24 @@ + new Middleware($middleware), static::$middleware); + } + + public function handle($filename) + { + abort_unless(request()->hasValidSignature(), 401); + + return Utils::pretendPreviewResponseIsPreviewFile($filename); + } +} diff --git a/portman/lib/Livewire/Features/SupportFileUploads/FileUploadConfiguration.php b/portman/lib/Livewire/Features/SupportFileUploads/FileUploadConfiguration.php new file mode 100644 index 00000000..650ec6a0 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportFileUploads/FileUploadConfiguration.php @@ -0,0 +1,127 @@ +runningUnitTests()) { + // We want to "fake" the first time in a test run, but not again because + // Storage::fake() wipes the storage directory every time its called. + rescue( + // If the storage disk is not found (meaning it's the first time), + // this will throw an error and trip the second callback. + fn() => Storage::disk($disk), + fn() => Storage::fake($disk), + // swallows the error that is thrown on the first try + report: false + ); + } + + return Storage::disk($disk); + } + + public static function disk() + { + if (app()->runningUnitTests()) { + return 'tmp-for-tests'; + } + + return config('livewire.temporary_file_upload.disk') ?: config('filesystems.default'); + } + + public static function diskConfig() + { + return config('filesystems.disks.'.static::disk()); + } + + public static function isUsingS3() + { + $diskBeforeTestFake = config('livewire.temporary_file_upload.disk') ?: config('filesystems.default'); + + return config('filesystems.disks.'.strtolower($diskBeforeTestFake).'.driver') === 's3'; + } + + public static function isUsingGCS() + { + $diskBeforeTestFake = config('livewire.temporary_file_upload.disk') ?: config('filesystems.default'); + + return config('filesystems.disks.'.strtolower($diskBeforeTestFake).'.driver') === 'gcs'; + } + + public static function normalizeRelativePath($path) + { + return (new WhitespacePathNormalizer)->normalizePath($path); + } + + public static function directory() + { + return static::normalizeRelativePath(config('livewire.temporary_file_upload.directory') ?: 'livewire-tmp'); + } + + protected static function s3Root() + { + if (! static::isUsingS3()) return ''; + + $diskConfig = static::diskConfig(); + + if (! is_array($diskConfig)) return ''; + + $root = $diskConfig['root'] ?? null; + + return $root !== null ? static::normalizeRelativePath($root) : ''; + } + + public static function path($path = '', $withS3Root = true) + { + $prefix = $withS3Root ? static::s3Root() : ''; + $directory = static::directory(); + $path = static::normalizeRelativePath($path); + + return $prefix.($prefix ? '/' : '').$directory.($path ? '/' : '').$path; + } + + public static function mimeType($filename) + { + $mimeType = static::storage()->mimeType(static::path($filename)); + + return $mimeType === 'image/svg' ? 'image/svg+xml' : $mimeType; + } + + public static function lastModified($filename) + { + return static::storage()->lastModified($filename); + } + + public static function middleware() + { + return config('livewire.temporary_file_upload.middleware') ?: 'throttle:60,1'; + } + + public static function shouldCleanupOldUploads() + { + return config('livewire.temporary_file_upload.cleanup', true); + } + + public static function rules() + { + $rules = config('livewire.temporary_file_upload.rules'); + + if (is_null($rules)) return ['required', 'file', 'max:12288']; + + if (is_array($rules)) return $rules; + + return explode('|', $rules); + } + + public static function maxUploadTime() + { + return config('livewire.temporary_file_upload.max_upload_time') ?: 5; + } +} diff --git a/portman/lib/Livewire/Features/SupportFileUploads/FileUploadController.php b/portman/lib/Livewire/Features/SupportFileUploads/FileUploadController.php new file mode 100644 index 00000000..41011ab8 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportFileUploads/FileUploadController.php @@ -0,0 +1,55 @@ + new Middleware($middleware), $middleware); + } + + public function handle() + { + abort_unless(request()->hasValidSignature(), 401); + + $disk = FileUploadConfiguration::disk(); + + $filePaths = $this->validateAndStore(request('files'), $disk); + + return ['paths' => $filePaths]; + } + + public function validateAndStore($files, $disk) + { + Validator::make(['files' => $files], [ + 'files.*' => FileUploadConfiguration::rules() + ])->validate(); + + $fileHashPaths = collect($files)->map(function ($file) use ($disk) { + $filename = TemporaryUploadedFile::generateHashNameWithOriginalNameEmbedded($file); + + return $file->storeAs('/'.FileUploadConfiguration::path(), $filename, [ + 'disk' => $disk + ]); + }); + + // Strip out the temporary upload directory from the paths. + return $fileHashPaths->map(function ($path) { return str_replace(FileUploadConfiguration::path('/'), '', $path); }); + } +} diff --git a/portman/lib/Livewire/Features/SupportFileUploads/FileUploadSynth.php b/portman/lib/Livewire/Features/SupportFileUploads/FileUploadSynth.php new file mode 100644 index 00000000..488a9130 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportFileUploads/FileUploadSynth.php @@ -0,0 +1,66 @@ +dehydratePropertyFromWithFileUploads($target), []]; + } + + public function dehydratePropertyFromWithFileUploads($value) + { + if (TemporaryUploadedFile::canUnserialize($value)) { + return TemporaryUploadedFile::unserializeFromLivewireRequest($value); + } + + if ($value instanceof TemporaryUploadedFile) { + return $value->serializeForLivewireResponse(); + } + + if (is_array($value) && isset(array_values($value)[0])) { + $isValid = true; + + foreach ($value as $key => $arrayValue) { + if (!($arrayValue instanceof TemporaryUploadedFile) || !is_numeric($key)) { + $isValid = false; + break; + } + } + + if ($isValid) { + return array_values($value)[0]::serializeMultipleForLivewireResponse($value); + } + } + + if (is_array($value)) { + foreach ($value as $key => $item) { + $value[$key] = $this->dehydratePropertyFromWithFileUploads($item); + } + } + + if ($value instanceof \Livewire\Wireable) { + $keys = array_keys(get_object_vars($value)); + + foreach ($keys as $key) { + $value->{$key} = $this->dehydratePropertyFromWithFileUploads($value->{$key}); + } + } + + return $value; + } + + function hydrate($value) { + if (TemporaryUploadedFile::canUnserialize($value)) { + return TemporaryUploadedFile::unserializeFromLivewireRequest($value); + } + } +} diff --git a/portman/lib/Livewire/Features/SupportFileUploads/GenerateSignedUploadUrl.php b/portman/lib/Livewire/Features/SupportFileUploads/GenerateSignedUploadUrl.php new file mode 100644 index 00000000..8369bc88 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportFileUploads/GenerateSignedUploadUrl.php @@ -0,0 +1,73 @@ +addMinutes(FileUploadConfiguration::maxUploadTime()) + ); + } + + public function forS3($file, $visibility = 'private') + { + $storage = FileUploadConfiguration::storage(); + + $driver = $storage->getDriver(); + + // Flysystem V2+ doesn't allow direct access to adapter, so we need to invade instead. + $adapter = invade($driver)->adapter; + + // Flysystem V2+ doesn't allow direct access to client, so we need to invade instead. + $client = invade($adapter)->client; + + // Flysystem V2+ doesn't allow direct access to bucket, so we need to invade instead. + $bucket = invade($adapter)->bucket; + + $fileType = $file->getMimeType(); + $fileHashName = TemporaryUploadedFile::generateHashNameWithOriginalNameEmbedded($file); + $path = FileUploadConfiguration::path($fileHashName); + + $command = $client->getCommand('putObject', array_filter([ + 'Bucket' => $bucket, + 'Key' => $path, + 'ACL' => $visibility, + 'ContentType' => $fileType ?: 'application/octet-stream', + 'CacheControl' => null, + 'Expires' => null, + ])); + + $signedRequest = $client->createPresignedRequest( + $command, + '+' . FileUploadConfiguration::maxUploadTime() . ' minutes' + ); + + $uri = $signedRequest->getUri(); + + if (filled($url = $storage->getConfig()['temporary_url'] ?? null)) { + $uri = invade($storage)->replaceBaseUrl($uri, $url); + } + + return [ + 'path' => $fileHashName, + 'url' => (string) $uri, + 'headers' => $this->headers($signedRequest, $fileType), + ]; + } + + protected function headers($signedRequest, $fileType) + { + return array_merge( + $signedRequest->getHeaders(), + [ + 'Content-Type' => $fileType ?: 'application/octet-stream' + ] + ); + } +} diff --git a/portman/lib/Livewire/Features/SupportFileUploads/MissingFileUploadsTraitException.php b/portman/lib/Livewire/Features/SupportFileUploads/MissingFileUploadsTraitException.php new file mode 100644 index 00000000..c478f78b --- /dev/null +++ b/portman/lib/Livewire/Features/SupportFileUploads/MissingFileUploadsTraitException.php @@ -0,0 +1,18 @@ +getName()}] component class." + ); + } +} diff --git a/portman/lib/Livewire/Features/SupportFileUploads/S3DoesntSupportMultipleFileUploads.php b/portman/lib/Livewire/Features/SupportFileUploads/S3DoesntSupportMultipleFileUploads.php new file mode 100644 index 00000000..b777c539 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportFileUploads/S3DoesntSupportMultipleFileUploads.php @@ -0,0 +1,18 @@ +runningUnitTests()) { + // Don't actually generate S3 signedUrls during testing. + GenerateSignedUploadUrlFacade::swap(new class extends GenerateSignedUploadUrl { + public function forS3($file, $visibility = '') { return []; } + }); + } + + app('livewire')->propertySynthesizer([ + FileUploadSynth::class, + ]); + + on('call', function ($component, $method, $params, $addEffect, $earlyReturn) { + if ($method === '_startUpload') { + if (! method_exists($component, $method)) { + throw new MissingFileUploadsTraitException($component); + } + } + }); + + Route::post('/livewire/upload-file', [FileUploadController::class, 'handle']) + ->name('livewire.upload-file'); + + Route::get('/livewire/preview-file/{filename}', [FilePreviewController::class, 'handle']) + ->name('livewire.preview-file'); + } +} diff --git a/portman/lib/Livewire/Features/SupportFileUploads/TemporaryUploadedFile.php b/portman/lib/Livewire/Features/SupportFileUploads/TemporaryUploadedFile.php new file mode 100644 index 00000000..78d2bbc5 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportFileUploads/TemporaryUploadedFile.php @@ -0,0 +1,250 @@ +disk = $disk; + $this->storage = Storage::disk($this->disk); + $this->path = FileUploadConfiguration::path($path, false); + + $tmpFile = tmpfile(); + + parent::__construct(stream_get_meta_data($tmpFile)['uri'], $this->path); + + // While running tests, update the last modified timestamp to the current + // Carbon timestamp (which respects time traveling), because otherwise + // cleanupOldUploads() will mess up with the filesystem... + if (app()->runningUnitTests()) + { + @touch($this->path(), now()->timestamp); + } + } + + public function getPath(): string + { + return $this->storage->path(FileUploadConfiguration::directory()); + } + + public function isValid(): bool + { + return true; + } + + public function getSize(): int + { + if (app()->runningUnitTests() && str($this->getFilename())->contains('-size=')) { + return (int) str($this->getFilename())->between('-size=', '.')->__toString(); + } + + return (int) $this->storage->size($this->path); + } + + public function getMimeType(): string + { + if (app()->runningUnitTests() && str($this->getFilename())->contains('-mimeType=')) { + $escapedMimeType = str($this->getFilename())->between('-mimeType=', '-'); + + // MimeTypes contain slashes, but we replaced them with underscores in `SupportTesting\Testable` + // to ensure the filename is valid, so we now need to revert that. + return (string) $escapedMimeType->replace('_', '/'); + } + + $mimeType = $this->storage->mimeType($this->path); + + // Flysystem V2.0+ removed guess mimeType from extension support, so it has been re-added back + // in here to ensure the correct mimeType is returned when using faked files in tests + if (in_array($mimeType, ['application/octet-stream', 'inode/x-empty', 'application/x-empty'])) { + $detector = new FinfoMimeTypeDetector(); + + $mimeType = $detector->detectMimeTypeFromPath($this->path) ?: 'text/plain'; + } + + return $mimeType; + } + + public function getFilename(): string + { + return $this->getName($this->path); + } + + public function getRealPath(): string + { + return $this->storage->path($this->path); + } + + public function getPathname(): string + { + return $this->getRealPath(); + } + + public function getClientOriginalName(): string + { + return $this->extractOriginalNameFromFilePath($this->path); + } + + public function dimensions() + { + stream_copy_to_stream($this->storage->readStream($this->path), $tmpFile = tmpfile()); + + return @getimagesize(stream_get_meta_data($tmpFile)['uri']); + } + + public function temporaryUrl() + { + if (!$this->isPreviewable()) { + throw new FileNotPreviewableException($this); + } + + if ((FileUploadConfiguration::isUsingS3() or FileUploadConfiguration::isUsingGCS()) && ! app()->runningUnitTests()) { + return $this->storage->temporaryUrl( + $this->path, + now()->addDay()->endOfHour(), + ['ResponseContentDisposition' => 'attachment; filename="' . urlencode($this->getClientOriginalName()) . '"'] + ); + } + + if (method_exists($this->storage->getAdapter(), 'getTemporaryUrl')) { + // This will throw an error because it's not used with S3. + return $this->storage->temporaryUrl($this->path, now()->addDay()); + } + + return URL::temporarySignedRoute( + 'livewire.preview-file', now()->addMinutes(30)->endOfHour(), ['filename' => $this->getFilename()] + ); + } + + public function isPreviewable() + { + $supportedPreviewTypes = config('livewire.temporary_file_upload.preview_mimes', [ + 'png', 'gif', 'bmp', 'svg', 'wav', 'mp4', + 'mov', 'avi', 'wmv', 'mp3', 'm4a', + 'jpg', 'jpeg', 'mpga', 'webp', 'wma', + ]); + + return in_array($this->guessExtension(), $supportedPreviewTypes); + } + + public function readStream() + { + return $this->storage->readStream($this->path); + } + + public function exists() + { + return $this->storage->exists($this->path); + } + + public function get() + { + return $this->storage->get($this->path); + } + + public function delete() + { + return $this->storage->delete($this->path); + } + + public function storeAs($path, $name = null, $options = []) + { + $options = $this->parseOptions($options); + + $disk = Arr::pull($options, 'disk') ?: $this->disk; + + $newPath = trim($path.'/'.$name, '/'); + + Storage::disk($disk)->put( + $newPath, $this->storage->readStream($this->path), $options + ); + + return $newPath; + } + + public static function generateHashNameWithOriginalNameEmbedded($file) + { + $hash = str()->random(30); + $meta = str('-meta'.base64_encode($file->getClientOriginalName()).'-')->replace('/', '_'); + $extension = '.'.$file->getClientOriginalExtension(); + + return $hash.$meta.$extension; + } + + public function hashName($path = null) + { + if (app()->runningUnitTests() && str($this->getFilename())->contains('-hash=')) { + return str($this->getFilename())->between('-hash=', '-mimeType')->value(); + } + + return parent::hashName($path); + } + + public function extractOriginalNameFromFilePath($path) + { + return base64_decode(head(explode('-', last(explode('-meta', str($path)->replace('_', '/')))))); + } + + public static function createFromLivewire($filePath) + { + return new static($filePath, FileUploadConfiguration::disk()); + } + + public static function canUnserialize($subject) + { + if (is_string($subject)) { + return (string) str($subject)->startsWith(['livewire-file:', 'livewire-files:']); + } + + if (is_array($subject)) { + return collect($subject)->contains(function ($value) { + return static::canUnserialize($value); + }); + } + + return false; + } + + public static function unserializeFromLivewireRequest($subject) + { + if (is_string($subject)) { + if (str($subject)->startsWith('livewire-file:')) { + return static::createFromLivewire(str($subject)->after('livewire-file:')); + } + + if (str($subject)->startsWith('livewire-files:')) { + $paths = json_decode(str($subject)->after('livewire-files:'), true); + + return collect($paths)->map(function ($path) { return static::createFromLivewire($path); })->toArray(); + } + } + + if (is_array($subject)) { + foreach ($subject as $key => $value) { + $subject[$key] = static::unserializeFromLivewireRequest($value); + } + } + + return $subject; + } + + public function serializeForLivewireResponse() + { + return 'livewire-file:'.$this->getFilename(); + } + + public static function serializeMultipleForLivewireResponse($files) + { + return 'livewire-files:'.json_encode(collect($files)->map->getFilename()); + } +} diff --git a/portman/lib/Livewire/Features/SupportFileUploads/WithFileUploads.php b/portman/lib/Livewire/Features/SupportFileUploads/WithFileUploads.php new file mode 100644 index 00000000..aba17958 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportFileUploads/WithFileUploads.php @@ -0,0 +1,128 @@ +create($fileInfo[0]['name'], $fileInfo[0]['size'] / 1024, $fileInfo[0]['type']); + + $this->dispatch('upload:generatedSignedUrlForS3', name: $name, payload: GenerateSignedUploadUrl::forS3($file))->self(); + + return; + } + + $this->dispatch('upload:generatedSignedUrl', name: $name, url: GenerateSignedUploadUrl::forLocal())->self(); + } + + function _finishUpload($name, $tmpPath, $isMultiple, $append = false) + { + if (FileUploadConfiguration::shouldCleanupOldUploads()) { + $this->cleanupOldUploads(); + } + + if ($isMultiple) { + $file = collect($tmpPath)->map(function ($i) { + return TemporaryUploadedFile::createFromLivewire($i); + })->toArray(); + $this->dispatch('upload:finished', name: $name, tmpFilenames: collect($file)->map->getFilename()->toArray())->self(); + + if ($append) { + $existing = $this->getPropertyValue($name); + if ($existing instanceof \Illuminate\Support\Collection) { + $file = $existing->merge($file); + } elseif (is_array($existing)) { + $file = array_merge($existing, $file); + } + } + } else { + $file = TemporaryUploadedFile::createFromLivewire($tmpPath[0]); + $this->dispatch('upload:finished', name: $name, tmpFilenames: [$file->getFilename()])->self(); + + // If the property is an array, but the upload ISNT set to "multiple" + // then APPEND the upload to the array, rather than replacing it. + if (is_array($value = $this->getPropertyValue($name))) { + $file = array_merge($value, [$file]); + } + } + + app('livewire')->updateProperty($this, $name, $file); + } + + function _uploadErrored($name, $errorsInJson, $isMultiple) { + $this->dispatch('upload:errored', name: $name)->self(); + + if (is_null($errorsInJson)) { + // Handle any translations/custom names + $translator = app()->make('translator'); + + $attribute = $translator->get("validation.attributes.{$name}"); + if ($attribute === "validation.attributes.{$name}") $attribute = $name; + + $message = trans('validation.uploaded', ['attribute' => $attribute]); + if ($message === 'validation.uploaded') $message = "The {$name} failed to upload."; + + throw ValidationException::withMessages([$name => $message]); + } + + $errorsInJson = $isMultiple + ? str_ireplace('files', $name, $errorsInJson) + : str_ireplace('files.0', $name, $errorsInJson); + + $errors = json_decode($errorsInJson, true)['errors']; + + throw (ValidationException::withMessages($errors)); + } + + function _removeUpload($name, $tmpFilename) + { + $uploads = $this->getPropertyValue($name); + + if (is_array($uploads) && isset($uploads[0]) && $uploads[0] instanceof TemporaryUploadedFile) { + $this->dispatch('upload:removed', name: $name, tmpFilename: $tmpFilename)->self(); + + app('livewire')->updateProperty($this, $name, array_values(array_filter($uploads, function ($upload) use ($tmpFilename) { + if ($upload->getFilename() === $tmpFilename) { + $upload->delete(); + return false; + } + + return true; + }))); + } elseif ($uploads instanceof TemporaryUploadedFile && $uploads->getFilename() === $tmpFilename) { + $uploads->delete(); + + $this->dispatch('upload:removed', name: $name, tmpFilename: $tmpFilename)->self(); + + app('livewire')->updateProperty($this, $name, null); + } + } + + protected function cleanupOldUploads() + { + if (FileUploadConfiguration::isUsingS3()) return; + + $storage = FileUploadConfiguration::storage(); + + foreach ($storage->allFiles(FileUploadConfiguration::path()) as $filePathname) { + // On busy websites, this cleanup code can run in multiple threads causing part of the output + // of allFiles() to have already been deleted by another thread. + if (! $storage->exists($filePathname)) continue; + + $yesterdaysStamp = now()->subDay()->timestamp; + if ($yesterdaysStamp > $storage->lastModified($filePathname)) { + $storage->delete($filePathname); + } + } + } +} diff --git a/portman/lib/Livewire/Features/SupportFileUploads/browser_test_image.png b/portman/lib/Livewire/Features/SupportFileUploads/browser_test_image.png new file mode 100644 index 00000000..99caba2a Binary files /dev/null and b/portman/lib/Livewire/Features/SupportFileUploads/browser_test_image.png differ diff --git a/portman/lib/Livewire/Features/SupportFileUploads/browser_test_image2.png b/portman/lib/Livewire/Features/SupportFileUploads/browser_test_image2.png new file mode 100644 index 00000000..99caba2a Binary files /dev/null and b/portman/lib/Livewire/Features/SupportFileUploads/browser_test_image2.png differ diff --git a/portman/lib/Livewire/Features/SupportFileUploads/browser_test_image_big.jpg b/portman/lib/Livewire/Features/SupportFileUploads/browser_test_image_big.jpg new file mode 100644 index 00000000..d73d0cf9 Binary files /dev/null and b/portman/lib/Livewire/Features/SupportFileUploads/browser_test_image_big.jpg differ diff --git a/portman/lib/Livewire/Features/SupportFormObjects/Form.php b/portman/lib/Livewire/Features/SupportFormObjects/Form.php new file mode 100644 index 00000000..85878515 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportFormObjects/Form.php @@ -0,0 +1,198 @@ +component; } + public function getPropertyName() { return $this->propertyName; } + + public function validate($rules = null, $messages = [], $attributes = []) + { + try { + return $this->parentValidate($rules, $messages, $attributes); + } catch (ValidationException $e) { + invade($e->validator)->messages = $this->prefixErrorBag(invade($e->validator)->messages); + invade($e->validator)->failedRules = $this->prefixArray(invade($e->validator)->failedRules); + + throw $e; + } + } + + public function validateOnly($field, $rules = null, $messages = [], $attributes = [], $dataOverrides = []) + { + try { + return $this->parentValidateOnly($field, $rules, $messages, $attributes, $dataOverrides); + } catch (ValidationException $e) { + invade($e->validator)->messages = $this->prefixErrorBag(invade($e->validator)->messages)->merge( + $this->getComponent()->getErrorBag() + ); + + invade($e->validator)->failedRules = $this->prefixArray(invade($e->validator)->failedRules); + + throw $e; + } + } + + protected function runSubValidators() + { + // This form object IS the sub-validator. + // Let's skip it... + } + + protected function prefixErrorBag($bag) + { + $raw = $bag->toArray(); + + $raw = Arr::prependKeysWith($raw, $this->getPropertyName().'.'); + + return new MessageBag($raw); + } + + protected function prefixArray($array) + { + return Arr::prependKeysWith($array, $this->getPropertyName().'.'); + } + + public function addError($key, $message) + { + $this->component->addError($this->propertyName . '.' . $key, $message); + } + + public function resetErrorBag($field = null) + { + $fields = (array) $field; + + foreach ($fields as $idx => $field) { + $fields[$idx] = $this->propertyName . '.' . $field; + } + + $this->getComponent()->resetErrorBag($fields); + } + + public function all() + { + return $this->toArray(); + } + + public function only($properties) + { + $results = []; + + foreach (is_array($properties) ? $properties : func_get_args() as $property) { + $results[$property] = $this->hasProperty($property) ? $this->getPropertyValue($property) : null; + } + + return $results; + } + + public function except($properties) + { + $properties = is_array($properties) ? $properties : func_get_args(); + + return array_diff_key($this->all(), array_flip($properties)); + } + + public function hasProperty($prop) + { + return property_exists($this, Utils::beforeFirstDot($prop)); + } + + public function getPropertyValue($name) + { + $value = $this->{Utils::beforeFirstDot($name)}; + + if (Utils::containsDots($name)) { + return data_get($value, Utils::afterFirstDot($name)); + } + + return $value; + } + + public function fill($values) + { + $publicProperties = array_keys($this->all()); + + if ($values instanceof Model) { + $values = $values->toArray(); + } + + foreach ($values as $key => $value) { + if (in_array(Utils::beforeFirstDot($key), $publicProperties)) { + data_set($this, $key, $value); + } + } + } + + public function reset(...$properties) + { + $properties = count($properties) && is_array($properties[0]) + ? $properties[0] + : $properties; + + if (empty($properties)) $properties = array_keys($this->all()); + + $freshInstance = new static($this->getComponent(), $this->getPropertyName()); + + foreach ($properties as $property) { + data_set($this, $property, data_get($freshInstance, $property)); + } + } + + public function resetExcept(...$properties) + { + if (count($properties) && is_array($properties[0])) { + $properties = $properties[0]; + } + + $keysToReset = array_diff(array_keys($this->all()), $properties); + + if($keysToReset === []) { + return; + } + + $this->reset($keysToReset); + } + + public function pull($properties = null) + { + $wantsASingleValue = is_string($properties); + + $properties = is_array($properties) ? $properties : func_get_args(); + + $beforeReset = match (true) { + empty($properties) => $this->all(), + $wantsASingleValue => $this->getPropertyValue($properties[0]), + default => $this->only($properties), + }; + + $this->reset($properties); + + return $beforeReset; + } + + public function toArray() + { + return Utils::getPublicProperties($this); + } +} diff --git a/portman/lib/Livewire/Features/SupportFormObjects/FormObjectSynth.php b/portman/lib/Livewire/Features/SupportFormObjects/FormObjectSynth.php new file mode 100644 index 00000000..fc3ab757 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportFormObjects/FormObjectSynth.php @@ -0,0 +1,69 @@ +toArray(); + + foreach ($data as $key => $child) { + $data[$key] = $dehydrateChild($key, $child); + } + + return [$data, ['class' => get_class($target)]]; + } + + function hydrate($data, $meta, $hydrateChild) + { + $form = new $meta['class']($this->context->component, $this->path); + + $callBootMethod = static::bootFormObject($this->context->component, $form, $this->path); + + foreach ($data as $key => $child) { + if ($child === null && Utils::propertyIsTypedAndUninitialized($form, $key)) { + continue; + } + + $form->$key = $hydrateChild($key, $child); + } + + $callBootMethod(); + + return $form; + } + + function set(&$target, $key, $value) + { + if ($value === null && Utils::propertyIsTyped($target, $key) && ! Utils::getProperty($target, $key)->getType()->allowsNull()) { + unset($target->$key); + } else { + $target->$key = $value; + } + } + + public static function bootFormObject($component, $form, $path) + { + $component->mergeOutsideAttributes( + AttributeCollection::fromComponent($component, $form, $path . '.') + ); + + return function () use ($form) { + wrap($form)->boot(); + }; + } +} + diff --git a/portman/lib/Livewire/Features/SupportFormObjects/HandlesFormObjects.php b/portman/lib/Livewire/Features/SupportFormObjects/HandlesFormObjects.php new file mode 100644 index 00000000..aa0f67ec --- /dev/null +++ b/portman/lib/Livewire/Features/SupportFormObjects/HandlesFormObjects.php @@ -0,0 +1,19 @@ +all() as $key => $value) { + if ($value instanceof Form) { + $forms[] = $value; + } + } + + return $forms; + } +} diff --git a/portman/lib/Livewire/Features/SupportFormObjects/SupportFormObjects.php b/portman/lib/Livewire/Features/SupportFormObjects/SupportFormObjects.php new file mode 100644 index 00000000..d1956938 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportFormObjects/SupportFormObjects.php @@ -0,0 +1,107 @@ +propertySynthesizer( + FormObjectSynth::class + ); + } + + function boot() + { + $this->initializeFormObjects(); + } + + public function update($formName, $fullPath, $newValue) + { + $form = $this->getProperty($formName); + + if (! $form instanceof Form) { + return; + } + + if (! str($fullPath)->contains('.')) { + return; + } + + $path = str($fullPath)->after('.')->__toString(); + + $name = str($path); + + $propertyName = $name->studly()->before('.'); + $keyAfterFirstDot = $name->contains('.') ? $name->after('.')->__toString() : null; + $keyAfterLastDot = $name->contains('.') ? $name->afterLast('.')->__toString() : null; + + $beforeMethod = 'updating'.$propertyName; + $afterMethod = 'updated'.$propertyName; + + $beforeNestedMethod = $name->contains('.') + ? 'updating'.$name->replace('.', '_')->studly() + : false; + + $afterNestedMethod = $name->contains('.') + ? 'updated'.$name->replace('.', '_')->studly() + : false; + + $this->callFormHook($form, 'updating', [$path, $newValue]); + + $this->callFormHook($form, $beforeMethod, [$newValue, $keyAfterFirstDot]); + + $this->callFormHook($form, $beforeNestedMethod, [$newValue, $keyAfterLastDot]); + + return function () use ($form, $path, $afterMethod, $afterNestedMethod, $keyAfterFirstDot, $keyAfterLastDot, $newValue) { + $this->callFormHook($form, 'updated', [$path, $newValue]); + + $this->callFormHook($form, $afterMethod, [$newValue, $keyAfterFirstDot]); + + $this->callFormHook($form, $afterNestedMethod, [$newValue, $keyAfterLastDot]); + }; + } + + protected function initializeFormObjects() + { + foreach ((new ReflectionClass($this->component))->getProperties() as $property) { + // Public properties only... + if ($property->isPublic() !== true) continue; + // Uninitialized properties only... + if ($property->isInitialized($this->component)) continue; + + $type = $property->getType(); + + if (! $type instanceof ReflectionNamedType) continue; + + $typeName = $type->getName(); + + // "Form" object property types only... + if (! is_subclass_of($typeName, Form::class)) continue; + + $form = new $typeName( + $this->component, + $name = $property->getName() + ); + + $callBootMethod = FormObjectSynth::bootFormObject($this->component, $form, $name); + + $property->setValue($this->component, $form); + + $callBootMethod(); + } + } + + protected function callFormHook($form, $name, $params = []) + { + if (method_exists($form, $name)) { + wrap($form)->$name(...$params); + } + } +} diff --git a/portman/lib/Livewire/Features/SupportIsolating/BaseIsolate.php b/portman/lib/Livewire/Features/SupportIsolating/BaseIsolate.php new file mode 100644 index 00000000..7785992f --- /dev/null +++ b/portman/lib/Livewire/Features/SupportIsolating/BaseIsolate.php @@ -0,0 +1,11 @@ +shouldIsolate()) { + $context->addMemo('isolate', true); + } + } + + public function shouldIsolate() + { + return $this->component->getAttributes() + ->filter(fn ($i) => is_subclass_of($i, BaseIsolate::class)) + ->count() > 0; + } +} diff --git a/portman/lib/Livewire/Features/SupportJsEvaluation/BaseJs.php b/portman/lib/Livewire/Features/SupportJsEvaluation/BaseJs.php new file mode 100644 index 00000000..c88e7735 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportJsEvaluation/BaseJs.php @@ -0,0 +1,22 @@ +getName(); + + $stringifiedMethod = $this->component->$name(); + + $context->pushEffect('js', $stringifiedMethod, $name); + } +} + + + + diff --git a/portman/lib/Livewire/Features/SupportJsEvaluation/HandlesJsEvaluation.php b/portman/lib/Livewire/Features/SupportJsEvaluation/HandlesJsEvaluation.php new file mode 100644 index 00000000..cfd88aea --- /dev/null +++ b/portman/lib/Livewire/Features/SupportJsEvaluation/HandlesJsEvaluation.php @@ -0,0 +1,13 @@ +push('js', compact('expression', 'params')); + } +} diff --git a/portman/lib/Livewire/Features/SupportJsEvaluation/SupportJsEvaluation.php b/portman/lib/Livewire/Features/SupportJsEvaluation/SupportJsEvaluation.php new file mode 100644 index 00000000..e8e8cb39 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportJsEvaluation/SupportJsEvaluation.php @@ -0,0 +1,17 @@ +component)->has('js')) return; + + $context->addEffect('xjs', store($this->component)->get('js')); + } +} diff --git a/portman/lib/Livewire/Features/SupportLazyLoading/BaseLazy.php b/portman/lib/Livewire/Features/SupportLazyLoading/BaseLazy.php new file mode 100644 index 00000000..b20972b2 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportLazyLoading/BaseLazy.php @@ -0,0 +1,13 @@ +defaults['lazy'] = $enabled; + + return $this; + }); + } + + public function mount($params) + { + $hasLazyParam = isset($params['lazy']); + $lazyProperty = $params['lazy'] ?? false; + $isolate = true; + + $reflectionClass = new \ReflectionClass($this->component); + $lazyAttribute = $reflectionClass->getAttributes(\Livewire\Attributes\Lazy::class)[0] ?? null; + + // If Livewire::withoutLazyLoading()... + if (static::$disableWhileTesting) return; + // If `:lazy="false"` disable lazy loading... + if ($hasLazyParam && ! $lazyProperty) return; + // If no lazy loading is included at all... + if (! $hasLazyParam && ! $lazyAttribute) return; + + if ($lazyAttribute) { + $attribute = $lazyAttribute->newInstance(); + + $isolate = $attribute->isolate; + } + + $this->component->skipMount(); + + store($this->component)->set('isLazyLoadMounting', true); + store($this->component)->set('isLazyIsolated', $isolate); + + $this->component->skipRender( + $this->generatePlaceholderHtml($params) + ); + } + + public function hydrate($memo) + { + if (! isset($memo['lazyLoaded'])) return; + if ($memo['lazyLoaded'] === true) return; + + $this->component->skipHydrate(); + + store($this->component)->set('isLazyLoadHydrating', true); + } + + function dehydrate($context) + { + if (store($this->component)->get('isLazyLoadMounting') === true) { + $context->addMemo('lazyLoaded', false); + $context->addMemo('lazyIsolated', store($this->component)->get('isLazyIsolated')); + } elseif (store($this->component)->get('isLazyLoadHydrating') === true) { + $context->addMemo('lazyLoaded', true); + } + } + + function call($method, $params, $returnEarly) + { + if ($method !== '__lazyLoad') return; + + [ $encoded ] = $params; + + $mountParams = $this->resurrectMountParams($encoded); + + $this->callMountLifecycleMethod($mountParams); + + $returnEarly(); + } + + public function generatePlaceholderHtml($params) + { + $this->registerContainerComponent(); + + $container = app('livewire')->new('__mountParamsContainer'); + + $container->forMount = array_diff_key($params, array_flip(['lazy'])); + + $context = new ComponentContext($container, mounting: true); + + trigger('dehydrate', $container, $context); + + $snapshot = app('livewire')->snapshot($container, $context); + + $encoded = base64_encode(json_encode($snapshot)); + + $placeholder = $this->getPlaceholderView($this->component, $params); + + $finish = trigger('render.placeholder', $this->component, $placeholder, $params); + + $viewContext = new ViewContext; + + $html = $placeholder->render(function ($view) use ($viewContext) { + // Extract leftover slots, sections, and pushes before they get flushed... + $viewContext->extractFromEnvironment($view->getFactory()); + }); + + $html = Utils::insertAttributesIntoHtmlRoot($html, [ + ((isset($params['lazy']) and $params['lazy'] === 'on-load') ? 'x-init' : 'x-intersect') => '$wire.__lazyLoad(\''.$encoded.'\')', + ]); + + $replaceHtml = function ($newHtml) use (&$html) { + $html = $newHtml; + }; + + $html = $finish($html, $replaceHtml, $viewContext); + + return $html; + } + + protected function getPlaceholderView($component, $params) + { + $globalPlaceholder = config('livewire.lazy_placeholder'); + + $placeholderHtml = $globalPlaceholder + ? view($globalPlaceholder)->render() + : '
'; + + $viewOrString = wrap($component)->withFallback($placeholderHtml)->placeholder($params); + + $properties = Utils::getPublicPropertiesDefinedOnSubclass($component); + + $view = Utils::generateBladeView($viewOrString, $properties); + + return $view; + } + + function resurrectMountParams($encoded) + { + $snapshot = json_decode(base64_decode($encoded), associative: true); + + $this->registerContainerComponent(); + + [ $container ] = app('livewire')->fromSnapshot($snapshot); + + return $container->forMount; + } + + function callMountLifecycleMethod($params) + { + $hook = new SupportLifecycleHooks; + + $hook->setComponent($this->component); + + $hook->mount($params); + } + + public function registerContainerComponent() + { + app('livewire')->component('__mountParamsContainer', new class extends Component { + public $forMount; + }); + } +} diff --git a/portman/lib/Livewire/Features/SupportLegacyModels/CannotBindToModelDataWithoutValidationRuleException.php b/portman/lib/Livewire/Features/SupportLegacyModels/CannotBindToModelDataWithoutValidationRuleException.php new file mode 100644 index 00000000..6dd4ea81 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportLegacyModels/CannotBindToModelDataWithoutValidationRuleException.php @@ -0,0 +1,17 @@ +getQueueableClass(); + + $meta = []; + + $meta['keys'] = $target->modelKeys(); + $meta['class'] = $class; + $meta['modelClass'] = $modelClass; + + if ($modelClass && ($connection = $this->getConnection($target)) !== $modelClass::make()->getConnectionName()) { + $meta['connection'] = $connection; + } + + $relations = $target->getQueueableRelations(); + + if (count($relations)) { + $meta['relations'] = $relations; + } + + $rules = $this->getRules($this->context); + + if (empty($rules)) return [[], $meta]; + + $data = $this->getDataFromCollection($target, $rules); + + foreach ($data as $key => $child) { + + $data[$key] = $dehydrateChild($key, $child); + } + + return [ $data, $meta ]; + } + + public function hydrate($data, $meta, $hydrateChild) + { + if (isset($meta['__child_from_parent'])) { + $collection = $meta['__child_from_parent']; + + unset($meta['__child_from_parent']); + } else { + $collection = $this->loadCollection($meta); + } + + if (isset($meta['relations'])) { + $collection->loadMissing($meta['relations']); + } + + if (count($data)) { + foreach ($data as $key => $childData) { + $childData[1]['__child_from_parent'] = $collection->get($key); + + $data[$key] = $hydrateChild($key, $childData); + } + + return $collection::wrap($data); + } + + return $collection; + } + + public function get(&$target, $key) + { + return $target->get($key); + } + + public function set(&$target, $key, $value, $pathThusFar, $fullPath) + { + if (SupportLegacyModels::missingRuleFor($this->context->component, $fullPath)) { + throw new CannotBindToModelDataWithoutValidationRuleException($fullPath, $this->context->component->getName()); + } + + $target->put($key, $value); + } + + public function methods($target) + { + return []; + } + + public function call($target, $method, $params, $addEffect) + { + } + + protected function getRules($context) + { + $key = $this->path ?? null; + + if (is_null($key)) return []; + + return SupportLegacyModels::getRulesFor($context->component, $key); + } + + protected function getConnection(EloquentCollection $collection) + { + if ($collection->isEmpty()) { + return; + } + + $connection = $collection->first()->getConnectionName(); + + $collection->each(function ($model) use ($connection) { + // If there is no connection name, it must be a new model so continue. + if (is_null($model->getConnectionName())) { + return; + } + + if ($model->getConnectionName() !== $connection) { + throw new LogicException('Livewire can\'t dehydrate an Eloquent Collection with models from different connections.'); + } + }); + + return $connection; + } + + protected function getDataFromCollection(EloquentCollection $collection, $rules) + { + return $this->filterData($collection->all(), $rules); + } + + protected function filterData($data, $rules) + { + return array_filter($data, function ($key) use ($rules) { + return array_key_exists('*', $rules); + }, ARRAY_FILTER_USE_KEY); + } + + protected function loadCollection($meta) + { + if (isset($meta['keys']) && count($meta['keys']) >= 0 && ! empty($meta['modelClass'])) { + $model = new $meta['modelClass']; + + if (isset($meta['connection'])) { + $model->setConnection($meta['connection']); + } + + $query = $model->newQueryForRestoration($meta['keys']); + + if (isset($meta['relations'])) { + $query->with($meta['relations']); + } + + $query->useWritePdo(); + + $collection = $query->get(); + + $collection = $collection->keyBy->getKey(); + + return new $meta['class']( + collect($meta['keys'])->map(function ($id) use ($collection) { + return $collection[$id] ?? null; + })->filter() + ); + } + + return new $meta['class'](); + } +} diff --git a/portman/lib/Livewire/Features/SupportLegacyModels/EloquentModelSynth.php b/portman/lib/Livewire/Features/SupportLegacyModels/EloquentModelSynth.php new file mode 100644 index 00000000..dbd7b8a3 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportLegacyModels/EloquentModelSynth.php @@ -0,0 +1,227 @@ +getMorphClass(); + } catch (ClassMorphViolationException $e) { + // If the model is not using morph classes, this exception is thrown + $alias = $class; + } + + $meta = []; + + if ($target->exists) { + $meta['key'] = $target->getKey(); + } + + $meta['class'] = $alias; + + if ($target->getConnectionName() !== $class::make()->getConnectionName()) { + $meta['connection'] = $target->getConnectionName(); + } + + $relations = $target->getQueueableRelations(); + + if (count($relations)) { + $meta['relations'] = $relations; + } + + $rules = $this->getRules($this->context); + + if (empty($rules)) return [[], $meta]; + + $data = $this->getDataFromModel($target, $rules); + + foreach ($data as $key => $child) { + $data[$key] = $dehydrateChild($key, $child); + } + + return [$data, $meta]; + } + + public function hydrate($data, $meta, $hydrateChild) + { + if (! is_iterable($data)) return null; + + if (isset($meta['__child_from_parent'])) { + $model = $meta['__child_from_parent']; + + unset($meta['__child_from_parent']); + } else { + $model = $this->loadModel($meta); + } + + if (isset($meta['relations'])) { + foreach($meta['relations'] as $relationKey) { + if (! isset($data[$relationKey])) continue; + + $data[$relationKey][1]['__child_from_parent'] = $model->getRelation($relationKey); + + $model->setRelation($relationKey, $hydrateChild($relationKey, $data[$relationKey])); + + unset($data[$relationKey]); + } + } + + foreach ($data as $key => $child) { + $data[$key] = $hydrateChild($key, $child); + } + + $this->setDataOnModel($model, $data); + + return $model; + } + + public function get(&$target, $key) + { + return $target->$key; + } + + public function set(Model &$target, $key, $value, $pathThusFar, $fullPath) + { + if (SupportLegacyModels::missingRuleFor($this->context->component, $fullPath)) { + throw new CannotBindToModelDataWithoutValidationRuleException($fullPath, $this->context->component->getName()); + } + + if ($target->relationLoaded($key)) { + return $target->setRelation($key, $value); + } + + if (array_key_exists($key, $target->getCasts()) && enum_exists($target->getCasts()[$key]) && $value === '') { + $value = null; + } + + $target->$key = $value; + } + + public function methods($target) + { + return []; + } + + public function call($target, $method, $params, $addEffect) + { + } + + protected function getRules($context) + { + $key = $this->path ?? null; + + if (is_null($key)) return []; + + if ($context->component) { + return SupportLegacyModels::getRulesFor($this->context->component, $key); + } + } + + protected function getDataFromModel(Model $model, $rules) + { + return [ + ...$this->filterAttributes($this->getAttributes($model), $rules), + ...$this->filterRelations($model->getRelations(), $rules), + ]; + } + + protected function getAttributes($model) + { + $attributes = $model->attributesToArray(); + + foreach ($model->getCasts() as $key => $cast) { + if (! class_exists($cast)) continue; + + if ( + in_array(CastsAttributes::class, class_implements($cast)) + && isset($attributes[$key]) + ) { + $attributes[$key] = $model->getAttributes()[$key]; + } + } + + return $attributes; + } + + protected function filterAttributes($data, $rules) + { + $filteredAttributes = []; + + foreach($rules as $key => $rule) { + // If the rule is an array, take the key instead + if (is_array($rule)) { + $rule = $key; + } + + // If someone has created an empty model, the attribute may not exist + // yet, so use data_get so it will still be sent to the front end. + $filteredAttributes[$rule] = data_get($data, $rule); + } + + return $filteredAttributes; + } + + protected function filterRelations($data, $rules) + { + return array_filter($data, function ($key) use ($rules) { + return array_key_exists($key, $rules) || in_array($key, $rules); + }, ARRAY_FILTER_USE_KEY); + } + + protected function loadModel($meta): ?Model + { + $class = $meta['class']; + + // If no alias found, this returns `null` + $aliasClass = Relation::getMorphedModel($class); + + if (! is_null($aliasClass)) { + $class = $aliasClass; + } + + if (isset($meta['key'])) { + $model = new $class; + + if (isset($meta['connection'])) { + $model->setConnection($meta['connection']); + } + + $query = $model->newQueryForRestoration($meta['key']); + + if (isset($meta['relations'])) { + $query->with($meta['relations']); + } + + $model = $query->first(); + } else { + $model = new $class(); + } + + return $model; + } + + protected function setDataOnModel(Model $model, $data) + { + foreach ($data as $key => $value) { + $model->$key = $value; + } + } +} diff --git a/portman/lib/Livewire/Features/SupportLegacyModels/SupportLegacyModels.php b/portman/lib/Livewire/Features/SupportLegacyModels/SupportLegacyModels.php new file mode 100644 index 00000000..9a95d122 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportLegacyModels/SupportLegacyModels.php @@ -0,0 +1,169 @@ +missingRuleFor() method on component. (inside ModelSynth) + */ +class SupportLegacyModels extends ComponentHook +{ + protected static $rules; + + static function provide() + { + // Only enable this feature if config option is set to `true`. + if (config('livewire.legacy_model_binding', false) !== true) return; + + app('livewire')->propertySynthesizer([ + EloquentModelSynth::class, + EloquentCollectionSynth::class, + ]); + + on('flush-state', function() { + static::flushRules(); + }); + } + + static function flushRules() + { + static::$rules = null; + } + + static function hasRuleFor($component, $path) { + $path = str($path)->explode('.'); + + $has = false; + $end = false; + + $segmentRules = static::getRules($component); + + foreach ($path as $key => $segment) { + if ($end) { + throw new \LogicException('Something went wrong'); + } + + if (!is_numeric($segment) && array_key_exists($segment, $segmentRules)) { + $segmentRules = $segmentRules[$segment]; + $has = true; + continue; + } + + if (is_numeric($segment) && array_key_exists('*', $segmentRules)) { + $segmentRules = $segmentRules['*']; + $has = true; + continue; + } + + if (is_numeric($segment) && in_array('*', $segmentRules, true)) { + $has = true; + $end = true; + continue; + } + + if (in_array($segment, $segmentRules, true)) { + $has = true; + $end = true; + continue; + } + + $has = false; + } + + return $has; + } + + static function missingRuleFor($component, $path) { + return ! static::hasRuleFor($component, $path); + } + + static function getRules($component) { + return static::$rules[$component->getId()] ??= static::processRules($component); + } + + static function getRulesFor($component, $key) + { + $rules = static::getRules($component); + + $propertyWithStarsInsteadOfNumbers = static::ruleWithNumbersReplacedByStars($key); + + return static::dataGetWithoutWildcardSupport( + $rules, + $propertyWithStarsInsteadOfNumbers, + data_get($rules, $key, []), + ); + } + + static function dataGetWithoutWildcardSupport($array, $key, $default) + { + $segments = explode('.', $key); + + $first = array_shift($segments); + + if (! isset($array[$first])) { + return value($default); + } + + $value = $array[$first]; + + if (count($segments) > 0) { + return static::dataGetWithoutWildcardSupport($value, implode('.', $segments), $default); + } + + return $value; + } + + static function ruleWithNumbersReplacedByStars($dotNotatedProperty) + { + // Convert foo.0.bar.1 -> foo.*.bar.* + return (string) str($dotNotatedProperty) + // Replace all numeric indexes with an array wildcard: (.0., .10., .007.) => .*. + // In order to match overlapping numerical indexes (foo.1.2.3.4.name), + // We need to use a positive look-behind, that's technically all the magic here. + // For better understanding, see: https://regexr.com/5d1n3 + ->replaceMatches('/(?<=(\.))\d+\./', '*.') + // Replace all numeric indexes at the end of the name with an array wildcard + // (Same as the previous regex, but ran only at the end of the string) + // For better undestanding, see: https://regexr.com/5d1n6 + ->replaceMatches('/\.\d+$/', '.*'); + } + + protected static function processRules($component) + { + $rules = array_keys($component->getRules()); + + return static::convertDotNotationToArrayNotation($rules); + } + + protected static function convertDotNotationToArrayNotation($rules) + { + return static::recusivelyProcessDotNotation($rules); + } + + protected static function recusivelyProcessDotNotation($rules) + { + $singleRules = []; + $groupedRules = []; + + foreach ($rules as $key => $value) { + $value = str($value); + + if (!$value->contains('.')) { + $singleRules[] = (string) $value; + + continue; + } + + $groupedRules[(string) $value->before('.')][] = (string) $value->after('.'); + } + + foreach ($groupedRules as $key => $value) { + $groupedRules[$key] = static::recusivelyProcessDotNotation($value); + } + + return $singleRules + $groupedRules; + } +} diff --git a/portman/lib/Livewire/Features/SupportLifecycleHooks/DirectlyCallingLifecycleHooksNotAllowedException.php b/portman/lib/Livewire/Features/SupportLifecycleHooks/DirectlyCallingLifecycleHooksNotAllowedException.php new file mode 100644 index 00000000..e664286c --- /dev/null +++ b/portman/lib/Livewire/Features/SupportLifecycleHooks/DirectlyCallingLifecycleHooksNotAllowedException.php @@ -0,0 +1,17 @@ +component)->has('skipMount')) { return; } + + $this->callHook('boot'); + $this->callTraitHook('boot'); + + $this->callTraitHook('initialize'); + + $this->callHook('mount', $params); + $this->callTraitHook('mount', $params); + + $this->callHook('booted'); + $this->callTraitHook('booted'); + } + + public function hydrate() + { + if (store($this->component)->has('skipHydrate')) { return; } + + $this->callHook('boot'); + $this->callTraitHook('boot'); + + $this->callTraitHook('initialize'); + + $this->callHook('hydrate'); + $this->callTraitHook('hydrate'); + + // Call "hydrateXx" hooks for each property... + foreach ($this->getProperties() as $property => $value) { + $this->callHook('hydrate'.str($property)->studly(), [$value]); + } + + $this->callHook('booted'); + $this->callTraitHook('booted'); + } + + public function update($propertyName, $fullPath, $newValue) + { + $name = str($fullPath); + + $propertyName = $name->studly()->before('.'); + $keyAfterFirstDot = $name->contains('.') ? $name->after('.')->__toString() : null; + $keyAfterLastDot = $name->contains('.') ? $name->afterLast('.')->__toString() : null; + + $beforeMethod = 'updating'.$propertyName; + $afterMethod = 'updated'.$propertyName; + + $beforeNestedMethod = $name->contains('.') + ? 'updating'.$name->replace('.', '_')->studly() + : false; + + $afterNestedMethod = $name->contains('.') + ? 'updated'.$name->replace('.', '_')->studly() + : false; + + $this->callHook('updating', [$fullPath, $newValue]); + $this->callTraitHook('updating', [$fullPath, $newValue]); + + $this->callHook($beforeMethod, [$newValue, $keyAfterFirstDot]); + + $this->callHook($beforeNestedMethod, [$newValue, $keyAfterLastDot]); + + return function () use ($fullPath, $afterMethod, $afterNestedMethod, $keyAfterFirstDot, $keyAfterLastDot, $newValue) { + $this->callHook('updated', [$fullPath, $newValue]); + $this->callTraitHook('updated', [$fullPath, $newValue]); + + $this->callHook($afterMethod, [$newValue, $keyAfterFirstDot]); + + $this->callHook($afterNestedMethod, [$newValue, $keyAfterLastDot]); + }; + } + + public function call($methodName, $params, $returnEarly) + { + $protectedMethods = [ + 'mount', + 'exception', + 'hydrate*', + 'dehydrate*', + 'updating*', + 'updated*', + ]; + + throw_if( + str($methodName)->is($protectedMethods), + new DirectlyCallingLifecycleHooksNotAllowedException($methodName, $this->component->getName()) + ); + + $this->callTraitHook('call', ['methodName' => $methodName, 'params' => $params, 'returnEarly' => $returnEarly]); + } + + public function exception($e, $stopPropagation) + { + $this->callHook('exception', ['e' => $e, 'stopPropagation' => $stopPropagation]); + $this->callTraitHook('exception', ['e' => $e, 'stopPropagation' => $stopPropagation]); + } + + public function render($view, $data) + { + $this->callHook('rendering', ['view' => $view, 'data' => $data]); + $this->callTraitHook('rendering', ['view' => $view, 'data' => $data]); + + return function ($html) use ($view) { + $this->callHook('rendered', ['view' => $view, 'html' => $html]); + $this->callTraitHook('rendered', ['view' => $view, 'html' => $html]); + }; + } + + public function dehydrate() + { + $this->callHook('dehydrate'); + $this->callTraitHook('dehydrate'); + + // Call "dehydrateXx" hooks for each property... + foreach ($this->getProperties() as $property => $value) { + $this->callHook('dehydrate'.str($property)->studly(), [$value]); + } + } + + public function callHook($name, $params = []) + { + if (method_exists($this->component, $name)) { + wrap($this->component)->__call($name, $params); + } + } + + function callTraitHook($name, $params = []) + { + foreach (class_uses_recursive($this->component) as $trait) { + $method = $name.class_basename($trait); + + if (method_exists($this->component, $method)) { + wrap($this->component)->$method(...$params); + } + } + } +} diff --git a/portman/lib/Livewire/Features/SupportLocales/SupportLocales.php b/portman/lib/Livewire/Features/SupportLocales/SupportLocales.php new file mode 100644 index 00000000..5fba921c --- /dev/null +++ b/portman/lib/Livewire/Features/SupportLocales/SupportLocales.php @@ -0,0 +1,18 @@ +setLocale($locale); + } + + function dehydrate($context) + { + $context->addMemo('locale', app()->getLocale()); + } +} diff --git a/portman/lib/Livewire/Features/SupportLockedProperties/BaseLocked.php b/portman/lib/Livewire/Features/SupportLockedProperties/BaseLocked.php new file mode 100644 index 00000000..20060a71 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportLockedProperties/BaseLocked.php @@ -0,0 +1,14 @@ +getName()); + } +} diff --git a/portman/lib/Livewire/Features/SupportLockedProperties/CannotUpdateLockedPropertyException.php b/portman/lib/Livewire/Features/SupportLockedProperties/CannotUpdateLockedPropertyException.php new file mode 100644 index 00000000..77010cec --- /dev/null +++ b/portman/lib/Livewire/Features/SupportLockedProperties/CannotUpdateLockedPropertyException.php @@ -0,0 +1,13 @@ +property.']' + ); + } +} diff --git a/portman/lib/Livewire/Features/SupportModels/EloquentCollectionSynth.php b/portman/lib/Livewire/Features/SupportModels/EloquentCollectionSynth.php new file mode 100644 index 00000000..eb5b686b --- /dev/null +++ b/portman/lib/Livewire/Features/SupportModels/EloquentCollectionSynth.php @@ -0,0 +1,92 @@ +getQueueableClass(); + + /** + * `getQueueableClass` above checks all models are the same and + * then returns the class. We then instantiate a model object + * so we can call `getMorphClass()` on it. + * + * If no alias is found, this just returns the class name + */ + $modelAlias = $modelClass ? (new $modelClass)->getMorphClass() : null; + + $meta = []; + + $serializedCollection = (array) $this->getSerializedPropertyValue($target); + + $meta['keys'] = $serializedCollection['id']; + $meta['class'] = $class; + $meta['modelClass'] = $modelAlias; + + return [ + null, + $meta + ]; + } + + function hydrate($data, $meta, $hydrateChild) + { + $class = $meta['class']; + + $modelClass = $meta['modelClass']; + + // If no alias found, this returns `null` + $modelAlias = Relation::getMorphedModel($modelClass); + + if (! is_null($modelAlias)) { + $modelClass = $modelAlias; + } + + $keys = $meta['keys'] ?? []; + + if (count($keys) === 0) { + return new $class(); + } + + // We are using Laravel's method here for restoring the collection, which ensures + // that all models in the collection are restored in one query, preventing n+1 + // issues and also only restores models that exist. + $collection = (new $modelClass)->newQueryForRestoration($keys)->useWritePdo()->get(); + + $collection = $collection->keyBy->getKey(); + + return new $meta['class']( + collect($meta['keys'])->map(function ($id) use ($collection) { + return $collection[$id] ?? null; + })->filter() + ); + } + + function get(&$target, $key) { + throw new \Exception('Can\'t access model properties directly'); + } + + function set(&$target, $key, $value, $pathThusFar, $fullPath) { + throw new \Exception('Can\'t set model properties directly'); + } + + function call($target, $method, $params, $addEffect) { + throw new \Exception('Can\'t call model methods directly'); + } +} \ No newline at end of file diff --git a/portman/lib/Livewire/Features/SupportModels/ModelSynth.php b/portman/lib/Livewire/Features/SupportModels/ModelSynth.php new file mode 100644 index 00000000..a14abf74 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportModels/ModelSynth.php @@ -0,0 +1,81 @@ +getMorphClass(); + } catch (ClassMorphViolationException $e) { + // If the model is not using morph classes, this exception is thrown + $alias = $class; + } + + $serializedModel = $target->exists + ? (array) $this->getSerializedPropertyValue($target) + : null; + + $meta = ['class' => $alias]; + + // If the model doesn't exist as it's an empty model or has been + // recently deleted, then we don't want to include any key. + if ($serializedModel) $meta['key'] = $serializedModel['id']; + + + return [ + null, + $meta, + ]; + } + + function hydrate($data, $meta) { + $class = $meta['class']; + + // If no alias found, this returns `null` + $aliasClass = Relation::getMorphedModel($class); + + if (! is_null($aliasClass)) { + $class = $aliasClass; + } + + // If no key is provided then an empty model is returned + if (! array_key_exists('key', $meta)) { + return new $class; + } + + $key = $meta['key']; + + $model = (new $class)->newQueryForRestoration($key)->useWritePdo()->firstOrFail(); + + return $model; + } + + function get(&$target, $key) { + throw new \Exception('Can\'t access model properties directly'); + } + + function set(&$target, $key, $value, $pathThusFar, $fullPath) { + throw new \Exception('Can\'t set model properties directly'); + } + + function call($target, $method, $params, $addEffect) { + throw new \Exception('Can\'t call model methods directly'); + } +} diff --git a/portman/lib/Livewire/Features/SupportModels/SupportModels.php b/portman/lib/Livewire/Features/SupportModels/SupportModels.php new file mode 100644 index 00000000..7a8b1691 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportModels/SupportModels.php @@ -0,0 +1,16 @@ +propertySynthesizer([ + ModelSynth::class, + EloquentCollectionSynth::class, + ]); + } +} diff --git a/portman/lib/Livewire/Features/SupportMorphAwareBladeCompilation/SupportMorphAwareBladeCompilation.php b/portman/lib/Livewire/Features/SupportMorphAwareBladeCompilation/SupportMorphAwareBladeCompilation.php new file mode 100644 index 00000000..2a250e99 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportMorphAwareBladeCompilation/SupportMorphAwareBladeCompilation.php @@ -0,0 +1,564 @@ + '@endif', + '@unless' => '@endunless', + '@error' => '@enderror', + '@isset' => '@endisset', + '@empty' => '@endempty', + '@auth' => '@endauth', + '@guest' => '@endguest', + '@switch' => '@endswitch', + '@foreach' => '@endforeach', + '@forelse' => '@endforelse', + '@while' => '@endwhile', + '@for' => '@endfor', + ]; + + Blade::precompiler(function ($entire) use ($directives) { + $conditions = \Livewire\invade(app('blade.compiler'))->conditions; + + foreach (array_keys($conditions) as $conditionalDirective) { + $directives['@'.$conditionalDirective] = '@end'.$conditionalDirective; + } + + $entire = static::compileDirectives($entire, $directives); + + return $entire; + }); + } + + /* + * This method is a modified version of the Blade compiler's `compileStatements` method. + * It finds all directives in the template, gets the expression if it has parentheses + * and prefixes the opening directives and suffixes the closing directives. + */ + public static function compileDirectives($template, $directives) + { + $openings = array_keys($directives); + $closings = array_values($directives); + + $openingDirectivesPattern = static::directivesPattern($openings); + $closingDirectivesPattern = static::directivesPattern($closings); + // This is for an `@empty` inside a `@forelse` loop, not `@empty()` conditional directive... + $loopEmptyDirectivePattern = '/@empty(?!\s*\()/mUxi'; + + // First, let's match ALL blade directives on the page, not just conditionals... + preg_match_all( + '/\B@(@?\w+(?:::\w+)?)([ \t]*)(\( ( [\S\s]*? ) \))?/x', + $template, + $matches, + PREG_OFFSET_CAPTURE + ); + + if (empty($matches[0])) { + return $template; + } + + // Find all tags which shouldn't have directives processed inside them... + $ignoredTags = ['script', 'style']; + $excludedRanges = static::findIgnoredTagRanges($template, $ignoredTags); + + for ($i = count($matches[0]) - 1; $i >= 0; $i--) { + $match = [ + $matches[0][$i][0], + $matches[1][$i][0], + $matches[2][$i][0], + $matches[3][$i][0] ?: null, + $matches[4][$i][0] ?: null, + ]; + + $matchPosition = $matches[0][$i][1]; + + // If the blade directive is escaped with an extra `@` then we don't want to process it... + if (str($match[1])->startsWith('@')) { + continue; + } + + // Skip directives inside ignored tags... + if (static::isInExcludedRange($matchPosition, $excludedRanges)) { + continue; + } + + // Here we check to see if we have properly found the closing parenthesis by + // regex pattern or not, and will recursively continue on to the next ")" + // then check again until the tokenizer confirms we find the right one. + while ( + isset($match[4]) + && str($match[0])->endsWith(')') + && ! static::hasEvenNumberOfParentheses($match[0]) + ) { + // Use position-based approach to find the text after the current match, + // rather than searching for the match string (which could find an earlier + // occurrence if the same pattern appears multiple times in the template)... + $afterPosition = $matchPosition + strlen($match[0]); + + if ($afterPosition >= strlen($template)) { + break; + } + + $after = substr($template, $afterPosition); + + $rest = str($after)->before(')'); + + if ( + isset($matches[0][$i - 1]) + && str($rest.')')->contains($matches[0][$i - 1][0]) + ) { + unset($matches[0][$i - 1]); + $i--; + } + + $match[0] = $match[0].$rest.')'; + $match[3] = $match[3].$rest.')'; + $match[4] = $match[4].$rest; + } + + // Now we can check to see if the current Blade directive is a conditional, + // and if so, prefix/suffix it with HTML comment morph markers... + if (preg_match($openingDirectivesPattern, $match[0])) { + $template = static::prefixOpeningDirective($match[0], $template, $matchPosition); + } elseif (preg_match($closingDirectivesPattern, $match[0])) { + $template = static::suffixClosingDirective($match[0], $template, $matchPosition); + } elseif (preg_match($loopEmptyDirectivePattern, $match[0])) { + $template = static::suffixLoopEmptyDirective($match[0], $template, $matchPosition); + } + } + + return $template; + } + + protected static function prefixOpeningDirective($found, $template, $position) + { + $foundEscaped = preg_quote($found, '/'); + + $livewireCheckOpeningTag = ''; + + $livewireCheckClosingTag = ''; + + $prefix = ''; + + $suffix = ''; + + if (static::$shouldInjectConditionalMarkers) { + $prefix = ''; + } + + if (static::$shouldInjectLoopMarkers && static::isLoop($found)) { + $prefix .= ''; + + $suffix .= 'index); ?>'; + } + + if ($prefix === '' && $suffix === '') { + return $template; + } + + if ($prefix !== '') { + $prefix = $livewireCheckOpeningTag.$prefix.$livewireCheckClosingTag; + } + + if ($suffix !== '') { + $suffix = $livewireCheckOpeningTag.$suffix.$livewireCheckClosingTag; + } + + $prefixEscaped = preg_quote($prefix); + + $suffixEscaped = preg_quote($suffix); + + $pattern = "/(?'; + + $livewireCheckClosingTag = ''; + + $prefix = ''; + + $suffix = ''; + + if (static::$shouldInjectConditionalMarkers) { + $suffix = ''; + } + + if (static::$shouldInjectLoopMarkers && static::isEndLoop($found)) { + $prefix .= ''; + + $suffix .= ''; + } + + if ($prefix === '' && $suffix === '') { + return $template; + } + + if ($prefix !== '') { + $prefix = $livewireCheckOpeningTag.$prefix.$livewireCheckClosingTag; + } + + if ($suffix !== '') { + $suffix = $livewireCheckOpeningTag.$suffix.$livewireCheckClosingTag; + } + + $prefixEscaped = preg_quote($prefix); + + $suffixEscaped = preg_quote($suffix); + + $pattern = "/"; + + // If the prefix is not empty, then add it to the pattern... + if ($prefixEscaped !== '') { + $pattern .= "(?'; + + $livewireCheckClosingTag = ''; + + $prefix = ''; + + $suffix = ''; + + if (static::$shouldInjectLoopMarkers) { + $prefix = ''; + + $suffix = ''; + } + + if ($prefix === '' && $suffix === '') { + return $template; + } + + if ($prefix !== '') { + $prefix = $livewireCheckOpeningTag.$prefix.$livewireCheckClosingTag; + } + + if ($suffix !== '') { + $suffix = $livewireCheckOpeningTag.$suffix.$livewireCheckClosingTag; + } + + $prefixEscaped = preg_quote($prefix); + + $suffixEscaped = preg_quote($suffix); + + $pattern = "/(?sortBy(fn ($directive) => strlen($directive), descending: true) + // Only match directives that are an exact match and not ones that + // simply start with the provided directive here... + ->map(fn ($directive) => $directive.'(?![a-zA-Z])') + // @empty is a special case in that it can be used as a standalone directive + // and also within a @forelese statement. We only want to target when it's standalone + // by enforcing @empty has an opening parenthesis after it when matching... + ->map(fn ($directive) => str($directive)->startsWith('@empty') ? $directive.'[^\S\r\n]*\(' : $directive) + ->join('|') + .')'; + + // Blade directives: (@if|@foreach|...) + $pattern = '/'.$directivesPattern.'/mUxi'; + + return $pattern; + } + + protected static function hasEvenNumberOfParentheses(string $expression) + { + $tokens = token_get_all('`... + preg_match_all( + '/<('.$tagsPattern.')(?:\s[^>]*)?>|<\/('.$tagsPattern.')>/i', + $template, + $tagMatches, + PREG_OFFSET_CAPTURE + ); + + $stack = []; + + foreach ($tagMatches[0] as $tagMatch) { + $tag = $tagMatch[0]; + $position = $tagMatch[1]; + + // Check if it's an opening tag... + if (preg_match('/<('.$tagsPattern.')/i', $tag, $typeMatch)) { + $type = strtolower($typeMatch[1]); + $stack[] = ['type' => $type, 'start' => $position]; + } + // Check if it's a closing tag... + elseif (preg_match('/<\/('.$tagsPattern.')>/i', $tag, $typeMatch)) { + $type = strtolower($typeMatch[1]); + + // Find the matching opening tag... + for ($i = count($stack) - 1; $i >= 0; $i--) { + if ($stack[$i]['type'] === $type) { + $start = $stack[$i]['start']; + $end = $position + strlen($tag); + $ranges[] = [$start, $end]; + array_splice($stack, $i, 1); + break; + } + } + } + } + + return $ranges; + } + + protected static function isInExcludedRange(int $position, array $ranges): bool + { + foreach ($ranges as [$start, $end]) { + if ($position >= $start && $position < $end) { + return true; + } + } + + return false; + } + + protected static function replaceMatchIfNotInsideAHtmlTag(string $template, int $position, string $pattern, string $found, string $prefix, string $suffix): string + { + // Clamp the match position to the template bounds... + $templateLength = strlen($template); + $position = max(0, min($templateLength, (int) $position)); + + // Check if we're inside an HTML tag by looking backwards... + if (static::isInsideHtmlTag($template, $position)) { + return $template; + } + + $before = substr($template, 0, $position); + $after = substr($template, $position); + + if (! preg_match($pattern, $after, $afterMatch, PREG_OFFSET_CAPTURE) || $afterMatch[0][1] !== 0) { + return $template; + } + + // Remove the match from the beginning of the after string... + $after = substr($after, strlen($afterMatch[0][0])); + + return $before.$prefix.$found.$suffix.$after; + } + + /** + * Check if the given position in the template is inside an unclosed HTML tag. + * + * This looks backwards from the position to find the most recent '<' that could + * start an HTML tag, then checks if there's a closing '>' between that '<' and + * the position. It correctly handles '>' characters inside quoted strings and + * Blade expressions (parentheses/braces). + */ + protected static function isInsideHtmlTag(string $template, int $position): bool + { + $before = substr($template, 0, $position); + + // Search backwards through '<' occurrences to find an HTML tag opener... + $searchFrom = strlen($before); + + while ($searchFrom > 0) { + // Find last '<' before $searchFrom... + $bracketPos = strrpos(substr($before, 0, $searchFrom), '<'); + + if ($bracketPos === false) { + return false; + } + + $segment = substr($before, $bracketPos); + + // Skip PHP tags (' that closes an HTML tag. + * + * This ignores '>' characters that appear inside: + * - Single or double quoted strings (attribute values) + * - Parentheses (Blade directive expressions like @if($x > 0)) + * - Braces (Blade echo like {{ $x > 0 }}) + */ + protected static function hasClosingBracketInSegment(string $segment): bool + { + $length = strlen($segment); + $inSingleQuote = false; + $inDoubleQuote = false; + $parenDepth = 0; + $braceDepth = 0; + + for ($i = 1; $i < $length; $i++) { + $char = $segment[$i]; + $prevChar = $segment[$i - 1]; + + // Track quote state (but not if escaped)... + if (! $inDoubleQuote && $char === "'" && $prevChar !== '\\') { + $inSingleQuote = ! $inSingleQuote; + } elseif (! $inSingleQuote && $char === '"' && $prevChar !== '\\') { + $inDoubleQuote = ! $inDoubleQuote; + } elseif (! $inSingleQuote && ! $inDoubleQuote) { + // Track nesting depth when not inside quotes... + if ($char === '(') { + $parenDepth++; + } elseif ($char === ')') { + $parenDepth = max(0, $parenDepth - 1); + } elseif ($char === '{') { + $braceDepth++; + } elseif ($char === '}') { + $braceDepth = max(0, $braceDepth - 1); + } elseif ($char === '>' && $parenDepth === 0 && $braceDepth === 0) { + // Found a top-level '>' that closes the tag... + return true; + } + } + } + + return false; + } +} diff --git a/portman/lib/Livewire/Features/SupportMultipleRootElementDetection/MultipleRootElementsDetectedException.php b/portman/lib/Livewire/Features/SupportMultipleRootElementDetection/MultipleRootElementsDetectedException.php new file mode 100644 index 00000000..e192a10a --- /dev/null +++ b/portman/lib/Livewire/Features/SupportMultipleRootElementDetection/MultipleRootElementsDetectedException.php @@ -0,0 +1,15 @@ +getName() . ']'); + } +} diff --git a/portman/lib/Livewire/Features/SupportMultipleRootElementDetection/SupportMultipleRootElementDetection.php b/portman/lib/Livewire/Features/SupportMultipleRootElementDetection/SupportMultipleRootElementDetection.php new file mode 100644 index 00000000..c669392b --- /dev/null +++ b/portman/lib/Livewire/Features/SupportMultipleRootElementDetection/SupportMultipleRootElementDetection.php @@ -0,0 +1,51 @@ +warnAgainstMoreThanOneRootElement($component, $html); + + }; + }); + } + + function warnAgainstMoreThanOneRootElement($component, $html) + { + $count = $this->getRootElementCount($html); + + if ($count > 1) { + throw new MultipleRootElementsDetectedException($component); + } + } + + function getRootElementCount($html) + { + $dom = new \DOMDocument(); + + @$dom->loadHTML($html); + + $body = $dom->getElementsByTagName('body')->item(0); + + $count = 0; + + foreach ($body->childNodes as $child) { + if ($child->nodeType == XML_ELEMENT_NODE) { + if ($child->tagName === 'script') continue; + + $count++; + } + } + + return $count; + } +} diff --git a/portman/lib/Livewire/Features/SupportNavigate/SupportNavigate.php b/portman/lib/Livewire/Features/SupportNavigate/SupportNavigate.php new file mode 100644 index 00000000..846051d0 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportNavigate/SupportNavigate.php @@ -0,0 +1,33 @@ +forceAssetInjection(); ?>
'; + }); + + Blade::directive('endpersist', function ($expression) { + return '
'; + }); + + app('livewire')->useScriptTagAttributes([ + 'data-navigate-once' => true, + ]); + + Vite::useScriptTagAttributes([ + 'data-navigate-track' => 'reload', + ]); + + Vite::useStyleTagAttributes([ + 'data-navigate-track' => 'reload', + ]); + } +} diff --git a/portman/lib/Livewire/Features/SupportNavigate/test-views/changed-layout.blade.php b/portman/lib/Livewire/Features/SupportNavigate/test-views/changed-layout.blade.php new file mode 100644 index 00000000..2e11daeb --- /dev/null +++ b/portman/lib/Livewire/Features/SupportNavigate/test-views/changed-layout.blade.php @@ -0,0 +1,13 @@ + + + + + + + {{ $slot }} + + @stack('scripts') + + + + diff --git a/portman/lib/Livewire/Features/SupportNavigate/test-views/changed-tracked-layout.blade.php b/portman/lib/Livewire/Features/SupportNavigate/test-views/changed-tracked-layout.blade.php new file mode 100644 index 00000000..ca54436b --- /dev/null +++ b/portman/lib/Livewire/Features/SupportNavigate/test-views/changed-tracked-layout.blade.php @@ -0,0 +1,13 @@ + + + + + + + {{ $slot }} + + @stack('scripts') + + + + diff --git a/portman/lib/Livewire/Features/SupportNavigate/test-views/html-attributes1.blade.php b/portman/lib/Livewire/Features/SupportNavigate/test-views/html-attributes1.blade.php new file mode 100644 index 00000000..42d3999d --- /dev/null +++ b/portman/lib/Livewire/Features/SupportNavigate/test-views/html-attributes1.blade.php @@ -0,0 +1,13 @@ + + + + + + + {{ $slot }} + + @stack('scripts') + + + + diff --git a/portman/lib/Livewire/Features/SupportNavigate/test-views/html-attributes2.blade.php b/portman/lib/Livewire/Features/SupportNavigate/test-views/html-attributes2.blade.php new file mode 100644 index 00000000..9ed3a8a8 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportNavigate/test-views/html-attributes2.blade.php @@ -0,0 +1,13 @@ + + + + + + + {{ $slot }} + + @stack('scripts') + + + + diff --git a/portman/lib/Livewire/Features/SupportNavigate/test-views/layout-with-navigate-outside.blade.php b/portman/lib/Livewire/Features/SupportNavigate/test-views/layout-with-navigate-outside.blade.php new file mode 100644 index 00000000..c326730c --- /dev/null +++ b/portman/lib/Livewire/Features/SupportNavigate/test-views/layout-with-navigate-outside.blade.php @@ -0,0 +1,15 @@ + + + + + + + Go to second page (outside) + + {{ $slot }} + + @stack('scripts') + + + + diff --git a/portman/lib/Livewire/Features/SupportNavigate/test-views/layout-with-noscript.blade.php b/portman/lib/Livewire/Features/SupportNavigate/test-views/layout-with-noscript.blade.php new file mode 100644 index 00000000..8903ecde --- /dev/null +++ b/portman/lib/Livewire/Features/SupportNavigate/test-views/layout-with-noscript.blade.php @@ -0,0 +1,14 @@ + + + + + + + + {{ $slot }} + + @stack('scripts') + + + + diff --git a/portman/lib/Livewire/Features/SupportNavigate/test-views/layout.blade.php b/portman/lib/Livewire/Features/SupportNavigate/test-views/layout.blade.php new file mode 100644 index 00000000..e496e85d --- /dev/null +++ b/portman/lib/Livewire/Features/SupportNavigate/test-views/layout.blade.php @@ -0,0 +1,13 @@ + + + + + + + {{ $slot }} + + @stack('scripts') + + + + diff --git a/portman/lib/Livewire/Features/SupportNavigate/test-views/navbar-sidebar.blade.php b/portman/lib/Livewire/Features/SupportNavigate/test-views/navbar-sidebar.blade.php new file mode 100644 index 00000000..07c1ded7 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportNavigate/test-views/navbar-sidebar.blade.php @@ -0,0 +1,63 @@ + + + + + + + Document + + + + + + +
+ {{ $slot }} +
+ + + + + diff --git a/portman/lib/Livewire/Features/SupportNavigate/test-views/test-navigate-asset.js b/portman/lib/Livewire/Features/SupportNavigate/test-views/test-navigate-asset.js new file mode 100644 index 00000000..a9c56bdf --- /dev/null +++ b/portman/lib/Livewire/Features/SupportNavigate/test-views/test-navigate-asset.js @@ -0,0 +1,8 @@ + +if (! window._lw_dusk_asset_count) { + window._lw_dusk_asset_count = 1 +} else { + window._lw_dusk_asset_count++ +} + + diff --git a/portman/lib/Livewire/Features/SupportNavigate/test-views/tracked-layout.blade.php b/portman/lib/Livewire/Features/SupportNavigate/test-views/tracked-layout.blade.php new file mode 100644 index 00000000..420f6c6e --- /dev/null +++ b/portman/lib/Livewire/Features/SupportNavigate/test-views/tracked-layout.blade.php @@ -0,0 +1,13 @@ + + + + + + + {{ $slot }} + + @stack('scripts') + + + + diff --git a/portman/lib/Livewire/Features/SupportNestedComponentListeners/SupportNestedComponentListeners.php b/portman/lib/Livewire/Features/SupportNestedComponentListeners/SupportNestedComponentListeners.php new file mode 100644 index 00000000..0bea1638 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportNestedComponentListeners/SupportNestedComponentListeners.php @@ -0,0 +1,62 @@ + $value) { + if (str($key)->startsWith('@')) { + // any kebab-cased parameters passed in will have been converted to camelCase + // so we need to convert back to kebab to ensure events are valid in html + $fullEvent = str($key)->after('@')->kebab(); + $attributeKey = 'x-on:'.$fullEvent; + $attributeValue = "\$wire.\$parent.".$value; + + store($this->component)->push('attributes', $attributeValue, $attributeKey); + } + } + } + + public function render($view, $data) + { + return function ($html, $replaceHtml) { + $attributes = store($this->component)->get('attributes', false); + + if (! $attributes) return; + + $replaceHtml(Utils::insertAttributesIntoHtmlRoot($html, $attributes)); + }; + } + + public function dehydrate($context) + { + $attributes = store($this->component)->get('attributes', false); + + if (! $attributes) return; + + $attributes && $context->addMemo('attributes', $attributes); + } + + public function hydrate($memo) + { + if (! isset($memo['attributes'])) return; + + $attributes = $memo['attributes']; + + // Store the attributes for later dehydration... + store($this->component)->set('attributes', $attributes); + } +} diff --git a/portman/lib/Livewire/Features/SupportNestingComponents/SupportNestingComponents.php b/portman/lib/Livewire/Features/SupportNestingComponents/SupportNestingComponents.php new file mode 100644 index 00000000..54815299 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportNestingComponents/SupportNestingComponents.php @@ -0,0 +1,120 @@ +"; + + static::setParentChild($parent, $key, $tag, $childId); + + $hijack($finish($html)); + } + }); + + on('mount', function ($component, $params, $key, $parent) { + $start = null; + if ($parent && config('app.debug')) $start = microtime(true); + + static::setParametersToMatchingProperties($component, $params); + + return function ($html) use ($component, $key, $parent, $start) { + if ($parent) { + if (config('app.debug')) trigger('profile', 'child:'.$component->getId(), $parent->getId(), [$start, microtime(true)]); + + preg_match('/<([a-zA-Z0-9\-]*)/', $html, $matches, PREG_OFFSET_CAPTURE); + $tag = $matches[1][0]; + static::setParentChild($parent, $key, $tag, $component->getId()); + } + }; + }); + } + + function hydrate($memo) + { + $children = $memo['children']; + + static::setPreviouslyRenderedChildren($this->component, $children); + + $this->ifThisComponentIsAChildThatHasBeenRemovedByTheParent(function () { + // Let's skip its render so that we aren't wasting extra rendering time + // on a component that has already been thrown-away by its parent... + $this->component->skipRender(); + }); + } + + function dehydrate($context) + { + $skipRender = $this->storeGet('skipRender'); + + if ($skipRender) $this->keepRenderedChildren(); + + $this->storeRemovedChildrenToReferenceWhenThoseChildrenHydrateSoWeCanSkipTheirRenderAndAvoideUselessWork(); + + $context->addMemo('children', $this->getChildren()); + } + + function getChildren() { return $this->storeGet('children', []); } + function setChild($key, $tag, $id) { $this->storePush('children', [$tag, $id], $key); } + + static function setParentChild($parent, $key, $tag, $id) { store($parent)->push('children', [$tag, $id], $key); } + static function setPreviouslyRenderedChildren($component, $children) { store($component)->set('previousChildren', $children); } + static function hasPreviouslyRenderedChild($parent, $key) { + return array_key_exists($key, store($parent)->get('previousChildren', [])); + } + + static function getPreviouslyRenderedChild($parent, $key) + { + return store($parent)->get('previousChildren')[$key]; + } + + function keepRenderedChildren() + { + $this->storeSet('children', $this->storeGet('previousChildren')); + } + + static function setParametersToMatchingProperties($component, $params) + { + // Assign all public component properties that have matching parameters. + collect(array_intersect_key($params, Utils::getPublicPropertiesDefinedOnSubclass($component))) + ->each(function ($value, $property) use ($component) { + $component->{$property} = $value; + }); + } + + protected function storeRemovedChildrenToReferenceWhenThoseChildrenHydrateSoWeCanSkipTheirRenderAndAvoideUselessWork() + { + // Get a list of children that we're "removed" in this request... + $removedChildren = array_diff_key($this->storeGet('previousChildren', []), $this->getChildren()); + + foreach ($removedChildren as $key => $child) { + store()->push('removedChildren', $key, $child[1]); + } + } + + protected function ifThisComponentIsAChildThatHasBeenRemovedByTheParent($callback) + { + $removedChildren = store()->get('removedChildren', []); + + if (isset($removedChildren[$this->component->getId()])) { + $callback(); + + store()->unset('removedChildren', $this->component->getId()); + } + } +} diff --git a/portman/lib/Livewire/Features/SupportPageComponents/BaseLayout.php b/portman/lib/Livewire/Features/SupportPageComponents/BaseLayout.php new file mode 100644 index 00000000..8de9e7a6 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportPageComponents/BaseLayout.php @@ -0,0 +1,14 @@ +mount($this::class, $params); + }); + + $layoutConfig = $layoutConfig ?: new PageComponentConfig; + + $layoutConfig->normalizeViewNameAndParamsForBladeComponents(); + + $response = response(SupportPageComponents::renderContentsIntoLayout($html, $layoutConfig)); + + if (is_callable($layoutConfig->response)) { + call_user_func($layoutConfig->response, $response); + } + + return $response; + } +} diff --git a/portman/lib/Livewire/Features/SupportPageComponents/MissingLayoutException.php b/portman/lib/Livewire/Features/SupportPageComponents/MissingLayoutException.php new file mode 100644 index 00000000..317ab7ab --- /dev/null +++ b/portman/lib/Livewire/Features/SupportPageComponents/MissingLayoutException.php @@ -0,0 +1,13 @@ +view = $view ?: config('livewire.layout'); + $this->viewContext = new ViewContext; + } + + function mergeParams($toMerge) + { + $this->params = array_merge($toMerge, $this->params); + } + + function normalizeViewNameAndParamsForBladeComponents() + { + // If a user passes the class name of a Blade component to the + // layout macro (or uses inside their config), we need to + // convert it to it's "view" name so Blade doesn't break. + $view = $this->view; + $params = $this->params; + + $attributes = $params['attributes'] ?? []; + unset($params['attributes']); + + if (is_subclass_of($view, \Illuminate\View\Component::class)) { + $layout = app()->makeWith($view, $params); + $view = $layout->resolveView()->name(); + $params = array_merge($params, $layout->resolveView()->getData()); + } else { + $layout = new AnonymousComponent($view, $params); + } + + $layout->withAttributes($attributes); + + $params = array_merge($params, $layout->data()); + + $this->view = $view; + $this->params = $params; + + // Remove default slot if present... + if (isset($this->slots['default'])) unset($this->slots['default']); + + return $this; + } +} diff --git a/portman/lib/Livewire/Features/SupportPageComponents/SupportPageComponents.php b/portman/lib/Livewire/Features/SupportPageComponents/SupportPageComponents.php new file mode 100644 index 00000000..3dc58276 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportPageComponents/SupportPageComponents.php @@ -0,0 +1,246 @@ +layoutConfig)) $this->layoutConfig = new PageComponentConfig; + + $this->layoutConfig->mergeParams($data); + + return $this; + }); + + View::macro('section', function ($section) { + if (! isset($this->layoutConfig)) $this->layoutConfig = new PageComponentConfig; + + $this->layoutConfig->slotOrSection = $section; + + return $this; + }); + + View::macro('title', function ($title) { + if (! isset($this->layoutConfig)) $this->layoutConfig = new PageComponentConfig; + + $this->layoutConfig->mergeParams(['title' => $title]); + + return $this; + }); + + View::macro('slot', function ($slot) { + if (! isset($this->layoutConfig)) $this->layoutConfig = new PageComponentConfig; + + $this->layoutConfig->slotOrSection = $slot; + + return $this; + }); + + View::macro('extends', function ($view, $params = []) { + if (! isset($this->layoutConfig)) $this->layoutConfig = new PageComponentConfig; + + $this->layoutConfig->type = 'extends'; + $this->layoutConfig->slotOrSection = 'content'; + $this->layoutConfig->view = $view; + $this->layoutConfig->mergeParams($params); + + return $this; + }); + + View::macro('layout', function ($view, $params = []) { + if (! isset($this->layoutConfig)) $this->layoutConfig = new PageComponentConfig; + + $this->layoutConfig->type = 'component'; + $this->layoutConfig->slotOrSection = 'slot'; + $this->layoutConfig->view = $view; + $this->layoutConfig->mergeParams($params); + + return $this; + }); + + View::macro('response', function (callable $callback) { + if (! isset($this->layoutConfig)) $this->layoutConfig = new PageComponentConfig; + + $this->layoutConfig->response = $callback; + + return $this; + }); + } + + static function interceptTheRenderOfTheComponentAndRetreiveTheLayoutConfiguration($callback) + { + $layoutConfig = null; + $slots = []; + + // Only run this handler once for the parent-most component. Otherwise child components + // will run this handler too and override the configured layout... + $handler = once(function ($target, $view, $data) use (&$layoutConfig, &$slots) { + $layoutAttr = $target->getAttributes()->whereInstanceOf(BaseLayout::class)->first(); + $titleAttr = $target->getAttributes()->whereInstanceOf(BaseTitle::class)->first(); + + if ($layoutAttr) { + $view->layout($layoutAttr->name, $layoutAttr->params); + } + + if ($titleAttr) { + $view->title($titleAttr->content); + } + + $layoutConfig = $view->layoutConfig ?? new PageComponentConfig; + + return function ($html, $replace, $viewContext) use ($view, $layoutConfig) { + // Gather up any slots and sections declared in the component template and store them + // to be later forwarded into the layout component itself... + $layoutConfig->viewContext = $viewContext; + }; + }); + + on('render', $handler); + on('render.placeholder', $handler); + + $callback(); + + off('render', $handler); + off('render.placeholder', $handler); + + return $layoutConfig; + } + + static function gatherMountMethodParamsFromRouteParameters($component) + { + // This allows for route parameters like "slug" in /post/{slug}, + // to be passed into a Livewire component's mount method... + $route = request()->route(); + + if (! $route) return []; + + try { + $params = (new ImplicitRouteBinding(app())) + ->resolveAllParameters($route, new $component); + } catch (ModelNotFoundException $exception) { + if (method_exists($route,'getMissing') && $route->getMissing()) { + abort( + $route->getMissing()(request()) + ); + } + + throw $exception; + } + + return $params; + } + + static function renderContentsIntoLayout($content, $layoutConfig) + { + try { + if ($layoutConfig->type === 'component') { + return Blade::render(<<<'HTML' + viewContext->mergeIntoNewEnvironment($__env); ?> + + @component($layout->view, $layout->params) + @slot($layout->slotOrSection) + {!! $content !!} + @endslot + + viewContext->slots[-1] ?? [] as $name => $slot) { + $__env->slot($name, attributes: $slot->attributes->getAttributes()); + echo $slot->toHtml(); + $__env->endSlot(); + } + ?> + @endcomponent + HTML, [ + 'content' => $content, + 'layout' => $layoutConfig, + ]); + } else { + return Blade::render(<<<'HTML' + viewContext->mergeIntoNewEnvironment($__env); ?> + + @extends($layout->view, $layout->params) + + @section($layout->slotOrSection) + {!! $content !!} + @endsection + HTML, [ + 'content' => $content, + 'layout' => $layoutConfig, + ]); + } + } catch (\Illuminate\View\ViewException $e) { + $layout = $layoutConfig->view; + + if (str($e->getMessage())->startsWith('View ['.$layout.'] not found.')) { + throw new MissingLayoutException($layout); + } else { + throw $e; + } + } + } + + // This needs to exist so that authorization middleware (and other middleware) have access + // to implicit route bindings based on the Livewire page component. Otherwise, Laravel + // has no implicit binding references because the action is __invoke with no params + protected static function resolvePageComponentRouteBindings() + { + // This method was introduced into Laravel 10.37.1 for this exact purpose... + if (static::canSubstituteImplicitBindings()) { + app('router')->substituteImplicitBindingsUsing(function ($container, $route, $default) { + // If the current route is a Livewire page component... + if ($componentClass = static::routeActionIsAPageComponent($route)) { + // Resolve and set all page component parameters to the current route... + (new \Livewire\Drawer\ImplicitRouteBinding($container)) + ->resolveAllParameters($route, new $componentClass); + } else { + // Otherwise, run the default Laravel implicit binding system... + $default(); + } + }); + } + } + + public static function canSubstituteImplicitBindings() + { + return method_exists(app('router'), 'substituteImplicitBindingsUsing'); + } + + protected static function routeActionIsAPageComponent($route) + { + $action = $route->action; + + if (! $action) return false; + + $uses = $action['uses'] ?? false; + + if (! $uses) return; + + if (is_string($uses)) { + $class = str($uses)->before('@')->toString(); + $method = str($uses)->after('@')->toString(); + + if (is_subclass_of($class, \Livewire\Component::class) && $method === '__invoke') { + return $class; + } + } + + return false; + } +} diff --git a/portman/lib/Livewire/Features/SupportPagination/HandlesPagination.php b/portman/lib/Livewire/Features/SupportPagination/HandlesPagination.php new file mode 100644 index 00000000..1bc13081 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportPagination/HandlesPagination.php @@ -0,0 +1,74 @@ +paginators)->mapWithKeys(function ($page, $pageName) { + return ['paginators.'.$pageName => ['history' => true, 'as' => $pageName, 'keep' => false]]; + })->toArray(); + } + + public function getPage($pageName = 'page') + { + return $this->paginators[$pageName] ?? Paginator::resolveCurrentPage($pageName); + } + + public function previousPage($pageName = 'page') + { + $this->setPage(max(($this->paginators[$pageName] ?? 1) - 1, 1), $pageName); + } + + public function nextPage($pageName = 'page') + { + $this->setPage(($this->paginators[$pageName] ?? 1) + 1, $pageName); + } + + public function gotoPage($page, $pageName = 'page') + { + $this->setPage($page, $pageName); + } + + public function resetPage($pageName = 'page') + { + $this->setPage(1, $pageName); + } + + public function setPage($page, $pageName = 'page') + { + if (is_numeric($page)) { + $page = (int) ($page <= 0 ? 1 : $page); + } + + $beforePaginatorMethod = 'updatingPaginators'; + $afterPaginatorMethod = 'updatedPaginators'; + + $beforeMethod = 'updating' . ucfirst(Str::camel($pageName)); + $afterMethod = 'updated' . ucfirst(Str::camel($pageName)); + + if (method_exists($this, $beforePaginatorMethod)) { + $this->{$beforePaginatorMethod}($page, $pageName); + } + + if (method_exists($this, $beforeMethod)) { + $this->{$beforeMethod}($page, null); + } + + $this->paginators[$pageName] = $page; + + if (method_exists($this, $afterPaginatorMethod)) { + $this->{$afterPaginatorMethod}($page, $pageName); + } + + if (method_exists($this, $afterMethod)) { + $this->{$afterMethod}($page, null); + } + } +} diff --git a/portman/lib/Livewire/Features/SupportPagination/PaginationUrl.php b/portman/lib/Livewire/Features/SupportPagination/PaginationUrl.php new file mode 100644 index 00000000..2845b671 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportPagination/PaginationUrl.php @@ -0,0 +1,17 @@ +pushQueryStringEffect($context); + } +} diff --git a/portman/lib/Livewire/Features/SupportPagination/SupportPagination.php b/portman/lib/Livewire/Features/SupportPagination/SupportPagination.php new file mode 100644 index 00000000..01eccd91 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportPagination/SupportPagination.php @@ -0,0 +1,161 @@ +provide(function () { + $this->loadViewsFrom(__DIR__.'/views', 'livewire'); + + $paths = [__DIR__.'/views' => resource_path('views/vendor/livewire')]; + + $this->publishes($paths, 'livewire'); + $this->publishes($paths, 'livewire:pagination'); + }); + } + + protected $restoreOverriddenPaginationViews; + + function skip() + { + return ! in_array(WithPagination::class, class_uses_recursive($this->component)); + } + + function boot() + { + $this->setPathResolvers(); + + $this->setPageResolvers(); + + $this->overrideDefaultPaginationViews(); + } + + function destroy() + { + ($this->restoreOverriddenPaginationViews)(); + } + + function overrideDefaultPaginationViews() + { + $oldDefaultView = Paginator::$defaultView; + $oldDefaultSimpleView = Paginator::$defaultSimpleView; + + $this->restoreOverriddenPaginationViews = function () use ($oldDefaultView, $oldDefaultSimpleView) { + Paginator::defaultView($oldDefaultView); + Paginator::defaultSimpleView($oldDefaultSimpleView); + }; + + Paginator::defaultView($this->paginationView()); + Paginator::defaultSimpleView($this->paginationSimpleView()); + } + + protected function setPathResolvers() + { + // Setting the path resolver here on the default paginator also works for the cursor paginator... + Paginator::currentPathResolver(function () { + return Livewire::originalPath(); + }); + } + + protected function setPageResolvers() + { + CursorPaginator::currentCursorResolver(function ($pageName) { + $this->ensurePaginatorIsInitialized($pageName, defaultPage: ''); + + return Cursor::fromEncoded($this->component->paginators[$pageName]); + }); + + Paginator::currentPageResolver(function ($pageName) { + $this->ensurePaginatorIsInitialized($pageName); + + return (int) $this->component->paginators[$pageName]; + }); + } + + protected function ensurePaginatorIsInitialized($pageName, $defaultPage = 1) + { + if (isset($this->component->paginators[$pageName])) return; + + $queryStringDetails = $this->getQueryStringDetails($pageName); + + $this->component->paginators[$pageName] = $this->resolvePage($queryStringDetails['as'], $defaultPage); + + $shouldSkipUrlTracking = in_array( + WithoutUrlPagination::class, class_uses_recursive($this->component) + ); + + if ($shouldSkipUrlTracking) return; + + $this->addUrlHook($pageName, $queryStringDetails); + } + + protected function getQueryStringDetails($pageName) + { + $pageNameQueryString = data_get($this->getQueryString(), 'paginators.' . $pageName); + + $pageNameQueryString['as'] ??= $pageName; + $pageNameQueryString['history'] ??= true; + $pageNameQueryString['keep'] ??= false; + + return $pageNameQueryString; + } + + protected function resolvePage($alias, $default) + { + return request()->query($alias, $default); + } + + protected function addUrlHook($pageName, $queryStringDetails) + { + $key = 'paginators.' . $pageName; + $alias = $queryStringDetails['as']; + $history = $queryStringDetails['history']; + $keep = $queryStringDetails['keep']; + + $attribute = new PaginationUrl(as: $alias, history: $history, keep: $keep); + + $this->component->setPropertyAttribute($key, $attribute); + + // We need to manually call this in case it's a Lazy component, + // in which case the `mount()` lifecycle hook isn't called. + // This means it can be called twice, but that's fine... + $attribute->setPropertyFromQueryString(); + } + + protected function paginationView() + { + if (method_exists($this->component, 'paginationView')) { + return $this->component->paginationView(); + } + + return 'livewire::' . (property_exists($this->component, 'paginationTheme') ? invade($this->component)->paginationTheme : config('livewire.pagination_theme', 'tailwind')); + } + + protected function paginationSimpleView() + { + if (method_exists($this->component, 'paginationSimpleView')) { + return $this->component->paginationSimpleView(); + } + + return 'livewire::simple-' . (property_exists($this->component, 'paginationTheme') ? invade($this->component)->paginationTheme : config('livewire.pagination_theme', 'tailwind')); + } + + protected function getQueryString() + { + $supportQueryStringHook = ComponentHookRegistry::getHook($this->component, SupportQueryString::class); + + return $supportQueryStringHook->getQueryString(); + } +} diff --git a/portman/lib/Livewire/Features/SupportPagination/WithoutUrlPagination.php b/portman/lib/Livewire/Features/SupportPagination/WithoutUrlPagination.php new file mode 100644 index 00000000..db54d68e --- /dev/null +++ b/portman/lib/Livewire/Features/SupportPagination/WithoutUrlPagination.php @@ -0,0 +1,8 @@ + + @if ($paginator->hasPages()) + + @endif + diff --git a/portman/lib/Livewire/Features/SupportPagination/views/simple-bootstrap.blade.php b/portman/lib/Livewire/Features/SupportPagination/views/simple-bootstrap.blade.php new file mode 100644 index 00000000..44bdce01 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportPagination/views/simple-bootstrap.blade.php @@ -0,0 +1,53 @@ +@php +if (! isset($scrollTo)) { + $scrollTo = 'body'; +} + +$scrollIntoViewJsSnippet = ($scrollTo !== false) + ? << + @if ($paginator->hasPages()) + + @endif + diff --git a/portman/lib/Livewire/Features/SupportPagination/views/simple-tailwind.blade.php b/portman/lib/Livewire/Features/SupportPagination/views/simple-tailwind.blade.php new file mode 100644 index 00000000..d7f2560d --- /dev/null +++ b/portman/lib/Livewire/Features/SupportPagination/views/simple-tailwind.blade.php @@ -0,0 +1,56 @@ +@php +if (! isset($scrollTo)) { + $scrollTo = 'body'; +} + +$scrollIntoViewJsSnippet = ($scrollTo !== false) + ? << + @if ($paginator->hasPages()) + + @endif + diff --git a/portman/lib/Livewire/Features/SupportPagination/views/tailwind.blade.php b/portman/lib/Livewire/Features/SupportPagination/views/tailwind.blade.php new file mode 100644 index 00000000..9ad7f28a --- /dev/null +++ b/portman/lib/Livewire/Features/SupportPagination/views/tailwind.blade.php @@ -0,0 +1,126 @@ +@php +if (! isset($scrollTo)) { + $scrollTo = 'body'; +} + +$scrollIntoViewJsSnippet = ($scrollTo !== false) + ? << + @if ($paginator->hasPages()) + + @endif + diff --git a/portman/lib/Livewire/Features/SupportQueryString/BaseUrl.php b/portman/lib/Livewire/Features/SupportQueryString/BaseUrl.php new file mode 100644 index 00000000..97ef44ae --- /dev/null +++ b/portman/lib/Livewire/Features/SupportQueryString/BaseUrl.php @@ -0,0 +1,158 @@ +nullable = $this->determineNullability(); + + $this->setPropertyFromQueryString(); + } + + public function dehydrate($context) + { + if (! $context->mounting) return; + + $this->pushQueryStringEffect($context); + } + + protected function determineNullability() + { + // It's nullable if they passed it in like: #[Url(nullable: true)] + if ($this->nullable !== null) return $this->nullable; + + $reflectionClass = new ReflectionClass($this->getSubTarget() ?? $this->getComponent()); + + // It's nullable if there's a nullable typehint like: public ?string $foo; + if ($this->getSubName() && $reflectionClass->hasProperty($this->getSubName())) { + $property = $reflectionClass->getProperty($this->getSubName()); + + return $property->getType()?->allowsNull() ?? false; + } + + return false; + } + + public function setPropertyFromQueryString() + { + if ($this->as === null && $this->isOnFormObjectProperty()) { + $this->as = $this->getSubName(); + } + + $nonExistentValue = uniqid('__no_exist__', true); + + $initialValue = $this->getFromUrlQueryString($this->urlName(), $nonExistentValue); + + if ($initialValue === $nonExistentValue) return; + + $decoded = is_array($initialValue) + ? json_decode(json_encode($initialValue), true) + : json_decode($initialValue ?? '', true); + + // If only part of an array is present in the query string, + // we want to merge instead of override the value... + if (is_array($decoded) && is_array($original = $this->getValue())) { + $decoded = $this->recursivelyMergeArraysWithoutAppendingDuplicateValues($original, $decoded); + } + + // Handle empty strings differently depending on if this + // field is considered "nullable" by typehint or API. + if ($initialValue === null) { + $value = $this->nullable ? null : ''; + } else { + $value = $decoded === null ? $initialValue : $decoded; + } + + $this->setValue($value, $this->nullable); + } + + protected function recursivelyMergeArraysWithoutAppendingDuplicateValues(&$array1, &$array2) + { + $merged = $array1; + + foreach ($array2 as $key => &$value) { + if (is_array($value) && isset($merged[$key]) && is_array($merged[$key])) { + $merged[$key] = $this->recursivelyMergeArraysWithoutAppendingDuplicateValues($merged[$key], $value); + } else { + $merged[$key] = $value; + } + } + + return $merged; + } + + public function pushQueryStringEffect($context) + { + $queryString = [ + 'as' => $this->as, + 'use' => $this->history ? 'push' : 'replace', + 'alwaysShow' => $this->keep, + 'except' => $this->except, + ]; + + $context->pushEffect('url', $queryString, $this->getName()); + } + + public function isOnFormObjectProperty() + { + $subTarget = $this->getSubTarget(); + + return $subTarget && is_subclass_of($subTarget, Form::class); + } + + public function urlName() + { + return $this->as ?? $this->getName(); + } + + public function getFromUrlQueryString($name, $default = null) + { + if (! app('livewire')->isLivewireRequest()) { + $value = request()->query($this->urlName(), $default); + + // If the property is present in the querystring without a value, then Laravel returns + // the $default value. We want to return null in this case, so we can differentiate + // between "not present" and "present with no value". If the request is a Livewire + // request, we don't have that issue as we use PHP's parse_str function. + if (array_key_exists($name, request()->query()) && $value === $default) { + return null; + } + + return $value; + } + + // If this is a subsequent ajax request, we can't use Laravel's standard "request()->query()"... + return $this->getFromRefererUrlQueryString( + request()->header('Referer'), + $name, + $default + ); + } + + public function getFromRefererUrlQueryString($url, $key, $default = null) + { + $parsedUrl = parse_url($url ?? ''); + $query = []; + + if (isset($parsedUrl['query'])) { + parse_str($parsedUrl['query'], $query); + } + + return $query[$key] ?? $default; + } +} diff --git a/portman/lib/Livewire/Features/SupportQueryString/SupportQueryString.php b/portman/lib/Livewire/Features/SupportQueryString/SupportQueryString.php new file mode 100644 index 00000000..47d52e22 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportQueryString/SupportQueryString.php @@ -0,0 +1,62 @@ +getQueryString()) return; + + foreach ($queryString as $key => $value) { + $key = is_string($key) ? $key : $value; + $alias = $value['as'] ?? $key; + $history = $value['history'] ?? true; + $keep = $value['alwaysShow'] ?? $value['keep'] ?? false; + $except = $value['except'] ?? null; + + $this->component->setPropertyAttribute($key, new BaseUrl(as: $alias, history: $history, keep: $keep, except: $except)); + } + } + + public function getQueryString() + { + if (isset($this->queryString)) return $this->queryString; + + $component = $this->component; + + $componentQueryString = []; + + if (method_exists($component, 'queryString')) $componentQueryString = invade($component)->queryString(); + elseif (property_exists($component, 'queryString')) $componentQueryString = invade($component)->queryString; + + return $this->queryString = collect(class_uses_recursive($class = $component::class)) + ->map(function ($trait) use ($class, $component) { + $member = 'queryString' . class_basename($trait); + + if (method_exists($class, $member)) { + return invade($component)->{$member}(); + } + + if (property_exists($class, $member)) { + return invade($component)->{$member}; + } + + return []; + }) + ->values() + ->mapWithKeys(function ($value) { + return $value; + }) + ->merge($componentQueryString) + ->toArray(); + } +} diff --git a/portman/lib/Livewire/Features/SupportReactiveProps/BaseReactive.php b/portman/lib/Livewire/Features/SupportReactiveProps/BaseReactive.php new file mode 100644 index 00000000..594cc3e2 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportReactiveProps/BaseReactive.php @@ -0,0 +1,45 @@ +getName(); + + store($this->component)->push('reactiveProps', $property); + + $this->originalValueHash = crc32(json_encode($this->getValue())); + } + + public function hydrate() + { + if (SupportReactiveProps::hasPassedInProps($this->component->getId())) { + $updatedValue = SupportReactiveProps::getPassedInProp( + $this->component->getId(), $this->getName() + ); + + $this->setValue($updatedValue); + } + + $this->originalValueHash = crc32(json_encode($this->getValue())); + } + + public function dehydrate($context) + { + if ($this->originalValueHash !== crc32(json_encode($this->getValue()))) { + throw new CannotMutateReactivePropException($this->component->getName(), $this->getName()); + } + + $context->pushMemo('props', $this->getName()); + } +} diff --git a/portman/lib/Livewire/Features/SupportReactiveProps/CannotMutateReactivePropException.php b/portman/lib/Livewire/Features/SupportReactiveProps/CannotMutateReactivePropException.php new file mode 100644 index 00000000..ed1f5c9e --- /dev/null +++ b/portman/lib/Livewire/Features/SupportReactiveProps/CannotMutateReactivePropException.php @@ -0,0 +1,13 @@ + static::$pendingChildParams = []); + + on('mount.stub', function ($tag, $id, $params, $parent, $key) { + static::$pendingChildParams[$id] = $params; + }); + } + + static function hasPassedInProps($id) { + return isset(static::$pendingChildParams[$id]); + } + + static function getPassedInProp($id, $name) { + $params = static::$pendingChildParams[$id] ?? []; + + return $params[$name] ?? null; + } +} diff --git a/portman/lib/Livewire/Features/SupportRedirects/HandlesRedirects.php b/portman/lib/Livewire/Features/SupportRedirects/HandlesRedirects.php new file mode 100644 index 00000000..4ea4d793 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportRedirects/HandlesRedirects.php @@ -0,0 +1,36 @@ +set('redirect', $url); + + if ($navigate) store($this)->set('redirectUsingNavigate', true); + + $shouldSkipRender = ! config('livewire.render_on_redirect', false); + + $shouldSkipRender && $this->skipRender(); + } + + public function redirectRoute($name, $parameters = [], $absolute = true, $navigate = false) + { + $this->redirect(route($name, $parameters, $absolute), $navigate); + } + + public function redirectIntended($default = '/', $navigate = false) + { + $url = session()->pull('url.intended', $default); + + $this->redirect($url, $navigate); + } + + public function redirectAction($name, $parameters = [], $absolute = true, $navigate = false) + { + $this->redirect(action($name, $parameters, $absolute), $navigate); + } +} diff --git a/portman/lib/Livewire/Features/SupportRedirects/Redirector.php b/portman/lib/Livewire/Features/SupportRedirects/Redirector.php new file mode 100644 index 00000000..e89ee68c --- /dev/null +++ b/portman/lib/Livewire/Features/SupportRedirects/Redirector.php @@ -0,0 +1,46 @@ +component->redirect($this->generator->to($path, [], $secure)); + + return $this; + } + + public function away($path, $status = 302, $headers = []) + { + return $this->to($path, $status, $headers); + } + + public function with($key, $value = null) + { + $key = is_array($key) ? $key : [$key => $value]; + + foreach ($key as $k => $v) { + $this->session->flash($k, $v); + } + + return $this; + } + + public function component(Component $component) + { + $this->component = $component; + + return $this; + } + + public function response($to) + { + return $this->createRedirect($to, 302, []); + } +} diff --git a/portman/lib/Livewire/Features/SupportRedirects/SupportRedirects.php b/portman/lib/Livewire/Features/SupportRedirects/SupportRedirects.php new file mode 100644 index 00000000..e434c36f --- /dev/null +++ b/portman/lib/Livewire/Features/SupportRedirects/SupportRedirects.php @@ -0,0 +1,71 @@ +has('session.store')) { + session()->forget(session()->get('_flash.new')); + } + }); + + on('flush-state', function () { + static::$atLeastOneMountedComponentHasRedirected = false; + }); + } + + public function boot() + { + // Put Laravel's redirector aside and replace it with our own custom one. + static::$redirectorCacheStack[] = app('redirect'); + + app()->bind('redirect', function () { + $redirector = app(Redirector::class)->component($this->component); + + if (app()->has('session.store')) { + $redirector->setSession(app('session.store')); + } + + return $redirector; + }); + } + + public function dehydrate($context) + { + // Put the old redirector back into the container. + app()->instance('redirect', array_pop(static::$redirectorCacheStack)); + + $to = $this->storeGet('redirect'); + $usingNavigate = $this->storeGet('redirectUsingNavigate'); + + if (is_subclass_of($to, Component::class)) { + $to = url()->action($to); + } + + if ($to && ! app(HandleRequests::class)->isLivewireRequest()) { + abort(redirect($to)); + } + + if (! $to) return; + + $context->addEffect('redirect', $to); + $usingNavigate && $context->addEffect('redirectUsingNavigate', true); + + if (! $context->isMounting()) { + static::$atLeastOneMountedComponentHasRedirected = true; + } + } +} diff --git a/portman/lib/Livewire/Features/SupportRedirects/TestsRedirects.php b/portman/lib/Livewire/Features/SupportRedirects/TestsRedirects.php new file mode 100644 index 00000000..bf0ae31f --- /dev/null +++ b/portman/lib/Livewire/Features/SupportRedirects/TestsRedirects.php @@ -0,0 +1,74 @@ +action($uri); + } + + if (! app('livewire')->isLivewireRequest()) { + $this->lastState->getResponse()->assertRedirect($uri); + + return $this; + } + + PHPUnit::assertArrayHasKey( + 'redirect', + $this->effects, + 'Component did not perform a redirect.' + ); + + if (! is_null($uri)) { + PHPUnit::assertSame(url($uri), url($this->effects['redirect'])); + } + + return $this; + } + + public function assertRedirectContains($uri) + { + if (is_subclass_of($uri, Component::class)) { + $uri = url()->action($uri); + } + + if (! app('livewire')->isLivewireRequest()) { + $this->lastState->getResponse()->assertRedirectContains($uri); + + return $this; + } + + PHPUnit::assertArrayHasKey( + 'redirect', + $this->effects, + 'Component did not perform a redirect.' + ); + + PHPUnit::assertTrue( + Str::contains($this->effects['redirect'], $uri), 'Redirect location ['.$this->effects['redirect'].'] does not contain ['.$uri.'].' + ); + + return $this; + } + + public function assertRedirectToRoute($name, $parameters = []) + { + $uri = route($name, $parameters); + + return $this->assertRedirect($uri); + } + + public function assertNoRedirect() + { + PHPUnit::assertTrue(! isset($this->effects['redirect'])); + + return $this; + } +} diff --git a/portman/lib/Livewire/Features/SupportReleaseTokens/HandlesReleaseTokens.php b/portman/lib/Livewire/Features/SupportReleaseTokens/HandlesReleaseTokens.php new file mode 100644 index 00000000..1c318ff6 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportReleaseTokens/HandlesReleaseTokens.php @@ -0,0 +1,14 @@ +getClass($snapshot['memo']['name']); + + if (!isset($snapshot['memo']['release']) || $snapshot['memo']['release'] !== static::generate($componentClass)) { + throw new LivewireReleaseTokenMismatchException; + } + } + + static function generate($componentOrComponentClass): string + { + $livewireReleaseToken = static::$LIVEWIRE_RELEASE_TOKEN; + $appReleaseToken = app('config')->get('livewire.release_token', ''); + $componentReleaseToken = method_exists($componentOrComponentClass, 'releaseToken') ? $componentOrComponentClass::releaseToken() : ''; + + return $livewireReleaseToken . '-' . $appReleaseToken . '-' . $componentReleaseToken; + } +} \ No newline at end of file diff --git a/portman/lib/Livewire/Features/SupportReleaseTokens/SupportReleaseTokens.php b/portman/lib/Livewire/Features/SupportReleaseTokens/SupportReleaseTokens.php new file mode 100644 index 00000000..65b68b48 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportReleaseTokens/SupportReleaseTokens.php @@ -0,0 +1,23 @@ +addMemo('release', ReleaseToken::generate($component)); + }); + + // Verify the release token before verifying the snapshot checksum, as we don't want to trigger a checksum + // failure if the release token doesn't match as there may be intentional changes in the snapshot... + on('checksum.verify', function ($checksum, $snapshot) { + ReleaseToken::verify($snapshot); + }); + } +} diff --git a/portman/lib/Livewire/Features/SupportScriptsAndAssets/SupportScriptsAndAssets.php b/portman/lib/Livewire/Features/SupportScriptsAndAssets/SupportScriptsAndAssets.php new file mode 100644 index 00000000..3e8e9ef6 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportScriptsAndAssets/SupportScriptsAndAssets.php @@ -0,0 +1,170 @@ + $assets) { + if (! in_array($key, $alreadyRunAssetKeys)) { + + // These will get injected into the HTML if it's an initial page load... + static::$renderedAssets[$key] = $assets; + + $alreadyRunAssetKeys[] = $key; + } + } + } + + public static function getUniqueBladeCompileTimeKey() + { + // Rather than using random strings as compile-time keys for blade directives, + // we want something more detereminstic to protect against problems that arise + // from using load-balancers and such. + // Therefore, we create a key based on the currently compiling view path and + // number of already compiled directives here... + $viewPath = crc32(app('blade.compiler')->getPath() ?? ''); + + if (! isset(static::$countersByViewPath[$viewPath])) static::$countersByViewPath[$viewPath] = 0; + + $key = $viewPath.'-'.static::$countersByViewPath[$viewPath]; + + static::$countersByViewPath[$viewPath]++; + + return $key; + } + + static function provide() + { + on('flush-state', function () { + static::$alreadyRunAssetKeys = []; + static::$countersByViewPath = []; + static::$renderedAssets = []; + static::$nonLivewireAssets = []; + }); + + Blade::directive('script', function () { + $key = static::getUniqueBladeCompileTimeKey(); + + return << + PHP; + }); + + Blade::directive('endscript', function () { + return <<push('scripts', \$__output, \$__scriptKey) + ?> + PHP; + }); + + Blade::directive('assets', function () { + $key = static::getUniqueBladeCompileTimeKey(); + + return << + PHP; + }); + + Blade::directive('endassets', function () { + return <<push('assets', \$__output, \$__assetKey); + } else { + \Livewire\Features\SupportScriptsAndAssets\SupportScriptsAndAssets::\$nonLivewireAssets[\$__assetKey] = \$__output; + } + } + ?> + PHP; + }); + } + + function hydrate($memo) { + // Store the "scripts" and "assets" memos so they can be re-added later (persisted between requests)... + if (isset($memo['scripts'])) { + store($this->component)->set('forwardScriptsToDehydrateMemo', $memo['scripts']); + } + + if (isset($memo['assets'])) { + store($this->component)->set('forwardAssetsToDehydrateMemo', $memo['assets']); + } + } + + function dehydrate($context) + { + $alreadyRunScriptKeys = store($this->component)->get('forwardScriptsToDehydrateMemo', []); + + // Add any scripts to the payload that haven't been run yet for this component.... + foreach (store($this->component)->get('scripts', []) as $key => $script) { + if (! in_array($key, $alreadyRunScriptKeys)) { + $context->pushEffect('scripts', $script, $key); + $alreadyRunScriptKeys[] = $key; + } + } + + $context->addMemo('scripts', $alreadyRunScriptKeys); + + // Add any assets to the payload that haven't been run yet for the entire page... + + $alreadyRunAssetKeys = store($this->component)->get('forwardAssetsToDehydrateMemo', []); + + foreach (store($this->component)->get('assets', []) as $key => $assets) { + if (! in_array($key, $alreadyRunAssetKeys)) { + + // These will either get injected into the HTML if it's an initial page load + // or they will be added to the "assets" key in an ajax payload... + static::$renderedAssets[$key] = $assets; + + $alreadyRunAssetKeys[] = $key; + } + } + + $context->addMemo('assets', $alreadyRunAssetKeys); + } +} diff --git a/portman/lib/Livewire/Features/SupportScriptsAndAssets/non-livewire-asset.js b/portman/lib/Livewire/Features/SupportScriptsAndAssets/non-livewire-asset.js new file mode 100644 index 00000000..15bc962a --- /dev/null +++ b/portman/lib/Livewire/Features/SupportScriptsAndAssets/non-livewire-asset.js @@ -0,0 +1,2 @@ + +document.querySelector('[dusk="foo"]').textContent = 'non livewire evaluated' diff --git a/portman/lib/Livewire/Features/SupportScriptsAndAssets/test.js b/portman/lib/Livewire/Features/SupportScriptsAndAssets/test.js new file mode 100644 index 00000000..3dddc4c1 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportScriptsAndAssets/test.js @@ -0,0 +1,2 @@ + +document.querySelector('[dusk="foo"]').textContent = 'evaluated' diff --git a/portman/lib/Livewire/Features/SupportSession/BaseSession.php b/portman/lib/Livewire/Features/SupportSession/BaseSession.php new file mode 100644 index 00000000..0cd16c5a --- /dev/null +++ b/portman/lib/Livewire/Features/SupportSession/BaseSession.php @@ -0,0 +1,62 @@ +exists()) return; + + $fromSession = $this->read(); + + $this->setValue($fromSession); + } + + public function dehydrate($context) + { + $this->write(); + } + + protected function exists() + { + return Session::exists($this->key()); + } + + protected function read() + { + return Session::get($this->key()); + } + + protected function write() + { + Session::put($this->key(), $this->getValue()); + } + + protected function key() + { + if (! $this->key) { + return (string) 'lw' . crc32($this->component->getName() . $this->getName()); + } + + return self::replaceDynamicPlaceholders($this->key, $this->component); + } + + static function replaceDynamicPlaceholders($key, $component) + { + return preg_replace_callback('/\{(.*)\}/U', function ($matches) use ($component) { + return data_get($component, $matches[1], function () use ($matches) { + throw new \Exception('Unable to evaluate dynamic session key placeholder: '.$matches[0]); + }); + }, $key); + } +} diff --git a/portman/lib/Livewire/Features/SupportStreaming/HandlesStreaming.php b/portman/lib/Livewire/Features/SupportStreaming/HandlesStreaming.php new file mode 100644 index 00000000..c1567cba --- /dev/null +++ b/portman/lib/Livewire/Features/SupportStreaming/HandlesStreaming.php @@ -0,0 +1,15 @@ +stream($to, $content, $replace); + } +} diff --git a/portman/lib/Livewire/Features/SupportStreaming/SupportStreaming.php b/portman/lib/Livewire/Features/SupportStreaming/SupportStreaming.php new file mode 100644 index 00000000..ce31f987 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportStreaming/SupportStreaming.php @@ -0,0 +1,42 @@ + $name, 'content' => $content, 'replace' => $replace]); + } + + public static function ensureStreamResponseStarted() + { + if (static::$response) return; + + static::$response = response()->stream(null , 200, [ + 'Cache-Control' => 'no-cache', + 'Content-Type' => 'text/event-stream', + 'X-Accel-Buffering' => 'no', + 'X-Livewire-Stream' => true, + ]); + + static::$response->sendHeaders(); + } + + public static function streamContent($body) + { + echo json_encode(['stream' => true, 'body' => $body, 'endStream' => true]); + + if (ob_get_level() > 0) { + ob_flush(); + } + + flush(); + } +} diff --git a/portman/lib/Livewire/Features/SupportTeleporting/SupportTeleporting.php b/portman/lib/Livewire/Features/SupportTeleporting/SupportTeleporting.php new file mode 100644 index 00000000..f089c2e4 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportTeleporting/SupportTeleporting.php @@ -0,0 +1,20 @@ +">'; + }); + + Blade::directive('endteleport', function () { + return ''; + }); + } +} diff --git a/portman/lib/Livewire/Features/SupportTesting/ComponentState.php b/portman/lib/Livewire/Features/SupportTesting/ComponentState.php new file mode 100644 index 00000000..6c40b452 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportTesting/ComponentState.php @@ -0,0 +1,79 @@ +component; + } + + function getSnapshot() + { + return $this->snapshot; + } + + function getSnapshotData() + { + return $this->untupleify($this->snapshot['data']); + } + + function getEffects() + { + return $this->effects; + } + + function getView() + { + return $this->view; + } + + function getResponse() + { + return $this->response; + } + + function untupleify($payload) { + $value = Utils::isSyntheticTuple($payload) ? $payload[0] : $payload; + + if (is_array($value)) { + foreach ($value as $key => $child) { + $value[$key] = $this->untupleify($child); + } + } + + return $value; + } + + function getHtml($stripInitialData = false) + { + $html = $this->html; + + if ($stripInitialData) { + $removeMe = (string) str($html)->betweenFirst( + 'wire:snapshot="', '"' + ); + + $html = str_replace($removeMe, '', $html); + + $removeMe = (string) str($html)->betweenFirst( + 'wire:effects="', '"' + ); + + $html = str_replace($removeMe, '', $html); + } + + return $html; + } +} diff --git a/portman/lib/Livewire/Features/SupportTesting/DuskBrowserMacros.php b/portman/lib/Livewire/Features/SupportTesting/DuskBrowserMacros.php new file mode 100644 index 00000000..49504069 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportTesting/DuskBrowserMacros.php @@ -0,0 +1,660 @@ +resolver->format($selector); + + $actual = $this->resolver->findOrFail($selector)->getAttribute($attribute); + + PHPUnit::assertNull( + $actual, + "Did not see expected attribute [{$attribute}] within element [{$fullSelector}]." + ); + + return $this; + }; + } + + public function assertNotVisible() + { + return function ($selector) { + /** @var \Laravel\Dusk\Browser $this */ + $fullSelector = $this->resolver->format($selector); + + PHPUnit::assertFalse( + $this->resolver->findOrFail($selector)->isDisplayed(), + "Element [{$fullSelector}] is visible." + ); + + return $this; + }; + } + + public function assertNotPresent() + { + return function ($selector) { + /** @var \Laravel\Dusk\Browser $this */ + $fullSelector = $this->resolver->format($selector); + + PHPUnit::assertTrue( + is_null($this->resolver->find($selector)), + "Element [{$fullSelector}] is present." + ); + + return $this; + }; + } + + public function assertHasClass() + { + return function ($selector, $className) { + /** @var \Laravel\Dusk\Browser $this */ + $fullSelector = $this->resolver->format($selector); + + PHPUnit::assertContains( + $className, + explode(' ', $this->attribute($selector, 'class')), + "Element [{$fullSelector}] missing class [{$className}]." + ); + + return $this; + }; + } + + public function assertScript() + { + return function ($js, $expects = true) { + /** @var \Laravel\Dusk\Browser $this */ + PHPUnit::assertEquals($expects, head($this->script( + str($js)->start('return ') + ))); + + return $this; + }; + } + + public function runScript() + { + return function ($js) { + /** @var \Laravel\Dusk\Browser $this */ + $this->script([$js]); + + return $this; + }; + } + + public function scrollTo() + { + return function ($selector) { + $this->browser->scrollTo($selector); + return $this; + }; + } + + public function assertNotInViewPort() + { + return function ($selector) { + /** @var \Laravel\Dusk\Browser $this */ + return $this->assertInViewPort($selector, invert: true); + }; + } + + public function assertInViewPort() + { + return function ($selector, $invert = false) { + /** @var \Laravel\Dusk\Browser $this */ + + $fullSelector = $this->resolver->format($selector); + + $result = $this->script( + 'const rect = document.querySelector(\''.$fullSelector.'\').getBoundingClientRect(); + return ( + rect.top >= 0 && + rect.left >= 0 && + rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && + rect.right <= (window.innerWidth || document.documentElement.clientWidth) + );', + $selector + )[0]; + + PHPUnit::assertEquals($invert ? false : true, $result); + + return $this; + }; + } + + public function assertClassMissing() + { + return function ($selector, $className) { + /** @var \Laravel\Dusk\Browser $this */ + $fullSelector = $this->resolver->format($selector); + + PHPUnit::assertNotContains( + $className, + explode(' ', $this->attribute($selector, 'class')), + "Element [{$fullSelector}] has class [{$className}]." + ); + + return $this; + }; + } + + public function waitForLivewireToLoad() + { + return function () { + /** @var \Laravel\Dusk\Browser $this */ + return $this->waitUsing(6, 25, function () { + return $this->driver->executeScript('return !! window.Livewire.initialRenderIsFinished'); + }); + }; + } + + public function waitForLivewire() + { + return function ($callback = null) { + /** @var \Laravel\Dusk\Browser $this */ + $id = str()->random(); + + $this->script([ + "window.duskIsWaitingForLivewireRequest{$id} = true", + "window.Livewire.hook('request', ({ respond, succeed, fail }) => { + window.duskIsWaitingForLivewireRequest{$id} = true + + let handle = () => { + queueMicrotask(() => { + console.log('test') + delete window.duskIsWaitingForLivewireRequest{$id} + }) + } + + succeed(handle) + fail(handle) + })", + ]); + + if ($callback) { + $callback($this); + + return $this->waitUsing(6, 25, function () use ($id) { + return $this->driver->executeScript("return window.duskIsWaitingForLivewireRequest{$id} === undefined"); + }, 'Livewire request was never triggered'); + } + + // If no callback is passed, make ->waitForLivewire a higher-order method. + return new class($this, $id) { + protected $browser; + protected $id; + + public function __construct($browser, $id) { $this->browser = $browser; $this->id = $id; } + + public function __call($method, $params) + { + return tap($this->browser->{$method}(...$params), function ($browser) { + $browser->waitUsing(6, 25, function () use ($browser) { + return $browser->driver->executeScript("return window.duskIsWaitingForLivewireRequest{$this->id} === undefined"); + }, 'Livewire request was never triggered'); + }); + } + }; + }; + } + + public function waitForNoLivewire() + { + return function ($callback = null) { + /** @var \Laravel\Dusk\Browser $this */ + $id = str()->random(); + + $this->script([ + "window.duskIsWaitingForLivewireRequest{$id} = true", + "window.Livewire.hook('request', ({ respond, succeed, fail }) => { + window.duskIsWaitingForLivewireRequest{$id} = true + + let handle = () => { + queueMicrotask(() => { + delete window.duskIsWaitingForLivewireRequest{$id} + }) + } + + succeed(handle) + fail(handle) + })", + ]); + + if ($callback) { + $callback($this); + + return $this->waitUsing(6, 25, function () use ($id) { + return $this->driver->executeScript("return window.duskIsWaitingForLivewireRequest{$id}"); + }, 'Livewire request was triggered'); + } + + // If no callback is passed, make ->waitForNoLivewire a higher-order method. + return new class($this, $id) { + protected $browser; + protected $id; + + public function __construct($browser, $id) { $this->browser = $browser; $this->id = $id; } + + public function __call($method, $params) + { + return tap($this->browser->{$method}(...$params), function ($browser) { + $browser->waitUsing(6, 25, function () use ($browser) { + return $browser->driver->executeScript("return window.duskIsWaitingForLivewireRequest{$this->id}"); + }, 'Livewire request was triggered'); + }); + } + }; + }; + } + + public function waitForNavigate() + { + return function ($callback = null) { + /** @var \Laravel\Dusk\Browser $this */ + $id = str()->random(); + + $this->script([ + "window.duskIsWaitingForLivewireNavigate{$id} = true", + "window.handler{$id} = () => { + window.duskIsWaitingForLivewireNavigate{$id} = true + + document.removeEventListener('livewire:navigated', window.handler{$id}) + + queueMicrotask(() => { + delete window.duskIsWaitingForLivewireNavigate{$id} + }) + }", + "document.addEventListener('livewire:navigated', window.handler{$id})", + ]); + + if ($callback) { + $callback($this); + + return $this->waitUsing(6, 25, function () use ($id) { + return $this->driver->executeScript("return window.duskIsWaitingForLivewireNavigate{$id} === undefined"); + }, 'Livewire navigate was never triggered'); + } + + // If no callback is passed, make ->waitForNavigate a higher-order method. + return new class($this, $id) { + protected $browser; + protected $id; + public function __construct($browser, $id) { $this->browser = $browser; $this->id = $id; } + + public function __call($method, $params) + { + return tap($this->browser->{$method}(...$params), function ($browser) { + $browser->waitUsing(6, 25, function () use ($browser) { + return $browser->driver->executeScript("return window.duskIsWaitingForLivewireNavigate{$this->id} === undefined"); + }, 'Livewire navigate was never triggered'); + }); + } + }; + }; + } + + public function waitForNavigateRequest() + { + return function ($callback = null) { + /** @var \Laravel\Dusk\Browser $this */ + $id = str()->random(); + + $this->script([ + "window.duskIsWaitingForLivewireNavigateRequestStarted{$id} = false", + "window.duskIsWaitingForLivewireNavigateRequestFinished{$id} = true", + 'let cleanupRequest = () => {}', + "cleanupRequest = Livewire.hook('navigate.request', () => { + window.duskIsWaitingForLivewireNavigateRequestStarted{$id} = true + + cleanupRequest() + })", + "window.handler{$id} = () => { + if (! window.duskIsWaitingForLivewireNavigateRequestStarted{$id}) { + return + } + + window.duskIsWaitingForLivewireNavigateRequestFinished{$id} = true + + document.removeEventListener('livewire:navigated', window.handler{$id}) + + queueMicrotask(() => { + delete window.duskIsWaitingForLivewireNavigateRequestStarted{$id} + delete window.duskIsWaitingForLivewireNavigateRequestFinished{$id} + }) + }", + "document.addEventListener('livewire:navigated', window.handler{$id})", + ]); + + if ($callback) { + $callback($this); + + return $this->waitUsing(6, 25, function () use ($id) { + return $this->driver->executeScript("return window.duskIsWaitingForLivewireNavigateRequestFinished{$id} === undefined"); + }, 'Livewire navigate request was never completed'); + } + + // If no callback is passed, make ->waitForNavigate a higher-order method. + return new class($this, $id) + { + protected $browser; + protected $id; + + public function __construct($browser, $id) + { + $this->browser = $browser; + $this->id = $id; + } + + public function __call($method, $params) + { + return tap($this->browser->{$method}(...$params), function ($browser) { + $browser->waitUsing(6, 25, function () use ($browser) { + return $browser->driver->executeScript("return window.duskIsWaitingForLivewireNavigateRequestFinished{$this->id} === undefined"); + }, 'Livewire navigate request was never completed'); + }); + } + }; + }; + } + + public function waitForNoNavigateRequest() + { + return function ($callback = null) { + /** @var \Laravel\Dusk\Browser $this */ + $id = str()->random(); + + $this->script([ + "window.duskIsWaitingForLivewireNavigateRequestStarted{$id} = true", + 'let cleanupRequest = () => {}', + "cleanupRequest = Livewire.hook('navigate.request', () => { + window.duskIsWaitingForLivewireNavigateRequestStarted{$id} = true + + cleanupRequest() + + queueMicrotask(() => { + delete window.duskIsWaitingForLivewireNavigateRequestStarted{$id} + }) + })", + ]); + + if ($callback) { + $callback($this); + + return $this->waitUsing(6, 25, function () use ($id) { + return $this->driver->executeScript("return window.duskIsWaitingForLivewireNavigateRequestStarted{$id}"); + }, 'Livewire navigate request was completed'); + } + + // If no callback is passed, make ->waitForNavigate a higher-order method. + return new class($this, $id) + { + protected $browser; + protected $id; + + public function __construct($browser, $id) + { + $this->browser = $browser; + $this->id = $id; + } + + public function __call($method, $params) + { + return tap($this->browser->{$method}(...$params), function ($browser) { + $browser->waitUsing(6, 25, function () use ($browser) { + return $browser->driver->executeScript("return window.duskIsWaitingForLivewireNavigateRequestStarted{$this->id}"); + }, 'Livewire navigate request was completed'); + }); + } + }; + }; + } + + public function waitForNavigatePrefetchRequest() + { + return function ($callback = null) { + /** @var \Laravel\Dusk\Browser $this */ + $id = str()->random(); + + $this->script([ + "window.duskIsWaitingForLivewireNavigatePrefetchRequest{$id} = true", + 'let cleanupPrefetchRequest = () => {}', + "cleanupPrefetchRequest = Livewire.hook('navigate.request', () => { + window.duskIsWaitingForLivewireNavigatePrefetchRequest{$id} = true + + cleanupPrefetchRequest() + + queueMicrotask(() => { + delete window.duskIsWaitingForLivewireNavigatePrefetchRequest{$id} + }) + })", + ]); + + if ($callback) { + $callback($this); + + return $this->waitUsing(6, 25, function () use ($id) { + return $this->driver->executeScript("return window.duskIsWaitingForLivewireNavigatePrefetchRequest{$id} === undefined"); + }, 'Livewire navigate prefetch request was never triggered'); + } + + // If no callback is passed, make ->waitForNavigatePrefetchRequest a higher-order method. + return new class($this, $id) + { + protected $browser; + protected $id; + + public function __construct($browser, $id) + { + $this->browser = $browser; + $this->id = $id; + } + + public function __call($method, $params) + { + return tap($this->browser->{$method}(...$params), function ($browser) { + $browser->waitUsing(6, 25, function () use ($browser) { + return $browser->driver->executeScript("return window.duskIsWaitingForLivewireNavigatePrefetchRequest{$this->id} === undefined"); + }, 'Livewire navigate prefetch request was never triggered'); + }); + } + }; + }; + } + + public function waitForNoNavigatePrefetchRequest() + { + // 60ms is the minimum delay for a hover event to trigger a prefetch plus a buffer... + return function ($callback = null, $prefetchDelay = 70) { + /** @var \Laravel\Dusk\Browser $this */ + $id = str()->random(); + + $this->script([ + "window.duskIsWaitingForLivewireNavigatePrefetchRequest{$id} = true", + "Livewire.hook('navigate.request', () => { + window.duskIsWaitingForLivewireNavigatePrefetchRequest{$id} = true + + queueMicrotask(() => { + delete window.duskIsWaitingForLivewireNavigatePrefetchRequest{$id} + }) + })", + ]); + + if ($callback) { + $callback($this); + + // Wait for the specified prefetch delay before checking + $this->pause($prefetchDelay); + + return $this->waitUsing(6, 25, function () use ($id) { + return $this->driver->executeScript("return window.duskIsWaitingForLivewireNavigatePrefetchRequest{$id}"); + }, 'Livewire navigate prefetch request was triggered'); + } + + // If no callback is passed, make ->waitForNoNavigatePrefetchRequest a higher-order method. + return new class($this, $id, $prefetchDelay) + { + protected $browser; + protected $id; + protected $prefetchDelay; + + public function __construct($browser, $id, $prefetchDelay) + { + $this->browser = $browser; + $this->id = $id; + $this->prefetchDelay = $prefetchDelay; + } + + public function __call($method, $params) + { + return tap($this->browser->{$method}(...$params), function ($browser) { + // Wait for the specified prefetch delay before checking + $browser->pause($this->prefetchDelay); + + $browser->waitUsing(6, 25, function () use ($browser) { + return $browser->driver->executeScript("return window.duskIsWaitingForLivewireNavigatePrefetchRequest{$this->id}"); + }, 'Livewire navigate prefetch request was triggered'); + }); + } + }; + }; + } + + public function online() + { + return function () { + /** @var \Laravel\Dusk\Browser $this */ + return tap($this)->script("window.dispatchEvent(new Event('online'))"); + }; + } + + public function offline() + { + return function () { + /** @var \Laravel\Dusk\Browser $this */ + return tap($this)->script("window.dispatchEvent(new Event('offline'))"); + }; + } + + public function selectMultiple() + { + return function ($field, $values = []) { + $element = $this->resolver->resolveForSelection($field); + + $options = $element->findElements(WebDriverBy::tagName('option')); + + if (empty($values)) { + $maxSelectValues = sizeof($options) - 1; + $minSelectValues = rand(0, $maxSelectValues); + foreach (range($minSelectValues, $maxSelectValues) as $optValue) { + $options[$optValue]->click(); + } + } else { + foreach ($options as $option) { + $optValue = (string)$option->getAttribute('value'); + if (in_array($optValue, $values)) { + $option->click(); + } + } + } + + return $this; + }; + } + + public function assertConsoleLogHasWarning() + { + return function($expectedMessage){ + $logs = $this->driver->manage()->getLog('browser'); + + $containsError = false; + + foreach ($logs as $log) { + if (! isset($log['message']) || ! isset($log['level']) || $log['level'] !== 'WARNING') continue; + + + if(str($log['message'])->contains($expectedMessage)) { + $containsError = true; + } + } + + PHPUnit::assertTrue($containsError, "Console log error message \"{$expectedMessage}\" not found"); + + return $this; + }; + } + + public function assertConsoleLogMissingWarning() + { + return function($expectedMessage){ + $logs = $this->driver->manage()->getLog('browser'); + + $containsError = false; + + foreach ($logs as $log) { + if (! isset($log['message']) || ! isset($log['level']) || $log['level'] !== 'WARNING') continue; + + + if(str($log['message'])->contains($expectedMessage)) { + $containsError = true; + } + } + + PHPUnit::assertFalse($containsError, "Console log error message \"{$expectedMessage}\" was found"); + + return $this; + }; + } + + public function assertConsoleLogHasNoErrors() + { + return function(){ + $logs = $this->driver->manage()->getLog('browser'); + + $errors = []; + foreach ($logs as $log) { + if (! isset($log['message']) || ! isset($log['level']) || $log['level'] !== 'SEVERE') continue; + + // Ignore favicon.ico + if(str($log['message'])->contains('favicon.ico')) continue; + + $errors[] = $log['message']; + } + + PHPUnit::assertEmpty($errors, "Console log contained errors: " . implode(", ", $errors)); + + return $this; + }; + } + + public function assertConsoleLogHasErrors() + { + return function(){ + $logs = $this->driver->manage()->getLog('browser'); + + $errors = []; + foreach ($logs as $log) { + if (! isset($log['message']) || ! isset($log['level']) || $log['level'] !== 'SEVERE') continue; + + // Ignore favicon.ico + if(str($log['message'])->contains('favicon.ico')) continue; + + $errors[] = $log['message']; + } + + PHPUnit::assertNotEmpty($errors, "Console log contained no errors"); + + return $this; + }; + } +} diff --git a/portman/lib/Livewire/Features/SupportTesting/DuskTestable.php b/portman/lib/Livewire/Features/SupportTesting/DuskTestable.php new file mode 100644 index 00000000..5aadc982 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportTesting/DuskTestable.php @@ -0,0 +1,195 @@ +middleware('web'); + + on('browser.testCase.setUp', function ($testCase) { + static::$currentTestCase = $testCase; + static::$isTestProcess = true; + + $tweakApplication = $testCase::tweakApplicationHook(); + + invade($testCase)->beforeServingApplication(function ($app, $config) use ($tweakApplication) { + $config->set('app.debug', true); + + if (is_callable($tweakApplication)) $tweakApplication(); + + static::loadTestComponents(); + }); + }); + + on('browser.testCase.tearDown', function () { + static::wipeRuntimeComponentRegistration(); + + static::$browser && static::$browser->quit(); + + static::$currentTestCase = null; + }); + + if (isset($_SERVER['CI']) && class_exists(\Orchestra\Testbench\Dusk\Options::class)) { + \Orchestra\Testbench\Dusk\Options::withoutUI(); + } + + \Laravel\Dusk\Browser::mixin(new DuskBrowserMacros); + } + + /** + * @return Browser + */ + static function create($components, $params = [], $queryParams = []) + { + if (static::$shortCircuitCreateCall) { + throw new class ($components) extends \Exception { + public $components; + public $isDuskShortcircuit = true; + function __construct($components) { + $this->components = $components; + } + }; + } + + $components = (array) $components; + + $firstComponent = array_shift($components); + + $id = 'a'.str()->random(10); + + $components = [$id => $firstComponent, ...$components]; + + return static::createBrowser($id, $components, $params, $queryParams)->visit('/livewire-dusk/'.$id.'?'.Arr::query($queryParams)); + } + + static function createBrowser($id, $components, $params = [], $queryParams = []) + { + if (static::$shortCircuitCreateCall) { + throw new class ($components) extends \Exception { + public $components; + public $isDuskShortcircuit = true; + function __construct($components) { + $this->components = $components; + } + }; + } + + [$class, $method] = static::findTestClassAndMethodThatCalledThis(); + + static::registerComponentsForNextTest([$id, $class, $method]); + + $testCase = invade(static::$currentTestCase); + + return static::$browser = $testCase->newBrowser($testCase->createWebDriver()); + } + + static function actingAs(\Illuminate\Contracts\Auth\Authenticatable $user, $driver = null) + { + // + } + + static function findTestClassAndMethodThatCalledThis() + { + $traces = debug_backtrace(options: DEBUG_BACKTRACE_IGNORE_ARGS, limit: 10); + + foreach ($traces as $trace) { + if (is_subclass_of($trace['class'], TestCase::class)) { + return [$trace['class'], $trace['function']]; + } + } + + throw new \Exception; + } + + static function loadTestComponents() + { + if (static::$isTestProcess) return; + + $tmp = __DIR__ . '/_runtime_components.json'; + + if (file_exists($tmp)) { + // We can't just "require" this file because of race conditions... + [$id, $testClass, $method] = json_decode(file_get_contents($tmp), associative: true); + + if (! method_exists($testClass, $method)) return; + + static::$shortCircuitCreateCall = true; + + $components = null; + + try { + if (\Orchestra\Testbench\phpunit_version_compare('10.0', '>=')) { + (new $testClass($method))->$method(); + } else { + (new $testClass())->$method(); + } + } catch (\Exception $e) { + if (! $e->isDuskShortcircuit) throw $e; + $components = $e->components; + } + + $components = is_array($components) ? $components : [$components]; + + $firstComponent = array_shift($components); + + $components = [$id => $firstComponent, ...$components]; + + static::$shortCircuitCreateCall = false; + + foreach ($components as $name => $class) { + if (is_object($class)) $class = $class::class; + + if (is_numeric($name)) { + app('livewire')->component($class); + } else { + app('livewire')->component($name, $class); + } + } + } + } + + static function registerComponentsForNextTest($components) + { + $tmp = __DIR__ . '/_runtime_components.json'; + + file_put_contents($tmp, json_encode($components, JSON_PRETTY_PRINT)); + } + + static function wipeRuntimeComponentRegistration() + { + $tmp = __DIR__ . '/_runtime_components.json'; + + file_exists($tmp) && unlink($tmp); + } + + function breakIntoATinkerShell($browsers, $e) + { + $sh = new \Psy\Shell(); + + $sh->add(new \Laravel\Dusk\Console\DuskCommand($this, $e)); + + $sh->setScopeVariables([ + 'browsers' => $browsers, + ]); + + $sh->addInput('dusk'); + + $sh->setBoundObject($this); + + $sh->run(); + + return $sh->getScopeVariables(false); + } +} diff --git a/portman/lib/Livewire/Features/SupportTesting/InitialRender.php b/portman/lib/Livewire/Features/SupportTesting/InitialRender.php new file mode 100644 index 00000000..5453f304 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportTesting/InitialRender.php @@ -0,0 +1,72 @@ +makeInitialRequest($name, $params, $fromQueryString, $cookies, $headers); + } + + function makeInitialRequest($name, $params, $fromQueryString = [], $cookies = [], $headers = []) + { + $uri = '/livewire-unit-test-endpoint/'.str()->random(20); + + $this->registerRouteBeforeExistingRoutes($uri, function () use ($name, $params) { + return \Illuminate\Support\Facades\Blade::render('@livewire($name, $params)', [ + 'name' => $name, + 'params' => $params, + ]); + }); + + [$response, $componentInstance, $componentView] = $this->extractComponentAndBladeView(function () use ($uri, $fromQueryString, $cookies, $headers) { + return $this->requestBroker->temporarilyDisableExceptionHandlingAndMiddleware(function ($requestBroker) use ($uri, $fromQueryString, $cookies, $headers) { + return $requestBroker->addHeaders($headers)->call('GET', $uri, $fromQueryString, $cookies); + }); + }); + + app('livewire')->flushState(); + + $html = $response->getContent(); + + // Set "original" to Blade view for assertions like "assertViewIs()"... + $response->original = $componentView; + + $snapshot = Utils::extractAttributeDataFromHtml($html, 'wire:snapshot'); + $effects = Utils::extractAttributeDataFromHtml($html, 'wire:effects'); + + return new ComponentState($componentInstance, $response, $componentView, $html, $snapshot, $effects); + } + + private function registerRouteBeforeExistingRoutes($path, $closure) + { + // To prevent this route from overriding wildcard routes registered within the application, + // We have to make sure that this route is registered before other existing routes. + $livewireTestingRoute = new \Illuminate\Routing\Route(['GET', 'HEAD'], $path, $closure); + + $existingRoutes = app('router')->getRoutes(); + + // Make an empty collection. + $runningCollection = new \Illuminate\Routing\RouteCollection; + + // Add this testing route as the first one. + $runningCollection->add($livewireTestingRoute); + + // Now add the existing routes after it. + foreach ($existingRoutes as $route) { + $runningCollection->add($route); + } + + // Now set this route collection as THE route collection for the app. + app('router')->setRoutes($runningCollection); + } +} diff --git a/portman/lib/Livewire/Features/SupportTesting/MakesAssertions.php b/portman/lib/Livewire/Features/SupportTesting/MakesAssertions.php new file mode 100644 index 00000000..b38418cd --- /dev/null +++ b/portman/lib/Livewire/Features/SupportTesting/MakesAssertions.php @@ -0,0 +1,198 @@ +html($stripInitialData) + ); + } + + return $this; + } + + function assertDontSee($values, $escape = true, $stripInitialData = true) + { + foreach (Arr::wrap($values) as $value) { + PHPUnit::assertStringNotContainsString( + $escape ? e($value): $value, + $this->html($stripInitialData) + ); + } + + return $this; + } + + function assertSeeHtml($values) + { + foreach (Arr::wrap($values) as $value) { + PHPUnit::assertStringContainsString( + $value, + $this->html() + ); + } + + return $this; + } + + function assertSeeHtmlInOrder($values) + { + PHPUnit::assertThat( + $values, + new SeeInOrder($this->html()) + ); + + return $this; + } + + function assertDontSeeHtml($values) + { + foreach (Arr::wrap($values) as $value) { + PHPUnit::assertStringNotContainsString( + $value, + $this->html() + ); + } + + return $this; + } + + function assertSeeText($value, $escape = true) + { + $value = Arr::wrap($value); + + $values = $escape ? array_map('e', ($value)) : $value; + + $content = $this->html(); + + tap(strip_tags($content), function ($content) use ($values) { + foreach ($values as $value) { + PHPUnit::assertStringContainsString((string) $value, $content); + } + }); + + return $this; + } + + function assertDontSeeText($value, $escape = true) + { + $value = Arr::wrap($value); + + $values = $escape ? array_map('e', ($value)) : $value; + + $content = $this->html(); + + tap(strip_tags($content), function ($content) use ($values) { + foreach ($values as $value) { + PHPUnit::assertStringNotContainsString((string) $value, $content); + } + }); + + return $this; + } + + function assertSet($name, $value, $strict = false) + { + $actual = $this->get($name); + + if (! is_string($value) && is_callable($value)) { + PHPUnit::assertTrue($value($actual)); + } else { + $strict ? PHPUnit::assertSame($value, $actual) : PHPUnit::assertEquals($value, $actual); + } + + return $this; + } + + function assertNotSet($name, $value, $strict = false) + { + $actual = $this->get($name); + + $strict ? PHPUnit::assertNotSame($value, $actual) : PHPUnit::assertNotEquals($value, $actual); + + return $this; + } + + function assertSetStrict($name, $value) + { + $this->assertSet($name, $value, true); + + return $this; + } + + function assertNotSetStrict($name, $value) + { + $this->assertNotSet($name, $value, true); + + return $this; + } + + function assertCount($name, $value) + { + PHPUnit::assertCount($value, $this->get($name)); + + return $this; + } + + function assertSnapshotSet($name, $value, $strict = false) + { + $data = $this->lastState->getSnapshotData(); + + if (is_callable($value)) { + PHPUnit::assertTrue($value(data_get($data, $name))); + } else { + $strict ? PHPUnit::assertSame($value, data_get($data, $name)) : PHPUnit::assertEquals($value, data_get($data, $name)); + } + + return $this; + } + + function assertSnapshotNotSet($name, $value, $strict = false) + { + $data = $this->lastState->getSnapshotData(); + + if (is_callable($value)) { + PHPUnit::assertFalse($value(data_get($data, $name))); + } else { + $strict ? PHPUnit::assertNotSame($value, data_get($data, $name)) : PHPUnit::assertNotEquals($value, data_get($data, $name)); + } + + return $this; + } + + function assertSnapshotSetStrict($name, $value) + { + $this->assertSnapshotSet($name, $value, true); + + return $this; + } + + function assertSnapshotNotSetStrict($name, $value) + { + $this->assertSnapshotNotSet($name, $value, true); + + return $this; + } + + public function assertReturned($value) + { + $data = data_get($this->lastState->getEffects(), 'returns.0'); + + if (is_callable($value)) { + PHPUnit::assertTrue($value($data)); + } else { + PHPUnit::assertEquals($value, $data); + } + + return $this; + } +} diff --git a/portman/lib/Livewire/Features/SupportTesting/Render.php b/portman/lib/Livewire/Features/SupportTesting/Render.php new file mode 100644 index 00000000..299085c4 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportTesting/Render.php @@ -0,0 +1,30 @@ +app = $app; + } + + function temporarilyDisableExceptionHandlingAndMiddleware($callback) + { + $cachedHandler = app(ExceptionHandler::class); + + $cachedShouldSkipMiddleware = $this->app->shouldSkipMiddleware(); + + $this->withoutExceptionHandling([HttpException::class, AuthorizationException::class])->withoutMiddleware(); + + $result = $callback($this); + + $this->app->instance(ExceptionHandler::class, $cachedHandler); + + if (! $cachedShouldSkipMiddleware) { + unset($this->app['middleware.disable']); + } + + return $result; + } + + function withoutHandling($except = []) + { + return $this->withoutExceptionHandling($except); + } + + function addHeaders(array $headers) + { + $this->serverVariables = $this->transformHeadersToServerVars($headers); + + return $this; + } +} diff --git a/portman/lib/Livewire/Features/SupportTesting/ShowDuskComponent.php b/portman/lib/Livewire/Features/SupportTesting/ShowDuskComponent.php new file mode 100644 index 00000000..e5c074ac --- /dev/null +++ b/portman/lib/Livewire/Features/SupportTesting/ShowDuskComponent.php @@ -0,0 +1,13 @@ +call(app('livewire')->new($class)); + } +} diff --git a/portman/lib/Livewire/Features/SupportTesting/SubsequentRender.php b/portman/lib/Livewire/Features/SupportTesting/SubsequentRender.php new file mode 100644 index 00000000..207b1d89 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportTesting/SubsequentRender.php @@ -0,0 +1,77 @@ +makeSubsequentRequest($calls, $updates, $cookies); + } + + function makeSubsequentRequest($calls = [], $updates = [], $cookies = []) { + $uri = app('livewire')->getUpdateUri(); + + $encodedSnapshot = json_encode($this->lastState->getSnapshot()); + + $payload = [ + 'components' => [ + [ + 'snapshot' => $encodedSnapshot, + 'calls' => $calls, + 'updates' => $updates, + ], + ], + ]; + + [$response, $componentInstance, $componentView] = $this->extractComponentAndBladeView(function () use ($uri, $payload, $cookies) { + return $this->requestBroker->temporarilyDisableExceptionHandlingAndMiddleware(function ($requestBroker) use ($uri, $payload, $cookies) { + return $requestBroker->addHeaders(['X-Livewire' => true])->call('POST', $uri, $payload, $cookies); + }); + }); + + app('livewire')->flushState(); + + if (! $response->isOk()) { + return new ComponentState( + $componentInstance, + $response, + null, + '', + [], + [], + ); + } + + $json = $response->json(); + + // Set "original" to Blade view for assertions like "assertViewIs()"... + $response->original = $componentView; + + $componentResponsePayload = $json['components'][0]; + + $snapshot = json_decode($componentResponsePayload['snapshot'], true); + + $effects = $componentResponsePayload['effects']; + + // If no new HTML has been rendered, let's forward the last known HTML... + $html = $effects['html'] ?? $this->lastState->getHtml(stripInitialData: true); + $view = $componentView ?? $this->lastState->getView(); + + return new ComponentState( + $componentInstance, + $response, + $view, + $html, + $snapshot, + $effects, + ); + } +} diff --git a/portman/lib/Livewire/Features/SupportTesting/SupportTesting.php b/portman/lib/Livewire/Features/SupportTesting/SupportTesting.php new file mode 100644 index 00000000..ed1c5900 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportTesting/SupportTesting.php @@ -0,0 +1,111 @@ +environment('testing')) return; + + if (class_exists('Laravel\Dusk\Browser')) { + DuskTestable::provide(); + } + + static::registerTestingMacros(); + } + + function dehydrate($context) + { + $target = $this->component; + + $errors = $target->getErrorBag(); + + if (! $errors->isEmpty()) { + $this->storeSet('testing.errors', $errors); + } + } + + function hydrate() + { + $this->storeSet('testing.validator', null); + } + + function exception($e, $stopPropagation) { + if (! $e instanceof ValidationException) return; + + $this->storeSet('testing.validator', $e->validator); + } + + protected static function registerTestingMacros() + { + // Usage: $this->assertSeeLivewire('counter'); + \Illuminate\Testing\TestResponse::macro('assertSeeLivewire', function ($component) { + if (is_subclass_of($component, Component::class)) { + $component = app(ComponentRegistry::class)->getName($component); + } + $escapedComponentName = trim(htmlspecialchars(json_encode(['name' => $component])), '{}'); + + \PHPUnit\Framework\Assert::assertStringContainsString( + $escapedComponentName, + $this->getContent(), + 'Cannot find Livewire component ['.$component.'] rendered on page.' + ); + + return $this; + }); + + // Usage: $this->assertDontSeeLivewire('counter'); + \Illuminate\Testing\TestResponse::macro('assertDontSeeLivewire', function ($component) { + if (is_subclass_of($component, Component::class)) { + $component = app(ComponentRegistry::class)->getName($component); + } + $escapedComponentName = trim(htmlspecialchars(json_encode(['name' => $component])), '{}'); + + \PHPUnit\Framework\Assert::assertStringNotContainsString( + $escapedComponentName, + $this->getContent(), + 'Found Livewire component ['.$component.'] rendered on page.' + ); + + return $this; + }); + + if (class_exists(\Illuminate\Testing\TestView::class)) { + \Illuminate\Testing\TestView::macro('assertSeeLivewire', function ($component) { + if (is_subclass_of($component, Component::class)) { + $component = app(ComponentRegistry::class)->getName($component); + } + $escapedComponentName = trim(htmlspecialchars(json_encode(['name' => $component])), '{}'); + + \PHPUnit\Framework\Assert::assertStringContainsString( + $escapedComponentName, + $this->rendered, + 'Cannot find Livewire component ['.$component.'] rendered on page.' + ); + + return $this; + }); + + \Illuminate\Testing\TestView::macro('assertDontSeeLivewire', function ($component) { + if (is_subclass_of($component, Component::class)) { + $component = app(ComponentRegistry::class)->getName($component); + } + $escapedComponentName = trim(htmlspecialchars(json_encode(['name' => $component])), '{}'); + + \PHPUnit\Framework\Assert::assertStringNotContainsString( + $escapedComponentName, + $this->rendered, + 'Found Livewire component ['.$component.'] rendered on page.' + ); + + return $this; + }); + } + } +} diff --git a/portman/lib/Livewire/Features/SupportTesting/Testable.php b/portman/lib/Livewire/Features/SupportTesting/Testable.php new file mode 100644 index 00000000..e9bc791d --- /dev/null +++ b/portman/lib/Livewire/Features/SupportTesting/Testable.php @@ -0,0 +1,405 @@ +|object $name + * + * @return string + */ + static function normalizeAndRegisterComponentName($name) + { + if (is_array($otherComponents = $name)) { + $name = array_shift($otherComponents); + + foreach ($otherComponents as $key => $value) { + if (is_numeric($key)) { + app('livewire')->isDiscoverable($name) || app('livewire')->component($value); + } else { + app('livewire')->component($key, $value); + } + } + } elseif (is_object($name)) { + $anonymousClassComponent = $name; + + $name = str()->random(10); + + app('livewire')->component($name, $anonymousClassComponent); + } else { + app('livewire')->isDiscoverable($name) || app('livewire')->component($name); + } + + return $name; + } + + /** + * @param ?string $driver + * + * @return void + */ + static function actingAs(\Illuminate\Contracts\Auth\Authenticatable $user, $driver = null) + { + if (isset($user->wasRecentlyCreated) && $user->wasRecentlyCreated) { + $user->wasRecentlyCreated = false; + } + + auth()->guard($driver)->setUser($user); + + auth()->shouldUse($driver); + } + + function id() { + return $this->lastState->getComponent()->getId(); + } + + /** + * @param string $key + */ + function get($key) + { + return data_get($this->lastState->getComponent(), $key); + } + + /** + * @param bool $stripInitialData + * + * @return string + */ + function html($stripInitialData = false) + { + return $this->lastState->getHtml($stripInitialData); + } + + /** + * @param string $name + * + * @return $this + */ + function updateProperty($name, $value = null) + { + return $this->set($name, $value); + } + + /** + * @param array $values + * + * @return $this + */ + function fill($values) + { + foreach ($values as $name => $value) { + $this->set($name, $value); + } + + return $this; + } + + /** + * @param string $name + * + * @return $this + */ + function toggle($name) + { + return $this->set($name, ! $this->get($name)); + } + + /** + * @param string|array $name + * + * @return $this + */ + function set($name, $value = null) + { + if (is_array($name)) { + foreach ($name as $key => $value) { + $this->setProperty($key, $value); + } + } else { + $this->setProperty($name, $value); + } + + return $this; + } + + /** + * @param string $name + * + * @return $this + */ + function setProperty($name, $value) + { + if ($value instanceof \Illuminate\Http\UploadedFile) { + return $this->upload($name, [$value]); + } elseif (is_array($value) && isset($value[0]) && $value[0] instanceof \Illuminate\Http\UploadedFile) { + return $this->upload($name, $value, $isMultiple = true); + } elseif ($value instanceof BackedEnum) { + $value = $value->value; + } + + return $this->update(updates: [$name => $value]); + } + + /** + * @param string $method + * + * @return $this + */ + function runAction($method, ...$params) + { + return $this->call($method, ...$params); + } + + /** + * @param string $method + * + * @return $this + */ + function call($method, ...$params) + { + if ($method === '$refresh') { + return $this->commit(); + } + + if ($method === '$set') { + return $this->set(...$params); + } + + return $this->update(calls: [ + [ + 'method' => $method, + 'params' => $params, + 'path' => '', + ] + ]); + } + + /** + * @return $this + */ + function commit() + { + return $this->update(); + } + + /** + * @return $this + */ + function refresh() + { + return $this->update(); + } + + /** + * @param array $calls + * @param array $updates + * + * @return $this + */ + function update($calls = [], $updates = []) + { + $newState = SubsequentRender::make( + $this->requestBroker, + $this->lastState, + $calls, + $updates, + app('request')->cookies->all() + ); + + $this->lastState = $newState; + + return $this; + } + + /** + * @todo Move me outta here and into the file upload folder somehow... + * + * @param string $name + * @param array $files + * @param bool $isMultiple + * + * @return $this + */ + function upload($name, $files, $isMultiple = false) + { + // This method simulates the calls Livewire's JavaScript + // normally makes for file uploads. + $this->call( + '_startUpload', + $name, + collect($files)->map(function ($file) { + return [ + 'name' => $file->name, + 'size' => $file->getSize(), + 'type' => $file->getMimeType(), + ]; + })->toArray(), + $isMultiple, + ); + + // This is where either the pre-signed S3 url or the regular Livewire signed + // upload url would do its thing and return a hashed version of the uploaded + // file in a tmp directory. + $storage = \Livewire\Features\SupportFileUploads\FileUploadConfiguration::storage(); + try { + $fileHashes = (new \Livewire\Features\SupportFileUploads\FileUploadController)->validateAndStore($files, \Livewire\Features\SupportFileUploads\FileUploadConfiguration::disk()); + } catch (\Illuminate\Validation\ValidationException $e) { + $this->call('_uploadErrored', $name, json_encode(['errors' => $e->errors()]), $isMultiple); + + return $this; + } + + // We are going to encode the original file size, mimeType and hashName in the filename + // so when we create a new TemporaryUploadedFile instance we can fake the + // same file size, mimeType and hashName set for the original file upload. + $newFileHashes = collect($files)->zip($fileHashes)->mapSpread(function ($file, $fileHash) { + // MimeTypes contain slashes, so we replace them with underscores to ensure the filename is valid. + $escapedMimeType = (string) str($file->getMimeType())->replace('/', '_'); + + return (string) str($fileHash)->replaceFirst('.', "-hash={$file->hashName()}-mimeType={$escapedMimeType}-size={$file->getSize()}."); + })->toArray(); + + collect($fileHashes)->zip($newFileHashes)->mapSpread(function ($fileHash, $newFileHash) use ($storage) { + $storage->move('/'.\Livewire\Features\SupportFileUploads\FileUploadConfiguration::path($fileHash), '/'.\Livewire\Features\SupportFileUploads\FileUploadConfiguration::path($newFileHash)); + }); + + // Now we finish the upload with a final call to the Livewire component + // with the temporarily uploaded file path. + $this->call('_finishUpload', $name, $newFileHashes, $isMultiple); + + return $this; + } + + /** + * @param string $key + */ + function viewData($key) + { + return $this->lastState->getView()->getData()[$key]; + } + + function getData() + { + return $this->lastState->getSnapshotData(); + } + + function instance() + { + return $this->lastState->getComponent(); + } + + /** + * @return \Livewire\Component + */ + function invade() + { + return \Livewire\invade($this->lastState->getComponent()); + } + + /** + * @return $this + */ + function dump() + { + dump($this->lastState->getHtml()); + + return $this; + } + + /** + * @return void + */ + function dd() + { + dd($this->lastState->getHtml()); + } + + /** + * @return $this + */ + function tap($callback) + { + $callback($this); + + return $this; + } + + /** + * @param string $property + */ + function __get($property) + { + if ($property === 'effects') return $this->lastState->getEffects(); + if ($property === 'snapshot') return $this->lastState->getSnapshot(); + if ($property === 'target') return $this->lastState->getComponent(); + + return $this->instance()->$property; + } + + /** + * @param string $method + * + * @return $this + */ + function __call($method, $params) + { + if (static::hasMacro($method)) { + return $this->macroCall($method, $params); + } + + $this->lastState->getResponse()->{$method}(...$params); + + return $this; + } +} diff --git a/portman/lib/Livewire/Features/SupportValidation/BaseRule.php b/portman/lib/Livewire/Features/SupportValidation/BaseRule.php new file mode 100644 index 00000000..f69c24a0 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportValidation/BaseRule.php @@ -0,0 +1,11 @@ +subTarget ?: $this->component; + $name = $this->subTarget ? $this->getSubName() : $this->getName(); + + $rules = []; + + if (is_null($this->rule)) { + // Allow "Rule" to be used without a given validation rule. It's purpose is to instead + // trigger validation on property updates... + } elseif (is_array($this->rule) && count($this->rule) > 0 && ! is_numeric(array_keys($this->rule)[0])) { + // Support setting rules by key-value for this and other properties: + // For example, #[Validate(['foo' => 'required', 'foo.*' => 'required'])] + $rules = $this->rule; + } else { + $rules[$this->getSubName()] = $this->rule; + } + + if ($this->attribute) { + if (is_array($this->attribute)) { + $target = $this->subTarget ?? $this->component; + $target->addValidationAttributesFromOutside($this->attribute); + } else { + $target->addValidationAttributesFromOutside([$name => $this->attribute]); + } + } + + if ($this->as) { + if (is_array($this->as)) { + $as = $this->translate + ? array_map(fn ($i) => trans($i), $this->as) + : $this->as; + + $target->addValidationAttributesFromOutside($as); + } else { + $target->addValidationAttributesFromOutside([$name => $this->translate ? trans($this->as) : $this->as]); + } + } + + if ($this->message) { + if (is_array($this->message)) { + $messages = $this->translate + ? array_map(fn ($i) => trans($i), $this->message) + : $this->message; + + $target->addMessagesFromOutside($messages); + } else { + // If a single message was provided, apply it to the first given rule. + // There should have only been one rule provided in this case anyways... + $rule = head(array_values($rules)); + + // In the case of "min:5" or something, we only want "min"... + $rule = (string) str($rule)->before(':'); + + $target->addMessagesFromOutside([$name.'.'.$rule => $this->translate ? trans($this->message) : $this->message]); + } + } + + $target->addRulesFromOutside($rules); + } + + function update($fullPath, $newValue) + { + if ($this->onUpdate === false) return; + + return function () { + // If this attribute is added to a "form object", we want to run + // the validateOnly method on the form object, not the base component... + $target = $this->subTarget ?: $this->component; + $name = $this->subTarget ? $this->getSubName() : $this->getName(); + + // Here we have to run the form object validator from the context + // of the base "wrapped" component so that validation works... + wrap($this->component)->tap(function () use ($target, $name) { + $target->validateOnly($name); + }); + }; + } +} diff --git a/portman/lib/Livewire/Features/SupportValidation/HandlesValidation.php b/portman/lib/Livewire/Features/SupportValidation/HandlesValidation.php new file mode 100644 index 00000000..f96930fb --- /dev/null +++ b/portman/lib/Livewire/Features/SupportValidation/HandlesValidation.php @@ -0,0 +1,523 @@ +rulesFromOutside[] = $rules; + } + + public function addMessagesFromOutside($messages) + { + $this->messagesFromOutside[] = $messages; + } + + public function addValidationAttributesFromOutside($validationAttributes) + { + $this->validationAttributesFromOutside[] = $validationAttributes; + } + + public function getErrorBag() + { + if (! store($this)->has('errorBag')) { + $previouslySharedErrors = app('view')->getShared()['errors'] ?? new ViewErrorBag; + $this->setErrorBag($previouslySharedErrors->getMessages()); + } + + return store($this)->get('errorBag'); + } + + public function addError($name, $message) + { + return $this->getErrorBag()->add($name, $message); + } + + public function setErrorBag($bag) + { + return store($this)->set('errorBag', $bag instanceof MessageBag + ? $bag + : new MessageBag($bag) + ); + } + + public function resetErrorBag($field = null) + { + $fields = (array) $field; + + if (empty($fields)) { + $errorBag = new MessageBag; + + $this->setErrorBag($errorBag); + + return $errorBag; + } + + $this->setErrorBag( + $this->errorBagExcept($fields) + ); + } + + public function clearValidation($field = null) + { + $this->resetErrorBag($field); + } + + public function resetValidation($field = null) + { + $this->resetErrorBag($field); + } + + public function errorBagExcept($field) + { + $fields = (array) $field; + + return new MessageBag( + collect($this->getErrorBag()) + ->reject(function ($messages, $messageKey) use ($fields) { + return collect($fields)->some(function ($field) use ($messageKey) { + return str($messageKey)->is($field); + }); + }) + ->toArray() + ); + } + + public function getRules() + { + $rulesFromComponent = []; + + if (method_exists($this, 'rules')) $rulesFromComponent = $this->rules(); + else if (property_exists($this, 'rules')) $rulesFromComponent = $this->rules; + + $rulesFromOutside = array_merge_recursive( + ...array_map( + fn($i) => value($i), + $this->rulesFromOutside + ) + ); + + return array_merge($rulesFromComponent, $rulesFromOutside); + } + + protected function getMessages() + { + $messages = []; + + if (method_exists($this, 'messages')) $messages = $this->messages(); + elseif (property_exists($this, 'messages')) $messages = $this->messages; + + $messagesFromOutside = array_merge( + ...array_map( + fn($i) => value($i), + $this->messagesFromOutside + ) + ); + + return array_merge($messages, $messagesFromOutside); + } + + protected function getValidationAttributes() + { + $validationAttributes = []; + + if (method_exists($this, 'validationAttributes')) $validationAttributes = $this->validationAttributes(); + elseif (property_exists($this, 'validationAttributes')) $validationAttributes = $this->validationAttributes; + + $validationAttributesFromOutside = array_merge( + ...array_map( + fn($i) => value($i), + $this->validationAttributesFromOutside + ) + ); + + return array_merge($validationAttributes, $validationAttributesFromOutside); + } + + protected function getValidationCustomValues() + { + if (method_exists($this, 'validationCustomValues')) return $this->validationCustomValues(); + if (property_exists($this, 'validationCustomValues')) return $this->validationCustomValues; + + return []; + } + + public function rulesForModel($name) + { + if (empty($this->getRules())) return collect(); + + return collect($this->getRules()) + ->filter(function ($value, $key) use ($name) { + return Utils::beforeFirstDot($key) === $name; + }); + } + + public function hasRuleFor($dotNotatedProperty) + { + $propertyWithStarsInsteadOfNumbers = $this->ruleWithNumbersReplacedByStars($dotNotatedProperty); + + // If property has numeric indexes in it, + if ($dotNotatedProperty !== $propertyWithStarsInsteadOfNumbers) { + return collect($this->getRules())->keys()->contains($propertyWithStarsInsteadOfNumbers); + } + + return collect($this->getRules()) + ->keys() + ->map(function ($key) { + return (string) str($key)->before('.*'); + })->contains($dotNotatedProperty); + } + + public function ruleWithNumbersReplacedByStars($dotNotatedProperty) + { + // Convert foo.0.bar.1 -> foo.*.bar.* + return (string) str($dotNotatedProperty) + // Replace all numeric indexes with an array wildcard: (.0., .10., .007.) => .*. + // In order to match overlapping numerical indexes (foo.1.2.3.4.name), + // We need to use a positive look-behind, that's technically all the magic here. + // For better understanding, see: https://regexr.com/5d1n3 + ->replaceMatches('/(?<=(\.))\d+\./', '*.') + // Replace all numeric indexes at the end of the name with an array wildcard + // (Same as the previous regex, but ran only at the end of the string) + // For better undestanding, see: https://regexr.com/5d1n6 + ->replaceMatches('/\.\d+$/', '.*'); + } + + public function missingRuleFor($dotNotatedProperty) + { + return ! $this->hasRuleFor($dotNotatedProperty); + } + + public function withValidator($callback) + { + $this->withValidatorCallback = $callback; + + return $this; + } + + protected function checkRuleMatchesProperty($rules, $data) + { + collect($rules) + ->keys() + ->each(function($ruleKey) use ($data) { + throw_unless( + array_key_exists(Utils::beforeFirstDot($ruleKey), $data), + new \Exception('No property found for validation: ['.$ruleKey.']') + ); + }); + } + + public function validate($rules = null, $messages = [], $attributes = []) + { + $isUsingGlobalRules = is_null($rules); + + [$rules, $messages, $attributes] = $this->providedOrGlobalRulesMessagesAndAttributes($rules, $messages, $attributes); + + $data = $this->prepareForValidation( + $this->getDataForValidation($rules) + ); + + $this->checkRuleMatchesProperty($rules, $data); + + $ruleKeysToShorten = $this->getModelAttributeRuleKeysToShorten($data, $rules); + + $data = $this->unwrapDataForValidation($data); + + $validator = Validator::make($data, $rules, $messages, $attributes); + + if ($this->withValidatorCallback) { + call_user_func($this->withValidatorCallback, $validator); + + $this->withValidatorCallback = null; + } + + $this->shortenModelAttributesInsideValidator($ruleKeysToShorten, $validator); + + $customValues = $this->getValidationCustomValues(); + + if (! empty($customValues)) { + $validator->addCustomValues($customValues); + } + + if ($this->isRootComponent() && $isUsingGlobalRules) { + $validatedData = $this->withFormObjectValidators($validator, fn () => $validator->validate(), fn ($form) => $form->validate()); + } else { + $validatedData = $validator->validate(); + } + + $this->resetErrorBag(); + + return $validatedData; + } + + protected function isRootComponent() + { + // Because this trait is used for form objects as well... + return $this instanceof \Livewire\Component; + } + + protected function withFormObjectValidators($validator, $validateSelf, $validateForm) + { + $cumulativeErrors = new MessageBag; + $cumulativeData = []; + $formExceptions = []; + + // First, run sub-validators... + foreach ($this->getFormObjects() as $form) { + try { + // Only run sub-validator if the sub-validator has rules... + if (filled($form->getRules())) { + $cumulativeData = array_merge($cumulativeData, $validateForm($form)); + } + } catch (ValidationException $e) { + $cumulativeErrors->merge($e->validator->errors()); + + $formExceptions[] = $e; + } + } + + // Now run main validator... + try { + $cumulativeData = array_merge($cumulativeData, $validateSelf()); + } catch (ValidationException $e) { + // If the main validator has errors, merge them with subs and rethrow... + $e->validator->errors()->merge($cumulativeErrors); + + throw $e; + } + + // If main validation passed, go through other sub-validation exceptions + // and throw the first one with the cumulative messages... + foreach ($formExceptions as $e) { + $exceptionErrorKeys = $e->validator->errors()->keys(); + $remainingErrors = Arr::except($cumulativeErrors->messages(), $exceptionErrorKeys); + $e->validator->errors()->merge($remainingErrors); + + throw $e; + } + + // All validation has passed, we can return the data... + return $cumulativeData; + } + + public function validateOnly($field, $rules = null, $messages = [], $attributes = [], $dataOverrides = []) + { + $property = (string) str($field)->before('.'); + + // If validating a field in a form object, defer validation to that form object... + if ( + $this->isRootComponent() + && ($form = $this->all()[$property] ?? false) instanceof Form + ) { + $stripPrefix = (string) str($field)->after('.'); + return $form->validateOnly($stripPrefix, $rules, $messages, $attributes, $dataOverrides); + } + + $isUsingGlobalRules = is_null($rules); + + [$rules, $messages, $attributes] = $this->providedOrGlobalRulesMessagesAndAttributes($rules, $messages, $attributes); + + // Loop through rules and swap any wildcard '*' with keys from field, then filter down to only + // rules that match the field, but return the rules without wildcard characters replaced, + // so that custom attributes and messages still work as they need wildcards to work. + $rulesForField = collect($rules) + ->filter(function($value, $rule) use ($field) { + if(! str($field)->is($rule)) { + return false; + } + + $fieldArray = str($field)->explode('.'); + $ruleArray = str($rule)->explode('.'); + + for($i = 0; $i < count($fieldArray); $i++) { + if(isset($ruleArray[$i]) && $ruleArray[$i] === '*') { + $ruleArray[$i] = $fieldArray[$i]; + } + } + + $rule = $ruleArray->join('.'); + + return $field === $rule; + }); + + $ruleForField = $rulesForField->keys()->first(); + + $rulesForField = $rulesForField->toArray(); + + $ruleKeysForField = array_keys($rulesForField); + + $data = array_merge($this->getDataForValidation($rules), $dataOverrides); + + $data = $this->prepareForValidation($data); + + $this->checkRuleMatchesProperty($rules, $data); + + $ruleKeysToShorten = $this->getModelAttributeRuleKeysToShorten($data, $rules); + + $data = $this->unwrapDataForValidation($data); + + // If a matching rule is found, then filter collections down to keys specified in the field, + // while leaving all other data intact. If a key isn't specified and instead there is a + // wildcard '*' then leave that whole collection intact. This ensures that any rules + // that depend on other fields/ properties still work. + if ($ruleForField) { + $ruleArray = str($ruleForField)->explode('.'); + $fieldArray = str($field)->explode('.'); + + $data = $this->filterCollectionDataDownToSpecificKeys($data, $ruleArray, $fieldArray); + } + + $validator = Validator::make($data, $rulesForField, $messages, $attributes); + + if ($this->withValidatorCallback) { + call_user_func($this->withValidatorCallback, $validator); + + $this->withValidatorCallback = null; + } + + $this->shortenModelAttributesInsideValidator($ruleKeysToShorten, $validator); + + $customValues = $this->getValidationCustomValues(); + if (!empty($customValues)) { + $validator->addCustomValues($customValues); + } + + try { + $result = $validator->validate(); + } catch (ValidationException $e) { + $messages = $e->validator->getMessageBag(); + + invade($e->validator)->messages = $messages->merge( + $this->errorBagExcept($ruleKeysForField) + ); + + throw $e; + } + + $this->resetErrorBag($ruleKeysForField); + + return $result; + } + + protected function filterCollectionDataDownToSpecificKeys($data, $ruleKeys, $fieldKeys) + { + + // Filter data down to specified keys in collections, but leave all other data intact + if (count($ruleKeys)) { + $ruleKey = $ruleKeys->shift(); + $fieldKey = $fieldKeys->shift(); + + if ($fieldKey == '*') { + // If the specified field has a '*', then loop through the collection and keep the whole collection intact. + foreach ($data as $key => $value) { + $data[$key] = $this->filterCollectionDataDownToSpecificKeys($value, $ruleKeys, $fieldKeys); + } + } else { + // Otherwise filter collection down to a specific key + $keyData = data_get($data, $fieldKey, null); + + if ($ruleKey == '*') { + $data = []; + } + + data_set($data, $fieldKey, $this->filterCollectionDataDownToSpecificKeys($keyData, $ruleKeys, $fieldKeys)); + } + } + + return $data; + } + + protected function getModelAttributeRuleKeysToShorten($data, $rules) + { + // If a model ($foo) is a property, and the validation rule is + // "foo.bar", then set the attribute to just "bar", so that + // the validation message is shortened and more readable. + + $toShorten = []; + + foreach ($rules as $key => $value) { + $propertyName = Utils::beforeFirstDot($key); + + if ($data[$propertyName] instanceof Model) { + $toShorten[] = $key; + } + } + + return $toShorten; + } + + protected function shortenModelAttributesInsideValidator($ruleKeys, $validator) + { + foreach ($ruleKeys as $key) { + if (str($key)->snake()->replace('_', ' ')->is($validator->getDisplayableAttribute($key))) { + $validator->addCustomAttributes([$key => $validator->getDisplayableAttribute(Utils::afterFirstDot($key))]); + } + } + } + + protected function providedOrGlobalRulesMessagesAndAttributes($rules, $messages, $attributes) + { + $rules = is_null($rules) ? $this->getRules() : $rules; + + // Before we warn the user about not providing validation rules, + // Let's make sure there are no form objects that contain them... + $allRules = $rules; + + if ($this->isRootComponent()) { + foreach ($this->getFormObjects() as $form) { + $allRules = array_merge($allRules, $form->getRules()); + } + } + + throw_if(empty($allRules), new MissingRulesException($this)); + + $messages = empty($messages) ? $this->getMessages() : $messages; + $attributes = empty($attributes) ? $this->getValidationAttributes() : $attributes; + + return [$rules, $messages, $attributes]; + } + + protected function getDataForValidation($rules) + { + return Utils::getPublicPropertiesDefinedOnSubclass($this); + } + + protected function unwrapDataForValidation($data) + { + return collect($data)->map(function ($value) { + + $synth = app('livewire')->findSynth($value, $this); + + if ($synth && method_exists($synth, 'unwrapForValidation')) return $synth->unwrapForValidation($value); + else if ($value instanceof Arrayable) return $value->toArray(); + + return $value; + })->all(); + } + + protected function prepareForValidation($attributes) + { + return $attributes; + } +} diff --git a/portman/lib/Livewire/Features/SupportValidation/SupportValidation.php b/portman/lib/Livewire/Features/SupportValidation/SupportValidation.php new file mode 100644 index 00000000..2cfdaf0f --- /dev/null +++ b/portman/lib/Livewire/Features/SupportValidation/SupportValidation.php @@ -0,0 +1,54 @@ +component->setErrorBag( + $memo['errors'] ?? [] + ); + } + + function render($view, $data) + { + $errors = (new ViewErrorBag)->put('default', $this->component->getErrorBag()); + + $revert = Utils::shareWithViews('errors', $errors); + + return function () use ($revert) { + // After the component has rendered, let's revert our global + // sharing of the "errors" variable with blade views... + $revert(); + }; + } + + function dehydrate($context) + { + $errors = $this->component->getErrorBag()->toArray(); + + // Only persist errors that were born from properties on the component + // and not from custom validators (Validator::make) that were run. + $context->addMemo('errors', collect($errors) + ->filter(function ($value, $key) { + return Utils::hasProperty($this->component, $key); + }) + ->toArray() + ); + } + + function exception($e, $stopPropagation) + { + if (! $e instanceof ValidationException) return; + + $this->component->setErrorBag($e->validator->errors()); + + $stopPropagation(); + } +} diff --git a/portman/lib/Livewire/Features/SupportValidation/TestsValidation.php b/portman/lib/Livewire/Features/SupportValidation/TestsValidation.php new file mode 100644 index 00000000..fbf0a39e --- /dev/null +++ b/portman/lib/Livewire/Features/SupportValidation/TestsValidation.php @@ -0,0 +1,135 @@ +target)->get('testing.validator'); + + if ($validator) return $validator->errors(); + + $errors = store($this->target)->get('testing.errors'); + + if ($errors) return $errors; + + return new MessageBag; + } + + function failedRules() + { + $validator = store($this->target)->get('testing.validator'); + + return $validator ? $validator->failed() : []; + } + + public function assertHasErrors($keys = []) + { + $errors = $this->errors(); + + PHPUnit::assertTrue($errors->isNotEmpty(), 'Component has no errors.'); + + $keys = (array) $keys; + + foreach ($keys as $key => $value) { + if (is_int($key)) { + $this->makeErrorAssertion($value); + } else { + $this->makeErrorAssertion($key, $value); + } + } + + return $this; + } + + protected function makeErrorAssertion($key = null, $value = null) { + $errors = $this->errors(); + + $messages = $errors->get($key); + + $failed = $this->failedRules() ?: []; + $failedRules = array_keys(Arr::get($failed, $key, [])); + $failedRules = array_map(function (string $rule) { + if (is_a($rule, ValidationRule::class, true) || is_a($rule, Rule::class, true)) { + return $rule; + } + + return Str::snake($rule); + }, $failedRules); + + PHPUnit::assertTrue($errors->isNotEmpty(), 'Component has no errors.'); + + if (is_null($value)) { + PHPUnit::assertTrue($errors->has($key), "Component missing error: $key"); + } elseif ($value instanceof Closure) { + PHPUnit::assertTrue($value($failedRules, $messages)); + } elseif (is_array($value)) { + foreach ((array) $value as $ruleOrMessage) { + $this->assertErrorMatchesRuleOrMessage($failedRules, $messages, $key, $ruleOrMessage); + } + } else { + $this->assertErrorMatchesRuleOrMessage($failedRules, $messages, $key, $value); + } + + return $this; + } + + protected function assertErrorMatchesRuleOrMessage($rules, $messages, $key, $ruleOrMessage) + { + if (Str::contains($ruleOrMessage, ':')){ + $ruleOrMessage = Str::before($ruleOrMessage, ':'); + } + + if (in_array($ruleOrMessage, $rules)) { + PHPUnit::assertTrue(true); + + return; + } + + // If the provided rule/message isn't a failed rule, let's check to see if it's a message... + PHPUnit::assertContains($ruleOrMessage, $messages, "Component has no matching failed rule or error message [{$ruleOrMessage}] for [{$key}] attribute."); + } + + + public function assertHasNoErrors($keys = []) + { + $errors = $this->errors(); + + if (empty($keys)) { + PHPUnit::assertTrue($errors->isEmpty(), 'Component has errors: "'.implode('", "', $errors->keys()).'"'); + + return $this; + } + + $keys = (array) $keys; + + foreach ($keys as $key => $value) { + if (is_int($key)) { + PHPUnit::assertFalse($errors->has($value), "Component has error: $value"); + } else { + $failed = $this->failedRules() ?: []; + $rules = array_keys(Arr::get($failed, $key, [])); + + foreach ((array) $value as $rule) { + if (Str::contains($rule, ':')){ + $rule = Str::before($rule, ':'); + } + + PHPUnit::assertNotContains(Str::studly($rule), $rules, "Component has [{$rule}] errors for [{$key}] attribute."); + } + } + } + + return $this; + } +} diff --git a/portman/lib/Livewire/Features/SupportWireCurrent/test-views/navbar-sidebar.blade.php b/portman/lib/Livewire/Features/SupportWireCurrent/test-views/navbar-sidebar.blade.php new file mode 100644 index 00000000..f5ed4e8f --- /dev/null +++ b/portman/lib/Livewire/Features/SupportWireCurrent/test-views/navbar-sidebar.blade.php @@ -0,0 +1,51 @@ + + + + + + + Document + + + + + + +
+ {{ $slot }} +
+ + diff --git a/portman/lib/Livewire/Features/SupportWireLoading/browser_test_image.png b/portman/lib/Livewire/Features/SupportWireLoading/browser_test_image.png new file mode 100644 index 00000000..99caba2a Binary files /dev/null and b/portman/lib/Livewire/Features/SupportWireLoading/browser_test_image.png differ diff --git a/portman/lib/Livewire/Features/SupportWireModelingNestedComponents/BaseModelable.php b/portman/lib/Livewire/Features/SupportWireModelingNestedComponents/BaseModelable.php new file mode 100644 index 00000000..c7a1ab6b --- /dev/null +++ b/portman/lib/Livewire/Features/SupportWireModelingNestedComponents/BaseModelable.php @@ -0,0 +1,55 @@ + $value) { + if (str($key)->startsWith('wire:model')) { + $outer = $value; + store($this->component)->push('bindings-directives', $key, $value); + break; + } + } + + if ($outer === null) return; + + $inner = $this->getName(); + + store($this->component)->push('bindings', $inner, $outer); + + $this->setValue(data_get($parent, $outer)); + } + + // This update hook is for the following scenario: + // An modelable value has changed in the browser. + // A network request is triggered from the parent. + // The request contains both parent and child component updates. + // The parent finishes it's request and the "updated" value is + // overridden in the parent's lifecycle (ex. a form field being reset). + // Without this hook, the child's value will not honor that change + // and will instead still be updated to the old value, even though + // the parent changed the bound value. This hook detects if the parent + // has provided a value during this request and ensures that it is the + // final value for the child's request... + function update($fullPath, $newValue) + { + if (store($this->component)->get('hasBeenSeeded', false)) { + $oldValue = $this->getValue(); + + return function () use ($oldValue) { + $this->setValue($oldValue); + }; + } + } +} diff --git a/portman/lib/Livewire/Features/SupportWireModelingNestedComponents/SupportWireModelingNestedComponents.php b/portman/lib/Livewire/Features/SupportWireModelingNestedComponents/SupportWireModelingNestedComponents.php new file mode 100644 index 00000000..a039dd21 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportWireModelingNestedComponents/SupportWireModelingNestedComponents.php @@ -0,0 +1,94 @@ + static::$outersByComponentId = []); + + // On a subsequent request, a parent encounters a child component + // with wire:model on it, and that child has already been mounted + // in a previous request, capture the value being passed in so we + // can later set the child's property if it exists in this request. + on('mount.stub', function ($tag, $id, $params, $parent, $key) { + $outer = collect($params)->first(function ($value, $key) { + return str($key)->startsWith('wire:model'); + }); + + if (! $outer) return; + + static::$outersByComponentId[$id] = [$outer => data_get($parent, $outer)]; + }); + } + + public function hydrate($memo) + { + if (! isset($memo['bindings'])) return; + + $bindings = $memo['bindings']; + $directives = $memo['bindingsDirectives']; + + // Store the bindings for later dehydration... + store($this->component)->set('bindings', $bindings); + store($this->component)->set('bindings-directives', $directives); + + // If this child's parent already rendered its stub, retrieve + // the memo'd value and set it. + if (! isset(static::$outersByComponentId[$memo['id']])) return; + + $outers = static::$outersByComponentId[$memo['id']]; + + foreach ($bindings as $outer => $inner) { + store($this->component)->set('hasBeenSeeded', true); + + $this->component->$inner = $outers[$outer]; + } + } + + public function render($view, $data) + { + return function ($html, $replaceHtml) { + $bindings = store($this->component)->get('bindings', false); + $directives = store($this->component)->get('bindings-directives', false); + + if (! $bindings) return; + + // Currently we can only support a single wire:model bound value, + // so we'll just get the first one. But in the future we will + // likely want to support named bindings, so we'll keep + // this value as an array. + $outer = array_keys($bindings)[0]; + $inner = array_values($bindings)[0]; + $directive = array_values($directives)[0]; + + // Attach the necessary Alpine directives so that the child and + // parent's JS, ephemeral, values are bound. + $replaceHtml(Utils::insertAttributesIntoHtmlRoot($html, [ + $directive => '$parent.'.$outer, + 'x-modelable' => '$wire.'.$inner, + ])); + }; + } + + public function dehydrate($context) + { + $bindings = store($this->component)->get('bindings', false); + + if (! $bindings) return; + + $directives = store($this->component)->get('bindings-directives'); + + // Add the bindings metadata to the paylad for later reference... + $context->addMemo('bindings', $bindings); + $context->addMemo('bindingsDirectives', $directives); + } +} diff --git a/portman/lib/Livewire/Features/SupportWireables/SupportWireables.php b/portman/lib/Livewire/Features/SupportWireables/SupportWireables.php new file mode 100644 index 00000000..f60fa4c4 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportWireables/SupportWireables.php @@ -0,0 +1,13 @@ +propertySynthesizer(WireableSynth::class); + } +} diff --git a/portman/lib/Livewire/Features/SupportWireables/WireableSynth.php b/portman/lib/Livewire/Features/SupportWireables/WireableSynth.php new file mode 100644 index 00000000..b2629b67 --- /dev/null +++ b/portman/lib/Livewire/Features/SupportWireables/WireableSynth.php @@ -0,0 +1,47 @@ +toLivewire(); + } + + function dehydrate($target, $dehydrateChild) + { + $data = $target->toLivewire(); + + foreach ($data as $key => $child) { + $data[$key] = $dehydrateChild($key, $child); + } + + return [ + $data, + ['class' => get_class($target)], + ]; + } + + function hydrate($value, $meta, $hydrateChild) { + foreach ($value as $key => $child) { + $value[$key] = $hydrateChild($key, $child); + } + + return $meta['class']::fromLivewire($value); + } + + function set(&$target, $key, $value) { + $target->{$key} = $value; + } +} diff --git a/portman/lib/Livewire/Form.php b/portman/lib/Livewire/Form.php new file mode 100644 index 00000000..aac5d5ae --- /dev/null +++ b/portman/lib/Livewire/Form.php @@ -0,0 +1,10 @@ +getParameters() as $parameter) { + static::substituteNameBindingForCallParameter($parameter, $parameters, $paramIndex); + static::substituteImplicitBindingForCallParameter($container, $parameter, $parameters); + static::addDependencyForCallParameter($container, $parameter, $parameters, $dependencies); + } + + return array_values(array_merge($dependencies, $parameters)); + } + + protected static function substituteNameBindingForCallParameter($parameter, array &$parameters, int &$paramIndex) + { + // check if we have a candidate for name/value binding + if (! array_key_exists($paramIndex, $parameters)) { + return; + } + + if ($parameter->isVariadic()) { + // this last param will pick up the rest - reindex any remaining parameters + $parameters = array_merge( + array_filter($parameters, function ($key) { return ! is_int($key); }, ARRAY_FILTER_USE_KEY), + array_values(array_filter($parameters, function ($key) { return is_int($key); }, ARRAY_FILTER_USE_KEY)) + ); + + return; + } + + // stop if this one is due for dependency injection + if (! is_null($className = static::getClassForDependencyInjection($parameter)) && ! $parameters[$paramIndex] instanceof $className) { + return; + } + + if (! array_key_exists($paramName = $parameter->getName(), $parameters)) { + // have a parameter value that is bound by sequential order + // and not yet bound by name, so bind it to parameter name + + $parameters[$paramName] = $parameters[$paramIndex]; + unset($parameters[$paramIndex]); + $paramIndex++; + } + } + + protected static function substituteImplicitBindingForCallParameter($container, $parameter, array &$parameters) + { + $paramName = $parameter->getName(); + + // check if we have a candidate for implicit binding + if (is_null($className = static::getClassForImplicitBinding($parameter))) { + return; + } + + // Check if the value we have for this param is an instance + // of the desired class, attempt implicit binding if not + if (array_key_exists($paramName, $parameters) && ! $parameters[$paramName] instanceof $className) { + $parameters[$paramName] = static::getImplicitBinding($container, $className, $parameters[$paramName]); + } elseif (array_key_exists($className, $parameters) && ! $parameters[$className] instanceof $className) { + $parameters[$className] = static::getImplicitBinding($container, $className, $parameters[$className]); + } + } + + protected static function getClassForDependencyInjection($parameter) + { + $className = static::getParameterClassName($parameter); + + if (is_null($className)) return null; + + if (static::isEnum($parameter)) return null; + + if (! static::implementsInterface($parameter)) return $className; + + return null; + } + + protected static function getClassForImplicitBinding($parameter) + { + $className = static::getParameterClassName($parameter); + + if (is_null($className)) return null; + + if (static::isEnum($parameter)) return $className; + + if (static::implementsInterface($parameter)) return $className; + + return null; + } + + protected static function getImplicitBinding($container, $className, $value) + { + if (is_null($value)) { + return null; + } + + if ((new ReflectionClass($className))->isEnum()) { + return $className::tryFrom($value); + } + + $model = $container->make($className)->resolveRouteBinding($value); + + if (! $model) { + throw (new ModelNotFoundException)->setModel($className, [$value]); + } + + return $model; + } + + public static function getParameterClassName($parameter) + { + $type = $parameter->getType(); + + if (! $type) return null; + + if (! $type instanceof ReflectionNamedType) return null; + + return (! $type->isBuiltin()) ? $type->getName() : null; + } + + public static function implementsInterface($parameter) + { + return (new ReflectionClass($parameter->getType()->getName()))->implementsInterface(ImplicitlyBindable::class); + } + + public static function isEnum($parameter) + { + return (new ReflectionClass($parameter->getType()->getName()))->isEnum(); + } +} diff --git a/portman/lib/Livewire/Livewire.php b/portman/lib/Livewire/Livewire.php new file mode 100644 index 00000000..c152981e --- /dev/null +++ b/portman/lib/Livewire/Livewire.php @@ -0,0 +1,43 @@ +provider = $provider; + } + + function provide($callback) + { + \Closure::bind($callback, $this->provider, $this->provider::class)(); + } + + function component($name, $class = null) + { + app(ComponentRegistry::class)->component($name, $class); + } + + function componentHook($hook) + { + ComponentHookRegistry::register($hook); + } + + function propertySynthesizer($synth) + { + app(HandleComponents::class)->registerPropertySynthesizer($synth); + } + + function directive($name, $callback) + { + app(ExtendBlade::class)->livewireOnlyDirective($name, $callback); + } + + function precompiler($callback) + { + app(ExtendBlade::class)->livewireOnlyPrecompiler($callback); + } + + function new($name, $id = null) + { + return app(ComponentRegistry::class)->new($name, $id); + } + + function isDiscoverable($componentNameOrClass) + { + return app(ComponentRegistry::class)->isDiscoverable($componentNameOrClass); + } + + function resolveMissingComponent($resolver) + { + return app(ComponentRegistry::class)->resolveMissingComponent($resolver); + } + + function mount($name, $params = [], $key = null) + { + return app(HandleComponents::class)->mount($name, $params, $key); + } + + function snapshot($component, $context = null) + { + return app(HandleComponents::class)->snapshot($component, $context); + } + + function fromSnapshot($snapshot) + { + return app(HandleComponents::class)->fromSnapshot($snapshot); + } + + function listen($eventName, $callback) { + return on($eventName, $callback); + } + + function current() + { + return last(app(HandleComponents::class)::$componentStack); + } + + function findSynth($keyOrTarget, $component) + { + return app(HandleComponents::class)->findSynth($keyOrTarget, $component); + } + + function update($snapshot, $diff, $calls) + { + return app(HandleComponents::class)->update($snapshot, $diff, $calls); + } + + function updateProperty($component, $path, $value) + { + $dummyContext = new ComponentContext($component, false); + + $updatedHook = app(HandleComponents::class)->updateProperty($component, $path, $value, $dummyContext); + + $updatedHook(); + } + + function isLivewireRequest() + { + return app(HandleRequests::class)->isLivewireRequest(); + } + + function componentHasBeenRendered() + { + return SupportAutoInjectedAssets::$hasRenderedAComponentThisRequest; + } + + function forceAssetInjection() + { + SupportAutoInjectedAssets::$forceAssetInjection = true; + } + + function setUpdateRoute($callback) + { + return app(HandleRequests::class)->setUpdateRoute($callback); + } + + function getUpdateUri() + { + return app(HandleRequests::class)->getUpdateUri(); + } + + function setScriptRoute($callback) + { + return app(FrontendAssets::class)->setScriptRoute($callback); + } + + function useScriptTagAttributes($attributes) + { + return app(FrontendAssets::class)->useScriptTagAttributes($attributes); + } + + protected $queryParamsForTesting = []; + + protected $cookiesForTesting = []; + + protected $headersForTesting = []; + + function withUrlParams($params) + { + return $this->withQueryParams($params); + } + + function withQueryParams($params) + { + $this->queryParamsForTesting = $params; + + return $this; + } + + function withCookie($name, $value) + { + $this->cookiesForTesting[$name] = $value; + + return $this; + } + + function withCookies($cookies) + { + $this->cookiesForTesting = array_merge($this->cookiesForTesting, $cookies); + + return $this; + } + + function withHeaders($headers) + { + $this->headersForTesting = array_merge($this->headersForTesting, $headers); + + return $this; + } + + function withoutLazyLoading() + { + SupportLazyLoading::disableWhileTesting(); + + return $this; + } + + function test($name, $params = []) + { + return Testable::create( + $name, + $params, + $this->queryParamsForTesting, + $this->cookiesForTesting, + $this->headersForTesting, + ); + } + + function visit($name) + { + return DuskTestable::create($name, $params = [], $this->queryParamsForTesting); + } + + function actingAs(\Illuminate\Contracts\Auth\Authenticatable $user, $driver = null) + { + Testable::actingAs($user, $driver); + + return $this; + } + + function isRunningServerless() + { + return in_array($_ENV['SERVER_SOFTWARE'] ?? null, [ + 'vapor', + 'bref', + ]); + } + + function addPersistentMiddleware($middleware) + { + app(PersistentMiddleware::class)->addPersistentMiddleware($middleware); + } + + function setPersistentMiddleware($middleware) + { + app(PersistentMiddleware::class)->setPersistentMiddleware($middleware); + } + + function getPersistentMiddleware() + { + return app(PersistentMiddleware::class)->getPersistentMiddleware(); + } + + function flushState() + { + trigger('flush-state'); + } + + function originalUrl() + { + if ($this->isLivewireRequest()) { + return url()->to($this->originalPath()); + } + + return url()->current(); + } + + function originalPath() + { + if ($this->isLivewireRequest()) { + $snapshot = json_decode(request('components.0.snapshot'), true); + + return data_get($snapshot, 'memo.path', 'POST'); + } + + return request()->path(); + } + + function originalMethod() + { + if ($this->isLivewireRequest()) { + $snapshot = json_decode(request('components.0.snapshot'), true); + + return data_get($snapshot, 'memo.method', 'POST'); + } + + return request()->method(); + } +} diff --git a/portman/lib/Livewire/LivewireServiceProvider.php b/portman/lib/Livewire/LivewireServiceProvider.php new file mode 100644 index 00000000..41340450 --- /dev/null +++ b/portman/lib/Livewire/LivewireServiceProvider.php @@ -0,0 +1,129 @@ +registerLivewireSingleton(); + $this->registerConfig(); + $this->bootEventBus(); + $this->registerMechanisms(); + } + + public function boot() + { + $this->bootMechanisms(); + $this->bootFeatures(); + } + + protected function registerLivewireSingleton() + { + $this->app->alias(LivewireManager::class, 'livewire'); + + $this->app->singleton(LivewireManager::class); + + app('livewire')->setProvider($this); + } + + protected function registerConfig() + { + $config = __DIR__.'/../config/livewire.php'; + + $this->publishes([$config => base_path('config/livewire.php')], ['livewire', 'livewire:config']); + + $this->mergeConfigFrom($config, 'livewire'); + } + + protected function bootEventBus() + { + app(EventBus::class)->boot(); + } + + protected function getMechanisms() + { + return [ + Mechanisms\PersistentMiddleware\PersistentMiddleware::class, + Mechanisms\HandleComponents\HandleComponents::class, + Mechanisms\HandleRequests\HandleRequests::class, + Mechanisms\FrontendAssets\FrontendAssets::class, + Mechanisms\ExtendBlade\ExtendBlade::class, + Mechanisms\CompileLivewireTags\CompileLivewireTags::class, + Mechanisms\ComponentRegistry::class, + Mechanisms\RenderComponent::class, + Mechanisms\DataStore::class, + ]; + } + + protected function registerMechanisms() + { + foreach ($this->getMechanisms() as $mechanism) { + app($mechanism)->register(); + } + } + + protected function bootMechanisms() + { + if (class_exists(AboutCommand::class) && class_exists(InstalledVersions::class)) { + AboutCommand::add('Livewire', [ + 'Livewire' => InstalledVersions::getPrettyVersion('livewire/livewire'), + ]); + } + + foreach ($this->getMechanisms() as $mechanism) { + app($mechanism)->boot(); + } + } + + protected function bootFeatures() + { + foreach([ + Features\SupportWireModelingNestedComponents\SupportWireModelingNestedComponents::class, + Features\SupportMultipleRootElementDetection\SupportMultipleRootElementDetection::class, + Features\SupportMorphAwareBladeCompilation\SupportMorphAwareBladeCompilation::class, + Features\SupportDisablingBackButtonCache\SupportDisablingBackButtonCache::class, + Features\SupportNestedComponentListeners\SupportNestedComponentListeners::class, + Features\SupportAutoInjectedAssets\SupportAutoInjectedAssets::class, + Features\SupportComputed\SupportLegacyComputedPropertySyntax::class, + Features\SupportNestingComponents\SupportNestingComponents::class, + Features\SupportCompiledWireKeys\SupportCompiledWireKeys::class, + Features\SupportScriptsAndAssets\SupportScriptsAndAssets::class, + Features\SupportBladeAttributes\SupportBladeAttributes::class, + Features\SupportConsoleCommands\SupportConsoleCommands::class, + Features\SupportPageComponents\SupportPageComponents::class, + Features\SupportReactiveProps\SupportReactiveProps::class, + Features\SupportReleaseTokens\SupportReleaseTokens::class, + Features\SupportFileDownloads\SupportFileDownloads::class, + Features\SupportJsEvaluation\SupportJsEvaluation::class, + Features\SupportQueryString\SupportQueryString::class, + Features\SupportFileUploads\SupportFileUploads::class, + Features\SupportTeleporting\SupportTeleporting::class, + Features\SupportLazyLoading\SupportLazyLoading::class, + Features\SupportFormObjects\SupportFormObjects::class, + Features\SupportAttributes\SupportAttributes::class, + Features\SupportPagination\SupportPagination::class, + Features\SupportValidation\SupportValidation::class, + Features\SupportIsolating\SupportIsolating::class, + Features\SupportRedirects\SupportRedirects::class, + Features\SupportStreaming\SupportStreaming::class, + Features\SupportNavigate\SupportNavigate::class, + Features\SupportEntangle\SupportEntangle::class, + Features\SupportLocales\SupportLocales::class, + Features\SupportTesting\SupportTesting::class, + Features\SupportModels\SupportModels::class, + Features\SupportEvents\SupportEvents::class, + + // Some features we want to have priority over others... + Features\SupportLifecycleHooks\SupportLifecycleHooks::class, + Features\SupportLegacyModels\SupportLegacyModels::class, + Features\SupportWireables\SupportWireables::class, + ] as $feature) { + app('livewire')->componentHook($feature); + } + + ComponentHookRegistry::boot(); + } +} diff --git a/portman/lib/Livewire/Mechanisms/CompileLivewireTags/CompileLivewireTags.php b/portman/lib/Livewire/Mechanisms/CompileLivewireTags/CompileLivewireTags.php new file mode 100644 index 00000000..f50ed376 --- /dev/null +++ b/portman/lib/Livewire/Mechanisms/CompileLivewireTags/CompileLivewireTags.php @@ -0,0 +1,13 @@ +precompiler(new LivewireTagPrecompiler); + } +} diff --git a/portman/lib/Livewire/Mechanisms/CompileLivewireTags/LivewireTagPrecompiler.php b/portman/lib/Livewire/Mechanisms/CompileLivewireTags/LivewireTagPrecompiler.php new file mode 100644 index 00000000..1b182df1 --- /dev/null +++ b/portman/lib/Livewire/Mechanisms/CompileLivewireTags/LivewireTagPrecompiler.php @@ -0,0 +1,92 @@ +getAttributesFromAttributeString($matches['attributes']); + + $keys = array_keys($attributes); + $values = array_values($attributes); + $attributesCount = count($attributes); + + for ($i=0; $i < $attributesCount; $i++) { + if ($keys[$i] === ':' && $values[$i] === 'true') { + if (isset($values[$i + 1]) && $values[$i + 1] === 'true') { + $attributes[$keys[$i + 1]] = '$'.$keys[$i + 1]; + unset($attributes[':']); + } + } + } + + // Convert all kebab-cased to camelCase. + $attributes = collect($attributes)->mapWithKeys(function ($value, $key) { + // Skip snake_cased attributes. + if (str($key)->contains('_')) return [$key => $value]; + + return [(string) str($key)->camel() => $value]; + })->toArray(); + + // Convert all snake_cased attributes to camelCase, and merge with + // existing attributes so both snake and camel are available. + $attributes = collect($attributes)->mapWithKeys(function ($value, $key) { + // Skip snake_cased attributes + if (! str($key)->contains('_')) return [$key => false]; + + return [(string) str($key)->camel() => $value]; + })->filter()->merge($attributes)->toArray(); + + $component = $matches[1]; + + if ($component === 'styles') return '@livewireStyles'; + if ($component === 'scripts') return '@livewireScripts'; + if ($component === 'dynamic-component' || $component === 'is') { + if (! isset($attributes['component']) && ! isset($attributes['is'])) { + throw new ComponentAttributeMissingOnDynamicComponentException; + } + + // Does not need quotes as resolved with quotes already. + $component = $attributes['component'] ?? $attributes['is']; + + unset($attributes['component'], $attributes['is']); + } else { + // Add single quotes to the component name to compile it as string in quotes + $component = "'{$component}'"; + } + + return $this->componentString($component, $attributes); + }, $value); + } + + protected function componentString(string $component, array $attributes) + { + if (isset($attributes['key']) || isset($attributes['wire:key'])) { + $key = $attributes['key'] ?? $attributes['wire:key']; + unset($attributes['key'], $attributes['wire:key']); + + return "@livewire({$component}, [".$this->attributesToString($attributes, escapeBound: false)."], key({$key}))"; + } + + return "@livewire({$component}, [".$this->attributesToString($attributes, escapeBound: false).'])'; + } + + protected function attributesToString(array $attributes, $escapeBound = true) + { + return collect($attributes) + ->map(function (string $value, string $attribute) use ($escapeBound) { + return $escapeBound && isset($this->boundAttributes[$attribute]) && $value !== 'true' && ! is_numeric($value) + ? "'{$attribute}' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute({$value})" + : "'{$attribute}' => {$value}"; + }) + ->implode(','); + } +} diff --git a/portman/lib/Livewire/Mechanisms/ComponentRegistry.php b/portman/lib/Livewire/Mechanisms/ComponentRegistry.php new file mode 100644 index 00000000..e3b848c0 --- /dev/null +++ b/portman/lib/Livewire/Mechanisms/ComponentRegistry.php @@ -0,0 +1,217 @@ +nonAliasedClasses[] = $name; + } else { + $this->aliases[$name] = $class; + } + } + + function new($nameOrClass, $id = null) + { + [$class, $name] = $this->getNameAndClass($nameOrClass); + + $component = new $class; + + $component->setId($id ?: str()->random(20)); + + $component->setName($name); + + // // Parameters passed in automatically set public properties by the same name... + // foreach ($params as $key => $value) { + // if (! property_exists($component, $key)) continue; + + // // Typed properties shouldn't be set back to "null". It will throw an error... + // if ((new \ReflectionProperty($component, $key))->getType() && is_null($value)) continue; + + // $component->$key = $value; + // } + + return $component; + } + + function isDiscoverable($classOrName) + { + if (is_object($classOrName)) { + $classOrName = get_class($classOrName); + } + + if (class_exists($name = $classOrName)) { + $name = $this->generateNameFromClass($classOrName); + } + + $class = $this->generateClassFromName($name); + + if (is_subclass_of($class, Component::class)) { + return true; + } + + return false; + } + + function getName($nameOrClassOrComponent) + { + [$class, $name] = $this->getNameAndClass($nameOrClassOrComponent); + + return $name; + } + + function getClass($nameOrClassOrComponent) + { + [$class, $name] = $this->getNameAndClass($nameOrClassOrComponent); + + return $class; + } + + function resolveMissingComponent($resolver) + { + $this->missingComponentResolvers[] = $resolver; + } + + protected function getNameAndClass($nameComponentOrClass) + { + // If a component itself was passed in, just take the class name... + $nameOrClass = is_object($nameComponentOrClass) ? $nameComponentOrClass::class : $nameComponentOrClass; + + // If a component class was passed in, use that... + if (is_subclass_of($nameOrClass, Component::class)) { + $class = $nameOrClass; + // Otherwise, assume it was a simple name... + } else { + $class = $this->nameToClass($nameOrClass); + + // If class can't be found, see if there is an index component in a subfolder... + if(! class_exists($class)) { + $class = $class . '\\Index'; + } + + if(! class_exists($class)) { + foreach ($this->missingComponentResolvers as $resolve) { + if ($resolved = $resolve($nameOrClass)) { + $this->component($nameOrClass, $resolved); + + $class = $this->aliases[$nameOrClass]; + + break; + } + } + } + } + + // Now that we have a class, we can check that it's actually a Livewire component... + if (! is_subclass_of($class, Component::class)) { + throw new ComponentNotFoundException( + "Unable to find component: [{$nameOrClass}]" + ); + } + + // Convert it to a name even if a name was passed in to make sure we're using deterministic names... + $name = $this->classToName($class); + + return [$class, $name]; + } + + protected function nameToClass($name) + { + // Check the aliases... + if (isset($this->aliases[$name])) { + if (is_object($this->aliases[$name])) return $this->aliases[$name]::class; + + return $this->aliases[$name]; + } + + // Hash check the non-aliased classes... + foreach ($this->nonAliasedClasses as $class) { + if (crc32($class) === $name) { + return $class; + } + } + + // Reverse generate a class from a name... + return $this->generateClassFromName($name); + } + + protected function classToName($class) + { + // Check the aliases... + $resolvedAliases = array_map(fn ($i) => is_object($i) ? get_class($i) : $i, $this->aliases); + + if ($name = array_search($class, $resolvedAliases)) return $name; + + // Check existance in non-aliased classes and hash... + foreach ($this->nonAliasedClasses as $oneOff) { + if (crc32($oneOff) === $hash = crc32($class)) { + return $hash; + } + } + + // Generate name from class... + return $this->generateNameFromClass($class); + } + + protected function generateClassFromName($name) + { + $rootNamespace = config('livewire.class_namespace'); + + $class = collect(str($name)->explode('.')) + ->map(fn ($segment) => (string) str($segment)->studly()) + ->join('\\'); + + if (empty($rootNamespace)) { + return $class; + } + + return '\\' . $rootNamespace . '\\' . $class; + } + + protected function generateNameFromClass($class) + { + $namespace = str_replace( + ['/', '\\'], + '.', + trim(trim(config('livewire.class_namespace')), '\\') + ); + + $class = str_replace( + ['/', '\\'], + '.', + trim(trim($class, '/'), '\\') + ); + + $namespace = collect(explode('.', $namespace)) + ->map(fn ($i) => \Illuminate\Support\Str::kebab($i)) + ->implode('.'); + + $fullName = str(collect(explode('.', $class)) + ->map(fn ($i) => \Illuminate\Support\Str::kebab($i)) + ->implode('.')); + + if ($fullName->startsWith('.')) { + $fullName = $fullName->substr(1); + } + + // If using an index component in a sub folder, remove the '.index' so the name is the subfolder name... + if ($fullName->endsWith('.index')) { + $fullName = $fullName->replaceLast('.index', ''); + } + + if ($fullName->startsWith($namespace)) { + return (string) $fullName->substr(strlen($namespace) + 1); + } + + return (string) $fullName; + } +} diff --git a/portman/lib/Livewire/Mechanisms/DataStore.php b/portman/lib/Livewire/Mechanisms/DataStore.php new file mode 100644 index 00000000..e24afbab --- /dev/null +++ b/portman/lib/Livewire/Mechanisms/DataStore.php @@ -0,0 +1,116 @@ +lookup = new WeakMap; + } + + function set($instance, $key, $value) + { + if (! isset($this->lookup[$instance])) { + $this->lookup[$instance] = []; + } + + $this->lookup[$instance][$key] = $value; + } + + function has($instance, $key, $iKey = null) { + if (! isset($this->lookup[$instance])) { + return false; + } + + if (! isset($this->lookup[$instance][$key])) { + return false; + } + + if ($iKey !== null) { + return !! ($this->lookup[$instance][$key][$iKey] ?? false); + } + + return true; + } + + function get($instance, $key, $default = null) + { + if (! isset($this->lookup[$instance])) { + return value($default); + } + + if (! isset($this->lookup[$instance][$key])) { + return value($default); + } + + return $this->lookup[$instance][$key]; + } + + function find($instance, $key, $iKey = null, $default = null) + { + if (! isset($this->lookup[$instance])) { + return value($default); + } + + if (! isset($this->lookup[$instance][$key])) { + return value($default); + } + + if ($iKey !== null && ! isset($this->lookup[$instance][$key][$iKey])) { + return value($default); + } + + return $iKey !== null + ? $this->lookup[$instance][$key][$iKey] + : $this->lookup[$instance][$key]; + } + + function push($instance, $key, $value, $iKey = null) + { + if (! isset($this->lookup[$instance])) { + $this->lookup[$instance] = []; + } + + if (! isset($this->lookup[$instance][$key])) { + $this->lookup[$instance][$key] = []; + } + + if ($iKey) { + $this->lookup[$instance][$key][$iKey] = $value; + } else { + $this->lookup[$instance][$key][] = $value; + } + } + + function unset($instance, $key, $iKey = null) + { + if (! isset($this->lookup[$instance])) { + return; + } + + if (! isset($this->lookup[$instance][$key])) { + return; + } + + if ($iKey !== null) { + // Set a local variable to avoid the "indirect modification" error. + $keyValue = $this->lookup[$instance][$key]; + + unset($keyValue[$iKey]); + + $this->lookup[$instance][$key] = $keyValue; + } else { + // Set a local variable to avoid the "indirect modification" error. + $instanceValue = $this->lookup[$instance]; + + unset($instanceValue[$key]); + + $this->lookup[$instance] = $instanceValue; + } + } +} diff --git a/portman/lib/Livewire/Mechanisms/ExtendBlade/DeterministicBladeKeys.php b/portman/lib/Livewire/Mechanisms/ExtendBlade/DeterministicBladeKeys.php new file mode 100644 index 00000000..12195527 --- /dev/null +++ b/portman/lib/Livewire/Mechanisms/ExtendBlade/DeterministicBladeKeys.php @@ -0,0 +1,48 @@ +currentPathHash) { + throw new \Exception('Latest compiled component path not found.'); + } + + $path = $this->currentPathHash; + $count = $this->counter(); + + // $key = "lw-[hash of Blade view path]-[current @livewire directive count]" + return 'lw-' . $this->currentPathHash . '-' . $count; + } + + public function counter() + { + if (! isset($this->countersByPath[$this->currentPathHash])) { + $this->countersByPath[$this->currentPathHash] = 0; + } + + return $this->countersByPath[$this->currentPathHash]++; + } + + public function hookIntoCompile(BladeCompiler $compiler, $viewContent) + { + $path = $compiler->getPath(); + + // If there is no path this means this Blade is being compiled + // with ->compileString(...) directly instead of ->compile() + // therefore we'll generate a hash of the contents instead + if ($path === null) { + $path = $viewContent; + } + + $this->currentPathHash = crc32($path); + } +} diff --git a/portman/lib/Livewire/Mechanisms/ExtendBlade/ExtendBlade.php b/portman/lib/Livewire/Mechanisms/ExtendBlade/ExtendBlade.php new file mode 100644 index 00000000..e035baa7 --- /dev/null +++ b/portman/lib/Livewire/Mechanisms/ExtendBlade/ExtendBlade.php @@ -0,0 +1,145 @@ + "window.Livewire.find('{{ \$_instance->getId() }}')"); + + on('render', function ($target, $view) { + $this->startLivewireRendering($target); + + $undo = $this->livewireifyBladeCompiler(); + + $this->renderCounter++; + + return function ($html) use ($view, $undo, $target) { + $this->endLivewireRendering(); + + $this->renderCounter--; + + if ($this->renderCounter === 0) { + $undo(); + } + + return $html; + }; + }); + + // This is a custom view engine that gets used when rendering + // Livewire views. Things like letting certain exceptions bubble + // to the handler, and registering custom directives like: "@this". + app()->make('view.engine.resolver')->register('blade', function () { + return new ExtendedCompilerEngine(app('blade.compiler')); + }); + + app()->singleton(DeterministicBladeKeys::class); + + // Reset this singleton between tests and Octane requests... + on('flush-state', function () { + app()->singleton(DeterministicBladeKeys::class); + + static::$livewireComponents = []; + }); + + // We're using "precompiler" as a hook for the point in time when + // Laravel compiles a Blade view... + app('blade.compiler')->precompiler(function ($value) { + app(DeterministicBladeKeys::class)->hookIntoCompile(app('blade.compiler'), $value); + + return $value; + }); + } + + function livewireOnlyDirective($name, $handler) + { + $this->directives[$name] = $handler; + } + + function livewireOnlyPrecompiler($handler) + { + $this->precompilers[] = $handler; + } + + function livewireifyBladeCompiler() { + $removals = []; + + if ($this->renderCounter === 0) { + $customDirectives = app('blade.compiler')->getCustomDirectives(); + $precompilers = invade(app('blade.compiler'))->precompilers; + + foreach ($this->directives as $name => $handler) { + if (! isset($customDirectives[$name])) { + $customDirectives[$name] = $handler; + + invade(app('blade.compiler'))->customDirectives = $customDirectives; + + $removals[] = function () use ($name) { + $customDirectives = app('blade.compiler')->getCustomDirectives(); + + unset($customDirectives[$name]); + + invade(app('blade.compiler'))->customDirectives = $customDirectives; + }; + } + } + + foreach ($this->precompilers as $handler) { + if (array_search($handler, $precompilers) === false) { + array_unshift($precompilers, $handler); + + invade(app('blade.compiler'))->precompilers = $precompilers; + + $removals[] = function () use ($handler) { + $precompilers = invade(app('blade.compiler'))->precompilers; + + $index = array_search($handler, $precompilers); + + if ($index === false) return; + + unset($precompilers[$index]); + + invade(app('blade.compiler'))->precompilers = $precompilers; + }; + } + } + } + + return function () use ($removals) { + while ($removals) array_pop($removals)(); + }; + } +} diff --git a/portman/lib/Livewire/Mechanisms/ExtendBlade/ExtendedCompilerEngine.php b/portman/lib/Livewire/Mechanisms/ExtendBlade/ExtendedCompilerEngine.php new file mode 100644 index 00000000..dfd21ac2 --- /dev/null +++ b/portman/lib/Livewire/Mechanisms/ExtendBlade/ExtendedCompilerEngine.php @@ -0,0 +1,76 @@ +handleViewException($e, $obLevel); + } + + return ltrim(ob_get_clean()); + } + + // Errors thrown while a view is rendering are caught by the Blade + // compiler and wrapped in an "ErrorException". This makes Livewire errors + // harder to read, AND causes issues like `abort(404)` not actually working. + protected function handleViewException(\Throwable $e, $obLevel) + { + if ($this->shouldBypassExceptionForLivewire($e, $obLevel)) { + // This is because there is no "parent::parent::". + \Illuminate\View\Engines\PhpEngine::handleViewException($e, $obLevel); + + return; + } + + parent::handleViewException($e, $obLevel); + } + + public function shouldBypassExceptionForLivewire(\Throwable $e, $obLevel) + { + $uses = array_flip(class_uses_recursive($e)); + + return ( + // Don't wrap "abort(403)". + $e instanceof \Illuminate\Auth\Access\AuthorizationException + // Don't wrap "abort(404)". + || $e instanceof \Symfony\Component\HttpKernel\Exception\NotFoundHttpException + // Don't wrap "abort(500)". + || $e instanceof \Symfony\Component\HttpKernel\Exception\HttpException + // Don't wrap most Livewire exceptions. + || isset($uses[\Livewire\Exceptions\BypassViewHandler::class]) + ); + } +} diff --git a/portman/lib/Livewire/Mechanisms/FrontendAssets/FrontendAssets.php b/portman/lib/Livewire/Mechanisms/FrontendAssets/FrontendAssets.php new file mode 100644 index 00000000..16238753 --- /dev/null +++ b/portman/lib/Livewire/Mechanisms/FrontendAssets/FrontendAssets.php @@ -0,0 +1,276 @@ +setScriptRoute(function ($handle) { + return config('app.debug') + ? Route::get('/livewire/livewire.js', $handle) + : Route::get('/livewire/livewire.min.js', $handle); + }); + + Route::get('/livewire/livewire.min.js.map', [static::class, 'maps']); + + Blade::directive('livewireScripts', [static::class, 'livewireScripts']); + Blade::directive('livewireScriptConfig', [static::class, 'livewireScriptConfig']); + Blade::directive('livewireStyles', [static::class, 'livewireStyles']); + + app('livewire')->provide(function() { + $this->publishes( + [ + __DIR__.'/../../../dist' => public_path('vendor/livewire'), + ], + 'livewire:assets', + ); + }); + + on('flush-state', function () { + $instance = app(static::class); + + $instance->hasRenderedScripts = false; + $instance->hasRenderedStyles = false; + }); + } + + function useScriptTagAttributes($attributes) + { + $this->scriptTagAttributes = array_merge($this->scriptTagAttributes, $attributes); + } + + function setScriptRoute($callback) + { + $route = $callback([self::class, 'returnJavaScriptAsFile']); + + $this->javaScriptRoute = $route; + } + + public static function livewireScripts($expression) + { + return '{!! \Livewire\Mechanisms\FrontendAssets\FrontendAssets::scripts('.$expression.') !!}'; + } + + public static function livewireScriptConfig($expression) + { + return '{!! \Livewire\Mechanisms\FrontendAssets\FrontendAssets::scriptConfig('.$expression.') !!}'; + } + + public static function livewireStyles($expression) + { + return '{!! \Livewire\Mechanisms\FrontendAssets\FrontendAssets::styles('.$expression.') !!}'; + } + + public function returnJavaScriptAsFile() + { + return Utils::pretendResponseIsFile( + config('app.debug') + ? __DIR__.'/../../../dist/livewire.js' + : __DIR__.'/../../../dist/livewire.min.js' + ); + } + + public function maps() + { + return Utils::pretendResponseIsFile(__DIR__.'/../../../dist/livewire.min.js.map'); + } + + /** + * @return string + */ + public static function styles($options = []) + { + app(static::class)->hasRenderedStyles = true; + + $nonce = static::nonce($options); + $nonce = $nonce ? "{$nonce} data-livewire-style" : ''; + + $progressBarColor = config('livewire.navigate.progress_bar_color', '#2299dd'); + + // Note: the attribute selectors are "doubled" so that they don't get overriden when Tailwind's CDN loads a script tag + // BELOW the one Livewire injects... + $html = << + + HTML; + + return static::minify($html); + } + + /** + * @return string + */ + public static function scripts($options = []) + { + app(static::class)->hasRenderedScripts = true; + + $debug = config('app.debug'); + + $scripts = static::js($options); + + // HTML Label. + $html = $debug ? [''] : []; + + $html[] = $scripts; + + return implode("\n", $html); + } + + public static function js($options) + { + // Use the default endpoint... + $url = app(static::class)->javaScriptRoute->uri; + + // Use the configured one... + $url = config('livewire.asset_url') ?: $url; + + // Use the legacy passed in one... + $url = $options['asset_url'] ?? $url; + + // Use the new passed in one... + $url = $options['url'] ?? $url; + + $url = rtrim($url, '/'); + + $url = (string) str($url)->when(! str($url)->isUrl(), fn($url) => $url->start('/')); + + // Add the build manifest hash to it... + $manifest = json_decode(file_get_contents(__DIR__.'/../../../dist/manifest.json'), true); + $versionHash = $manifest['/livewire.js']; + $url = "{$url}?id={$versionHash}"; + + $token = app()->has('session.store') ? csrf_token() : ''; + + $assetWarning = null; + + $nonce = static::nonce($options); + + [$url, $assetWarning] = static::usePublishedAssetsIfAvailable($url, $manifest, $nonce); + + $progressBar = config('livewire.navigate.show_progress_bar', true) ? '' : 'data-no-progress-bar'; + + $updateUri = app('livewire')->getUpdateUri(); + + $extraAttributes = Utils::stringifyHtmlAttributes( + app(static::class)->scriptTagAttributes, + ); + + return << + HTML; + } + + public static function scriptConfig($options = []) + { + app(static::class)->hasRenderedScripts = true; + + $nonce = static::nonce($options); + + $progressBar = config('livewire.navigate.show_progress_bar', true) ? '' : 'data-no-progress-bar'; + + $attributes = json_encode([ + 'csrf' => app()->has('session.store') ? csrf_token() : '', + 'uri' => app('livewire')->getUpdateUri(), + 'progressBar' => $progressBar, + 'nonce' => isset($options['nonce']) ? $options['nonce'] : '', + ]); + + return <<window.livewireScriptConfig = {$attributes}; + HTML; + } + + protected static function usePublishedAssetsIfAvailable($url, $manifest, $nonce) + { + $assetWarning = null; + + // Check to see if static assets have been published... + if (! file_exists(public_path('vendor/livewire/manifest.json'))) { + return [$url, $assetWarning]; + } + + $publishedManifest = json_decode(file_get_contents(public_path('vendor/livewire/manifest.json')), true); + $versionedFileName = $publishedManifest['/livewire.js']; + + $fileName = config('app.debug') ? '/livewire.js' : '/livewire.min.js'; + + $versionedFileName = "{$fileName}?id={$versionedFileName}"; + + $assertUrl = config('livewire.asset_url') + ?? (app('livewire')->isRunningServerless() + ? rtrim(config('app.asset_url'), '/')."/vendor/livewire$versionedFileName" + : url("vendor/livewire{$versionedFileName}") + ); + + $url = $assertUrl; + + if ($manifest !== $publishedManifest) { + $assetWarning = << + console.warn('Livewire: The published Livewire assets are out of date\\n See: https://livewire.laravel.com/docs/installation#publishing-livewires-frontend-assets') + \n + HTML; + } + + return [$url, $assetWarning]; + } + + protected static function minify($subject) + { + return preg_replace('~(\v|\t|\s{2,})~m', '', $subject); + } + + protected static function nonce($options = []) + { + $nonce = $options['nonce'] ?? Vite::cspNonce(); + + return $nonce ? "nonce=\"{$nonce}\"" : ''; + } +} diff --git a/portman/lib/Livewire/Mechanisms/HandleComponents/BaseRenderless.php b/portman/lib/Livewire/Mechanisms/HandleComponents/BaseRenderless.php new file mode 100644 index 00000000..a8a15fc3 --- /dev/null +++ b/portman/lib/Livewire/Mechanisms/HandleComponents/BaseRenderless.php @@ -0,0 +1,14 @@ +component->skipRender(); + } +} diff --git a/portman/lib/Livewire/Mechanisms/HandleComponents/Checksum.php b/portman/lib/Livewire/Mechanisms/HandleComponents/Checksum.php new file mode 100644 index 00000000..86ae42e4 --- /dev/null +++ b/portman/lib/Livewire/Mechanisms/HandleComponents/Checksum.php @@ -0,0 +1,36 @@ +getKey(); + + // Remove the children from the memo in the snapshot, as it is actually Ok + // if the "children" tracking is tampered with. This way JavaScript can + // modify children as it needs to for dom-diffing purposes... + unset($snapshot['memo']['children']); + + $checksum = hash_hmac('sha256', json_encode($snapshot), $hashKey); + + trigger('checksum.generate', $checksum, $snapshot); + + return $checksum; + } +} diff --git a/portman/lib/Livewire/Mechanisms/HandleComponents/ComponentContext.php b/portman/lib/Livewire/Mechanisms/HandleComponents/ComponentContext.php new file mode 100644 index 00000000..4be9d69f --- /dev/null +++ b/portman/lib/Livewire/Mechanisms/HandleComponents/ComponentContext.php @@ -0,0 +1,60 @@ +mounting; + } + + public function addEffect($key, $value) + { + if (is_array($key)) { + foreach ($key as $iKey => $iValue) $this->addEffect($iKey, $iValue); + + return; + } + + $this->effects[$key] = $value; + } + + public function pushEffect($key, $value, $iKey = null) + { + if (! isset($this->effects[$key])) $this->effects[$key] = []; + + if ($iKey) { + $this->effects[$key][$iKey] = $value; + } else { + $this->effects[$key][] = $value; + } + } + + public function addMemo($key, $value) + { + $this->memo[$key] = $value; + } + + public function pushMemo($key, $value, $iKey = null) + { + if (! isset($this->memo[$key])) $this->memo[$key] = []; + + if ($iKey) { + $this->memo[$key][$iKey] = $value; + } else { + $this->memo[$key][] = $value; + } + } +} diff --git a/portman/lib/Livewire/Mechanisms/HandleComponents/CorruptComponentPayloadException.php b/portman/lib/Livewire/Mechanisms/HandleComponents/CorruptComponentPayloadException.php new file mode 100644 index 00000000..bd495049 --- /dev/null +++ b/portman/lib/Livewire/Mechanisms/HandleComponents/CorruptComponentPayloadException.php @@ -0,0 +1,18 @@ +propertySynthesizers, $class); + } + } + + public function mount($name, $params = [], $key = null) + { + $parent = app('livewire')->current(); + + if ($html = $this->shortCircuitMount($name, $params, $key, $parent)) return $html; + + $component = app('livewire')->new($name); + + $this->pushOntoComponentStack($component); + + $context = new ComponentContext($component, mounting: true); + + if (config('app.debug')) $start = microtime(true); + $finish = trigger('mount', $component, $params, $key, $parent); + if (config('app.debug')) trigger('profile', 'mount', $component->getId(), [$start, microtime(true)]); + + if (config('app.debug')) $start = microtime(true); + $html = $this->render($component, '
'); + if (config('app.debug')) trigger('profile', 'render', $component->getId(), [$start, microtime(true)]); + + if (config('app.debug')) $start = microtime(true); + trigger('dehydrate', $component, $context); + + $snapshot = $this->snapshot($component, $context); + if (config('app.debug')) trigger('profile', 'dehydrate', $component->getId(), [$start, microtime(true)]); + + trigger('destroy', $component, $context); + + $html = Utils::insertAttributesIntoHtmlRoot($html, [ + 'wire:snapshot' => $snapshot, + 'wire:effects' => $context->effects, + ]); + + $this->popOffComponentStack(); + + return $finish($html, $snapshot); + } + + protected function shortCircuitMount($name, $params, $key, $parent) + { + $newHtml = null; + + trigger('pre-mount', $name, $params, $key, $parent, function ($html) use (&$newHtml) { + $newHtml = $html; + }); + + return $newHtml; + } + + public function update($snapshot, $updates, $calls) + { + $data = $snapshot['data']; + $memo = $snapshot['memo']; + + if (config('app.debug')) $start = microtime(true); + [ $component, $context ] = $this->fromSnapshot($snapshot); + + $this->pushOntoComponentStack($component); + + trigger('hydrate', $component, $memo, $context); + + $this->updateProperties($component, $updates, $data, $context); + if (config('app.debug')) trigger('profile', 'hydrate', $component->getId(), [$start, microtime(true)]); + + $this->callMethods($component, $calls, $context); + + if (config('app.debug')) $start = microtime(true); + if ($html = $this->render($component)) { + $context->addEffect('html', $html); + if (config('app.debug')) trigger('profile', 'render', $component->getId(), [$start, microtime(true)]); + } + + if (config('app.debug')) $start = microtime(true); + trigger('dehydrate', $component, $context); + + $snapshot = $this->snapshot($component, $context); + if (config('app.debug')) trigger('profile', 'dehydrate', $component->getId(), [$start, microtime(true)]); + + trigger('destroy', $component, $context); + + $this->popOffComponentStack(); + + return [ $snapshot, $context->effects ]; + } + + public function fromSnapshot($snapshot) + { + Checksum::verify($snapshot); + + trigger('snapshot-verified', $snapshot); + + $data = $snapshot['data']; + $name = $snapshot['memo']['name']; + $id = $snapshot['memo']['id']; + + $component = app('livewire')->new($name, id: $id); + + $context = new ComponentContext($component); + + $this->hydrateProperties($component, $data, $context); + + return [ $component, $context ]; + } + + public function snapshot($component, $context = null) + { + $context ??= new ComponentContext($component); + + $data = $this->dehydrateProperties($component, $context); + + $snapshot = [ + 'data' => $data, + 'memo' => [ + 'id' => $component->getId(), + 'name' => $component->getName(), + ...$context->memo, + ], + ]; + + $snapshot['checksum'] = Checksum::generate($snapshot); + + return $snapshot; + } + + protected function dehydrateProperties($component, $context) + { + $data = Utils::getPublicPropertiesDefinedOnSubclass($component); + + foreach ($data as $key => $value) { + $data[$key] = $this->dehydrate($value, $context, $key); + } + + return $data; + } + + protected function dehydrate($target, $context, $path) + { + if (Utils::isAPrimitive($target)) return $target; + + $synth = $this->propertySynth($target, $context, $path); + + [ $data, $meta ] = $synth->dehydrate($target, function ($name, $child) use ($context, $path) { + return $this->dehydrate($child, $context, "{$path}.{$name}"); + }); + + $meta['s'] = $synth::getKey(); + + return [ $data, $meta ]; + } + + protected function hydrateProperties($component, $data, $context) + { + foreach ($data as $key => $value) { + if (! property_exists($component, $key)) continue; + + $child = $this->hydrate($value, $context, $key); + + // Typed properties shouldn't be set back to "null". It will throw an error... + if ((new \ReflectionProperty($component, $key))->getType() && is_null($child)) continue; + + $component->$key = $child; + } + } + + protected function hydrate($valueOrTuple, $context, $path) + { + if (! Utils::isSyntheticTuple($value = $tuple = $valueOrTuple)) return $value; + + [$value, $meta] = $tuple; + + // Nested properties get set as `__rm__` when they are removed. We don't want to hydrate these. + if ($this->isRemoval($value) && str($path)->contains('.')) { + return $value; + } + + $synth = $this->propertySynth($meta['s'], $context, $path); + + return $synth->hydrate($value, $meta, function ($name, $child) use ($context, $path) { + return $this->hydrate($child, $context, "{$path}.{$name}"); + }); + } + + protected function hydratePropertyUpdate($valueOrTuple, $context, $path) + { + if (! Utils::isSyntheticTuple($value = $tuple = $valueOrTuple)) return $value; + + [$value, $meta] = $tuple; + + // Nested properties get set as `__rm__` when they are removed. We don't want to hydrate these. + if ($this->isRemoval($value) && str($path)->contains('.')) { + return $value; + } + + $synth = $this->propertySynth($meta['s'], $context, $path); + + return $synth->hydrate($value, $meta, function ($name, $child) { + return $child; + }); + } + + protected function render($component, $default = null) + { + if ($html = store($component)->get('skipRender', false)) { + $html = value(is_string($html) ? $html : $default); + + if (! $html) return; + + return Utils::insertAttributesIntoHtmlRoot($html, [ + 'wire:id' => $component->getId(), + ]); + } + + [ $view, $properties ] = $this->getView($component); + + return $this->trackInRenderStack($component, function () use ($component, $view, $properties) { + $finish = trigger('render', $component, $view, $properties); + + $revertA = Utils::shareWithViews('__livewire', $component); + $revertB = Utils::shareWithViews('_instance', $component); // @deprecated + + $viewContext = new ViewContext; + + $html = $view->render(function ($view) use ($viewContext) { + // Extract leftover slots, sections, and pushes before they get flushed... + $viewContext->extractFromEnvironment($view->getFactory()); + }); + + $revertA(); $revertB(); + + $html = Utils::insertAttributesIntoHtmlRoot($html, [ + 'wire:id' => $component->getId(), + ]); + + $replaceHtml = function ($newHtml) use (&$html) { + $html = $newHtml; + }; + + $html = $finish($html, $replaceHtml, $viewContext); + + return $html; + }); + } + + protected function getView($component) + { + $viewPath = config('livewire.view_path', resource_path('views/livewire')); + + $dotName = $component->getName(); + + $fileName = str($dotName)->replace('.', '/')->__toString(); + + $viewOrString = method_exists($component, 'render') + ? wrap($component)->render() + : View::file($viewPath . '/' . $fileName . '.blade.php'); + + $properties = Utils::getPublicPropertiesDefinedOnSubclass($component); + + $view = Utils::generateBladeView($viewOrString, $properties); + + return [ $view, $properties ]; + } + + protected function trackInRenderStack($component, $callback) + { + array_push(static::$renderStack, $component); + + return tap($callback(), function () { + array_pop(static::$renderStack); + }); + } + + protected function updateProperties($component, $updates, $data, $context) + { + $finishes = []; + + foreach ($updates as $path => $value) { + $value = $this->hydrateForUpdate($data, $path, $value, $context); + + // We only want to run "updated" hooks after all properties have + // been updated so that each individual hook has the ability + // to overwrite the updated states of other properties... + $finishes[] = $this->updateProperty($component, $path, $value, $context); + } + + foreach ($finishes as $finish) { + $finish(); + } + } + + public function updateProperty($component, $path, $value, $context) + { + $segments = explode('.', $path); + + $property = array_shift($segments); + + $finish = trigger('update', $component, $path, $value); + + // Ensure that it's a public property, not on the base class first... + if (! in_array($property, array_keys(Utils::getPublicPropertiesDefinedOnSubclass($component)))) { + throw new PublicPropertyNotFoundException($property, $component->getName()); + } + + // If this isn't a "deep" set, set it directly, otherwise we have to + // recursively get up and set down the value through the synths... + if (empty($segments)) { + $this->setComponentPropertyAwareOfTypes($component, $property, $value); + } else { + $propertyValue = $component->$property; + + $this->setComponentPropertyAwareOfTypes($component, $property, + $this->recursivelySetValue($property, $propertyValue, $value, $segments, 0, $context) + ); + } + + return $finish; + } + + protected function hydrateForUpdate($raw, $path, $value, $context) + { + $meta = $this->getMetaForPath($raw, $path); + + // If we have meta data already for this property, let's use that to get a synth... + if ($meta) { + return $this->hydratePropertyUpdate([$value, $meta], $context, $path); + } + + // If we don't, let's check to see if it's a typed property and fetch the synth that way... + $parent = str($path)->contains('.') + ? data_get($context->component, str($path)->beforeLast('.')->toString()) + : $context->component; + + $childKey = str($path)->afterLast('.'); + + if ($parent && is_object($parent) && property_exists($parent, $childKey) && Utils::propertyIsTyped($parent, $childKey)) { + $type = Utils::getProperty($parent, $childKey)->getType(); + + $types = $type instanceof ReflectionUnionType ? $type->getTypes() : [$type]; + + foreach ($types as $type) { + $synth = $this->getSynthesizerByType($type->getName(), $context, $path); + + if ($synth) return $synth->hydrateFromType($type->getName(), $value); + } + } + + return $value; + } + + protected function getMetaForPath($raw, $path) + { + $segments = explode('.', $path); + + $first = array_shift($segments); + + [$data, $meta] = Utils::isSyntheticTuple($raw) ? $raw : [$raw, null]; + + if ($path !== '') { + $value = $data[$first] ?? null; + + return $this->getMetaForPath($value, implode('.', $segments)); + } + + return $meta; + } + + protected function recursivelySetValue($baseProperty, $target, $leafValue, $segments, $index = 0, $context = null) + { + $isLastSegment = count($segments) === $index + 1; + + $property = $segments[$index]; + + $path = implode('.', array_slice($segments, 0, $index + 1)); + + $synth = $this->propertySynth($target, $context, $path); + + if ($isLastSegment) { + $toSet = $leafValue; + } else { + $propertyTarget = $synth->get($target, $property); + + // "$path" is a dot-notated key. This means we may need to drill + // down and set a value on a deeply nested object. That object + // may not exist, so let's find the first one that does... + + // Here's we've determined we're trying to set a deeply nested + // value on an object/array that doesn't exist, so we need + // to build up that non-existant nesting structure first. + if ($propertyTarget === null) $propertyTarget = []; + + $toSet = $this->recursivelySetValue($baseProperty, $propertyTarget, $leafValue, $segments, $index + 1, $context); + } + + $method = ($this->isRemoval($leafValue) && $isLastSegment) ? 'unset' : 'set'; + + $pathThusFar = collect([$baseProperty, ...$segments])->slice(0, $index + 1)->join('.'); + $fullPath = collect([$baseProperty, ...$segments])->join('.'); + + $synth->$method($target, $property, $toSet, $pathThusFar, $fullPath); + + return $target; + } + + protected function setComponentPropertyAwareOfTypes($component, $property, $value) + { + try { + $component->$property = $value; + } catch (\TypeError $e) { + // If an "int" is being set to empty string, unset the property (making it null). + // This is common in the case of `wire:model`ing an int to a text field... + // If a value is being set to "null", do the same... + if ($value === '' || $value === null) { + unset($component->$property); + } else { + throw $e; + } + } + } + + protected function callMethods($root, $calls, $context) + { + $returns = []; + + foreach ($calls as $idx => $call) { + $method = $call['method']; + $params = $call['params']; + + + $earlyReturnCalled = false; + $earlyReturn = null; + $returnEarly = function ($return = null) use (&$earlyReturnCalled, &$earlyReturn) { + $earlyReturnCalled = true; + $earlyReturn = $return; + }; + + $finish = trigger('call', $root, $method, $params, $context, $returnEarly); + + if ($earlyReturnCalled) { + $returns[] = $finish($earlyReturn); + + continue; + } + + $methods = Utils::getPublicMethodsDefinedBySubClass($root); + + // Also remove "render" from the list... + $methods = array_values(array_diff($methods, ['render'])); + + // @todo: put this in a better place: + $methods[] = '__dispatch'; + + if (! in_array($method, $methods)) { + throw new MethodNotFoundException($method); + } + + if (config('app.debug')) $start = microtime(true); + $return = wrap($root)->{$method}(...$params); + if (config('app.debug')) trigger('profile', 'call'.$idx, $root->getId(), [$start, microtime(true)]); + + $returns[] = $finish($return); + } + + $context->addEffect('returns', $returns); + } + + public function findSynth($keyOrTarget, $component): ?Synth + { + $context = new ComponentContext($component); + try { + return $this->propertySynth($keyOrTarget, $context, null); + } catch (\Exception $e) { + return null; + } + } + + public function propertySynth($keyOrTarget, $context, $path): Synth + { + return is_string($keyOrTarget) + ? $this->getSynthesizerByKey($keyOrTarget, $context, $path) + : $this->getSynthesizerByTarget($keyOrTarget, $context, $path); + } + + protected function getSynthesizerByKey($key, $context, $path) + { + foreach ($this->propertySynthesizers as $synth) { + if ($synth::getKey() === $key) { + return new $synth($context, $path); + } + } + + throw new \Exception('No synthesizer found for key: "'.$key.'"'); + } + + protected function getSynthesizerByTarget($target, $context, $path) + { + foreach ($this->propertySynthesizers as $synth) { + if ($synth::match($target)) { + return new $synth($context, $path); + } + } + + throw new \Exception('Property type not supported in Livewire for property: ['.json_encode($target).']'); + } + + protected function getSynthesizerByType($type, $context, $path) + { + foreach ($this->propertySynthesizers as $synth) { + if ($synth::matchByType($type)) { + return new $synth($context, $path); + } + } + + return null; + } + + protected function pushOntoComponentStack($component) + { + array_push($this::$componentStack, $component); + } + + protected function popOffComponentStack() + { + array_pop($this::$componentStack); + } + + protected function isRemoval($value) { + return $value === '__rm__'; + } +} diff --git a/portman/lib/Livewire/Mechanisms/HandleComponents/Synthesizers/ArraySynth.php b/portman/lib/Livewire/Mechanisms/HandleComponents/Synthesizers/ArraySynth.php new file mode 100644 index 00000000..23ed08e2 --- /dev/null +++ b/portman/lib/Livewire/Mechanisms/HandleComponents/Synthesizers/ArraySynth.php @@ -0,0 +1,43 @@ + $child) { + $target[$key] = $dehydrateChild($key, $child); + } + + return [$target, []]; + } + + function hydrate($value, $meta, $hydrateChild) { + // If we are "hydrating" a value about to be used in an update, + // Let's make sure it's actually an array before try to set it. + // This is most common in the case of "__rm__" values, but also + // applies to any non-array value... + if (! is_array($value)) { + return $value; + } + + foreach ($value as $key => $child) { + $value[$key] = $hydrateChild($key, $child); + } + + return $value; + } + + function set(&$target, $key, $value) { + $target[$key] = $value; + } + + function unset(&$target, $key) { + unset($target[$key]); + } +} diff --git a/portman/lib/Livewire/Mechanisms/HandleComponents/Synthesizers/CarbonSynth.php b/portman/lib/Livewire/Mechanisms/HandleComponents/Synthesizers/CarbonSynth.php new file mode 100644 index 00000000..321c9b60 --- /dev/null +++ b/portman/lib/Livewire/Mechanisms/HandleComponents/Synthesizers/CarbonSynth.php @@ -0,0 +1,52 @@ + DateTime::class, + 'nativeImmutable' => DateTimeImmutable::class, + 'carbon' => Carbon::class, + 'carbonImmutable' => CarbonImmutable::class, + 'illuminate' => \Illuminate\Support\Carbon::class, + ]; + + public static $key = 'cbn'; + + static function match($target) { + foreach (static::$types as $type => $class) { + if ($target instanceof $class) return true; + } + + return false; + } + + static function matchByType($type) { + return is_subclass_of($type, DateTimeInterface::class); + } + + function dehydrate($target) { + return [ + $target->format(DateTimeInterface::ATOM), + ['type' => array_search(get_class($target), static::$types)], + ]; + } + + static function hydrateFromType($type, $value) { + if ($value === '' || $value === null) return null; + + return new $type($value); + } + + function hydrate($value, $meta) { + if ($value === '' || $value === null) return null; + + return new static::$types[$meta['type']]($value); + } +} diff --git a/portman/lib/Livewire/Mechanisms/HandleComponents/Synthesizers/CollectionSynth.php b/portman/lib/Livewire/Mechanisms/HandleComponents/Synthesizers/CollectionSynth.php new file mode 100644 index 00000000..d39845fc --- /dev/null +++ b/portman/lib/Livewire/Mechanisms/HandleComponents/Synthesizers/CollectionSynth.php @@ -0,0 +1,51 @@ +all(); + + foreach ($data as $key => $child) { + $data[$key] = $dehydrateChild($key, $child); + } + + return [ + $data, + ['class' => get_class($target)] + ]; + } + + function hydrate($value, $meta, $hydrateChild) { + foreach ($value as $key => $child) { + $value[$key] = $hydrateChild($key, $child); + } + + return new $meta['class']($value); + } + + function &get(&$target, $key) { + // We need this "$reader" callback to get a reference to + // the items property inside collections. Otherwise, + // we'd receive a copy instead of the reference. + $reader = function & ($object, $property) { + $value = & \Closure::bind(function & () use ($property) { + return $this->$property; + }, $object, $object)->__invoke(); + + return $value; + }; + + $items =& $reader($target, 'items'); + + return $items[$key]; + } +} diff --git a/portman/lib/Livewire/Mechanisms/HandleComponents/Synthesizers/EnumSynth.php b/portman/lib/Livewire/Mechanisms/HandleComponents/Synthesizers/EnumSynth.php new file mode 100644 index 00000000..84ca8b8c --- /dev/null +++ b/portman/lib/Livewire/Mechanisms/HandleComponents/Synthesizers/EnumSynth.php @@ -0,0 +1,36 @@ +value, + ['class' => get_class($target)] + ]; + } + + function hydrate($value, $meta) { + if ($value === null || $value === '') return null; + + $class = $meta['class']; + + return $class::from($value); + } +} diff --git a/portman/lib/Livewire/Mechanisms/HandleComponents/Synthesizers/FloatSynth.php b/portman/lib/Livewire/Mechanisms/HandleComponents/Synthesizers/FloatSynth.php new file mode 100644 index 00000000..de9fe45f --- /dev/null +++ b/portman/lib/Livewire/Mechanisms/HandleComponents/Synthesizers/FloatSynth.php @@ -0,0 +1,25 @@ + $child) { + $data[$key] = $dehydrateChild($key, $child); + } + + return [$data, []]; + } + + function hydrate($value, $meta, $hydrateChild) { + $obj = new stdClass; + + foreach ($value as $key => $child) { + $obj->$key = $hydrateChild($key, $child); + } + + return $obj; + } + + function set(&$target, $key, $value) { + $target->$key = $value; + } +} diff --git a/portman/lib/Livewire/Mechanisms/HandleComponents/Synthesizers/StringableSynth.php b/portman/lib/Livewire/Mechanisms/HandleComponents/Synthesizers/StringableSynth.php new file mode 100644 index 00000000..2065f66b --- /dev/null +++ b/portman/lib/Livewire/Mechanisms/HandleComponents/Synthesizers/StringableSynth.php @@ -0,0 +1,21 @@ +__toString(), []]; + } + + function hydrate($value) { + return str($value); + } +} diff --git a/portman/lib/Livewire/Mechanisms/HandleComponents/Synthesizers/Synth.php b/portman/lib/Livewire/Mechanisms/HandleComponents/Synthesizers/Synth.php new file mode 100644 index 00000000..1bfd4ce4 --- /dev/null +++ b/portman/lib/Livewire/Mechanisms/HandleComponents/Synthesizers/Synth.php @@ -0,0 +1,66 @@ +$key; + } + + function __call($method, $params) { + if ($method === 'dehydrate') { + throw new \Exception('You must define a "dehydrate" method'); + } + + if ($method === 'hydrate') { + throw new \Exception('You must define a "hydrate" method'); + } + + if ($method === 'hydrateFromType') { + throw new \Exception('You must define a "hydrateFromType" method'); + } + + if ($method === 'get') { + throw new \Exception('This synth doesn\'t support getting properties: '.get_class($this)); + } + + if ($method === 'set') { + throw new \Exception('This synth doesn\'t support setting properties: '.get_class($this)); + } + + if ($method === 'unset') { + throw new \Exception('This synth doesn\'t support unsetting properties: '.get_class($this)); + } + + if ($method === 'call') { + throw new \Exception('This synth doesn\'t support calling methods: '.get_class($this)); + } + } +} diff --git a/portman/lib/Livewire/Mechanisms/HandleComponents/ViewContext.php b/portman/lib/Livewire/Mechanisms/HandleComponents/ViewContext.php new file mode 100644 index 00000000..c6464114 --- /dev/null +++ b/portman/lib/Livewire/Mechanisms/HandleComponents/ViewContext.php @@ -0,0 +1,35 @@ +slots = $factory->slots; + $this->pushes = $factory->pushes; + $this->prepends = $factory->prepends; + $this->sections = $factory->sections; + } + + function mergeIntoNewEnvironment($__env) + { + $factory = invade($__env); + + $factory->slots = $this->slots; + $factory->pushes = $this->pushes; + $factory->prepends = $this->prepends; + $factory->sections = $this->sections; + } +} diff --git a/portman/lib/Livewire/Mechanisms/HandleRequests/HandleRequests.php b/portman/lib/Livewire/Mechanisms/HandleRequests/HandleRequests.php new file mode 100644 index 00000000..c0a06af8 --- /dev/null +++ b/portman/lib/Livewire/Mechanisms/HandleRequests/HandleRequests.php @@ -0,0 +1,148 @@ +updateRoute && ! $this->updateRouteExists()) { + app($this::class)->setUpdateRoute(function ($handle) { + return Route::post('/livewire/update', $handle) + ->middleware('web') + ->name('default.livewire.update'); + }); + } + + $this->skipRequestPayloadTamperingMiddleware(); + } + + protected function updateRouteExists() + { + return $this->findUpdateRoute() !== null; + } + + function getUpdateUri() + { + // When routes are cached, $this->updateRoute may be null because + // setUpdateRoute() was never called (the route already existed). + // In this case, find the route from the router. + $route = $this->updateRoute ?? $this->findUpdateRoute(); + + return (string) str( + route($route->getName(), [], false) + )->start('/'); + } + + protected function findUpdateRoute() + { + // Find the route with name ending in 'livewire.update'. + // Custom routes can have prefixes (e.g., 'tenant.livewire.update') + // so we check for routes ending with 'livewire.update', not just exact matches. + // Prioritise custom routes over the default route. + $defaultRoute = null; + + foreach (Route::getRoutes()->getRoutes() as $route) { + if (str($route->getName())->endsWith('livewire.update')) { + // If it's the default route, save it but keep looking for a custom one + if ($route->getName() === 'default.livewire.update') { + $defaultRoute = $route; + continue; + } + + // Found a custom route, return it immediately + return $route; + } + } + + return $defaultRoute; + } + + function skipRequestPayloadTamperingMiddleware() + { + \Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::skipWhen(function () { + return $this->isLivewireRequest(); + }); + + \Illuminate\Foundation\Http\Middleware\TrimStrings::skipWhen(function () { + return $this->isLivewireRequest(); + }); + } + + function setUpdateRoute($callback) + { + $route = $callback([self::class, 'handleUpdate']); + + // Append `livewire.update` to the existing name, if any. + if (! str($route->getName())->endsWith('livewire.update')) { + $route->name('livewire.update'); + } + + $this->updateRoute = $route; + } + + function isLivewireRequest() + { + return request()->hasHeader('X-Livewire'); + } + + function isLivewireRoute() + { + // @todo: Rename this back to `isLivewireRequest` once the need for it in tests has been fixed. + $route = request()->route(); + + if (! $route) return false; + + /* + * Check to see if route name ends with `livewire.update`, as if + * a custom update route is used and they add a name, then when + * we call `->name('livewire.update')` on the route it will + * suffix the existing name with `livewire.update`. + */ + return $route->named('*livewire.update'); + } + + function handleUpdate() + { + $requestPayload = request(key: 'components', default: []); + + $finish = trigger('request', $requestPayload); + + $requestPayload = $finish($requestPayload); + + $componentResponses = []; + + foreach ($requestPayload as $componentPayload) { + $snapshot = json_decode($componentPayload['snapshot'], associative: true); + $updates = $componentPayload['updates']; + $calls = $componentPayload['calls']; + + [ $snapshot, $effects ] = app('livewire')->update($snapshot, $updates, $calls); + + $componentResponses[] = [ + 'snapshot' => json_encode($snapshot), + 'effects' => $effects, + ]; + } + + $responsePayload = [ + 'components' => $componentResponses, + 'assets' => SupportScriptsAndAssets::getAssets(), + ]; + + $finish = trigger('response', $responsePayload); + + return $finish($responsePayload); + } +} diff --git a/portman/lib/Livewire/Mechanisms/Mechanism.php b/portman/lib/Livewire/Mechanisms/Mechanism.php new file mode 100644 index 00000000..30825111 --- /dev/null +++ b/portman/lib/Livewire/Mechanisms/Mechanism.php @@ -0,0 +1,16 @@ +instance(static::class, $this); + } + + function boot() + { + // + } +} diff --git a/portman/lib/Livewire/Mechanisms/PersistentMiddleware/PersistentMiddleware.php b/portman/lib/Livewire/Mechanisms/PersistentMiddleware/PersistentMiddleware.php new file mode 100644 index 00000000..e09af9fc --- /dev/null +++ b/portman/lib/Livewire/Mechanisms/PersistentMiddleware/PersistentMiddleware.php @@ -0,0 +1,179 @@ +extractPathAndMethodFromRequest(); + + $context->addMemo('path', $path); + $context->addMemo('method', $method); + }); + + on('snapshot-verified', function ($snapshot) { + // Only apply middleware to requests hitting the Livewire update endpoint, and not any fake requests such as a test. + if (! app(HandleRequests::class)->isLivewireRoute()) return; + + $this->extractPathAndMethodFromSnapshot($snapshot); + + $this->applyPersistentMiddleware(); + }); + + on('flush-state', function() { + // Only flush these at the end of a full request, so that child components have access to this data. + $this->path = null; + $this->method = null; + }); + } + + function addPersistentMiddleware($middleware) + { + static::$persistentMiddleware = Router::uniqueMiddleware(array_merge(static::$persistentMiddleware, (array) $middleware)); + } + + function setPersistentMiddleware($middleware) + { + static::$persistentMiddleware = Router::uniqueMiddleware((array) $middleware); + } + + function getPersistentMiddleware() + { + return static::$persistentMiddleware; + } + + protected function extractPathAndMethodFromRequest() + { + if (app(HandleRequests::class)->isLivewireRoute()) { + return [$this->path, $this->method]; + } + + return [request()->path(), request()->method()]; + } + + protected function extractPathAndMethodFromSnapshot($snapshot) + { + if ( + ! isset($snapshot['memo']['path']) + || ! isset($snapshot['memo']['method']) + ) return; + + // Store these locally, so dynamically added child components can use this data. + $this->path = $snapshot['memo']['path']; + $this->method = $snapshot['memo']['method']; + } + + protected function applyPersistentMiddleware() + { + $request = $this->makeFakeRequest(); + + $middleware = $this->getApplicablePersistentMiddleware($request); + + // Only send through pipeline if there are middleware found + if (is_null($middleware)) return; + + Utils::applyMiddleware($request, $middleware); + } + + protected function makeFakeRequest() + { + $originalPath = $this->formatPath($this->path); + $originalMethod = $this->method; + + $currentPath = $this->formatPath(request()->path()); + + // Clone server bag to ensure changes below don't overwrite the original. + $serverBag = clone request()->server; + + // Replace the Livewire endpoint path with the path from the original request. + $serverBag->set( + 'REQUEST_URI', + str_replace($currentPath, $originalPath, $serverBag->get('REQUEST_URI')) + ); + + $serverBag->set('REQUEST_METHOD', $originalMethod); + + /** + * Make the fake request from the current request with path and method changed so + * all other request data, such as headers, are available in the fake request, + * but merge in the new server bag with the updated `REQUEST_URI`. + */ + $request = request()->duplicate( + server: $serverBag->all() + ); + + return $request; + } + + protected function formatPath($path) + { + return '/' . ltrim($path, '/'); + } + + protected function getApplicablePersistentMiddleware($request) + { + $route = $this->getRouteFromRequest($request); + + if (! $route) return []; + + $middleware = app('router')->gatherRouteMiddleware($route); + + return $this->filterMiddlewareByPersistentMiddleware($middleware); + } + + protected function getRouteFromRequest($request) + { + try { + $route = app('router')->getRoutes()->match($request); + $request->setRouteResolver(fn() => $route); + } catch (NotFoundHttpException $e){ + return null; + } + + return $route; + } + + protected function filterMiddlewareByPersistentMiddleware($middleware) + { + $middleware = collect($middleware); + + $persistentMiddleware = collect(app(PersistentMiddleware::class)->getPersistentMiddleware()); + + return $middleware + ->filter(function ($value, $key) use ($persistentMiddleware) { + return $persistentMiddleware->contains(function($iValue, $iKey) use ($value) { + // Some middlewares can be closures. + if (! is_string($value)) return false; + + // Ensure any middleware arguments aren't included in the comparison + return Str::before($value, ':') == $iValue; + }); + }) + ->values() + ->all(); + } +} diff --git a/portman/lib/Livewire/Mechanisms/RenderComponent.php b/portman/lib/Livewire/Mechanisms/RenderComponent.php new file mode 100644 index 00000000..e62d62ee --- /dev/null +++ b/portman/lib/Livewire/Mechanisms/RenderComponent.php @@ -0,0 +1,56 @@ +generate(); + $deterministicBladeKey = "'{$deterministicBladeKey}'"; + + return <<mount(\$__name, \$__params, \$__key); + +echo \$__html; + +unset(\$__html); +unset(\$__key); +unset(\$__name); +unset(\$__params); +unset(\$__split); +if (isset(\$__slots)) unset(\$__slots); +?> +EOT; + } +} diff --git a/portman/lib/Livewire/Pipe.php b/portman/lib/Livewire/Pipe.php new file mode 100644 index 00000000..4164962a --- /dev/null +++ b/portman/lib/Livewire/Pipe.php @@ -0,0 +1,36 @@ +target = $target; + } + + function __invoke(...$params) { + if (empty($params)) return $this->target; + + [ $before, $through, $after ] = [ [], null, [] ]; + + foreach ($params as $key => $param) { + if (! $through) { + if (is_callable($param)) $through = $param; + + else $before[$key] = $param; + } else { + $after[$key] = $param; + } + } + + $params = [ ...$before, $this->target, ...$after ]; + + $this->target = $through(...$params); + + return $this; + } +} + diff --git a/portman/lib/Livewire/Transparency.php b/portman/lib/Livewire/Transparency.php new file mode 100644 index 00000000..ddb77765 --- /dev/null +++ b/portman/lib/Livewire/Transparency.php @@ -0,0 +1,44 @@ +target; + } + + function offsetExists(mixed $offset): bool + { + return isset($this->target[$offset]); + } + + function offsetGet(mixed $offset): mixed + { + return $this->target[$offset]; + } + + function offsetSet(mixed $offset, mixed $value): void + { + $this->target[$offset] = $value; + } + + function offsetUnset(mixed $offset): void + { + unset($this->target[$offset]); + } + + function getIterator(): Traversable + { + return (function () { + foreach ($this->target as $key => $value) { + yield $key => $value; + } + })(); + } +} diff --git a/portman/lib/Livewire/WireDirective.php b/portman/lib/Livewire/WireDirective.php new file mode 100644 index 00000000..80e5918a --- /dev/null +++ b/portman/lib/Livewire/WireDirective.php @@ -0,0 +1,59 @@ +name; + } + + public function directive() + { + return $this->directive; + } + + public function value() + { + return $this->value; + } + + public function modifiers() + { + return str($this->directive) + ->replace("wire:{$this->name}", '') + ->explode('.') + ->filter()->values(); + } + + public function hasModifier($modifier) + { + return $this->modifiers()->contains($modifier); + } + + public function toHtml() + { + return (new ComponentAttributeBag([$this->directive => $this->value]))->toHtml(); + } + + public function toString() + { + return (string) $this; + } + + public function __toString() + { + return (string) $this->value; + } +} diff --git a/portman/lib/Livewire/Wireable.php b/portman/lib/Livewire/Wireable.php new file mode 100644 index 00000000..ad4d21d4 --- /dev/null +++ b/portman/lib/Livewire/Wireable.php @@ -0,0 +1,10 @@ +fallback = $fallback; + + return $this; + } + + function __call($method, $params) + { + if (! method_exists($this->target, $method)) return value($this->fallback); + + try { + return ImplicitlyBoundMethod::call(app(), [$this->target, $method], $params); + } catch (\Throwable $e) { + $shouldPropagate = true; + + $stopPropagation = function () use (&$shouldPropagate) { + $shouldPropagate = false; + }; + + trigger('exception', $this->target, $e, $stopPropagation); + + $shouldPropagate && throw $e; + } + } +} + + + + diff --git a/portman/lib/Livewire/helpers.php b/portman/lib/Livewire/helpers.php new file mode 100644 index 00000000..3c8fab4c --- /dev/null +++ b/portman/lib/Livewire/helpers.php @@ -0,0 +1,173 @@ +obj = $obj; + $this->reflected = new ReflectionClass($obj); + } + + public function &__get($name) + { + $getProperty = function &() use ($name) { + return $this->{$name}; + }; + + $getProperty = $getProperty->bindTo($this->obj, get_class($this->obj)); + + return $getProperty(); + } + + public function __set($name, $value) + { + $setProperty = function () use ($name, &$value) { + $this->{$name} = $value; + }; + + $setProperty = $setProperty->bindTo($this->obj, get_class($this->obj)); + + $setProperty(); + } + + public function __call($name, $params) + { + $method = $this->reflected->getMethod($name); + + return $method->invoke($this->obj, ...$params); + } + }; +} + +function once($fn) +{ + $hasRun = false; + + return function (...$params) use ($fn, &$hasRun) { + if ($hasRun) return; + + $hasRun = true; + + return $fn(...$params); + }; +} + +function of(...$params) +{ + return $params; +} + +function revert(&$variable) +{ + $cache = $variable; + + return function () use (&$variable, $cache) { + $variable = $cache; + }; +} + +function wrap($subject) { + return new Wrapped($subject); +} + +function pipe($subject) { + return new Pipe($subject); +} + +function trigger($name, ...$params) { + return app(\Livewire\EventBus::class)->trigger($name, ...$params); +} + +function on($name, $callback) { + return app(\Livewire\EventBus::class)->on($name, $callback); +} + +function after($name, $callback) { + return app(\Livewire\EventBus::class)->after($name, $callback); +} + +function before($name, $callback) { + return app(\Livewire\EventBus::class)->before($name, $callback); +} + +function off($name, $callback) { + app(\Livewire\EventBus::class)->off($name, $callback); +} + +function memoize($target) { + static $memo = new \WeakMap; + + return new class ($target, $memo) { + function __construct( + protected $target, + protected &$memo, + ) {} + + function __call($method, $params) + { + $this->memo[$this->target] ??= []; + + $signature = $method . crc32(json_encode($params)); + + return $this->memo[$this->target][$signature] + ??= $this->target->$method(...$params); + } + }; +} + +function store($instance = null) +{ + if (! $instance) $instance = app(\Livewire\Mechanisms\DataStore::class); + + return new class ($instance) { + function __construct(protected $instance) {} + + function get($key, $default = null) { + return app(\Livewire\Mechanisms\DataStore::class)->get($this->instance, $key, $default); + } + + function set($key, $value) { + return app(\Livewire\Mechanisms\DataStore::class)->set($this->instance, $key, $value); + } + + function push($key, $value, $iKey = null) + { + return app(\Livewire\Mechanisms\DataStore::class)->push($this->instance, $key, $value, $iKey); + } + + function find($key, $iKey = null, $default = null) + { + return app(\Livewire\Mechanisms\DataStore::class)->find($this->instance, $key, $iKey, $default); + } + + function has($key, $iKey = null) + { + return app(\Livewire\Mechanisms\DataStore::class)->has($this->instance, $key, $iKey); + } + + function unset($key, $iKey = null) + { + return app(\Livewire\Mechanisms\DataStore::class)->unset($this->instance, $key, $iKey); + } + }; +} diff --git a/rector.php b/rector.php new file mode 100644 index 00000000..ed29c807 --- /dev/null +++ b/rector.php @@ -0,0 +1,30 @@ +withPaths([ + realpath('dist'), + ]) + ->withRules([ + ClassPropertyAssignToConstructorPromotionRector::class, + ArraySpreadInsteadOfArrayMergeRector::class + ]) + ->withPhpSets( + php82: true + ) + ->withPreparedSets( + deadCode: true, + codeQuality: true, + codingStyle: true, + typeDeclarations: true, + ) + ->withNoDiffs() + //->withTypeCoverageLevel(Level::PHP_82) + ->withImportNames( + removeUnusedImports: true + ); diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 00000000..1f94d5d2 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,11 @@ +{ + "packages": { + ".": { + "changelog-path": "CHANGELOG.md", + "release-type": "php", + "draft": false, + "prerelease": false + } + }, + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json" +} diff --git a/src/Component.php b/src/Component.php index d964ca4e..62f2e354 100644 --- a/src/Component.php +++ b/src/Component.php @@ -1,4 +1,5 @@ -getId(); + } - /** - * Protected methods. - * - * @see getUncallables() - * @var string[] - */ - protected $uncallables = []; + function setId($id) + { + // Support backwards compatibility. + $this->id = $id; - /** @var ComponentContext $context */ - protected $context; + $this->__id = $id; + } - /** - * @param ComponentContext $context - */ - public function __construct(ComponentContext $context) + function getId() { - $this->context = $context; + return $this->__id; } - /** - * Assign/overwrite public class properties. - * - * @lifecyclehook updatingProperty - * @lifecyclehook updating - * @lifecyclehook defineProperty - * @lifecyclehook updated - * @lifecyclehook updatedProperty - * - * @param string $name - * @param mixed $value - * @param bool $skipLifecycle - * @param string|null $method - * @return $this - */ - public function assign( - string $name, - $value, - bool $skipLifecycle = false, - string $method = null - ): self + function setName($name) { - try { - if (!array_key_exists($name, $this->getPublicProperties())) { - throw new ComponentException(__('Public property %1 does\'nt exist', [$name])); - } - if ($skipLifecycle) { - throw new LifecycleException(__('Skips lifecycle')); - } - - // Process lifecycle from this point on. - $before = 'updating' . str_replace(' ', '', ucwords(str_replace(['-', '_'], ' ', $method ?? $name))); - $current = str_replace('updating', 'define', $before); - $after = str_replace('define', 'updated', $current); + $this->__name = $name; + } - $methods = [$before, 'updating', $current, 'updated', $after]; - $clone = $value; + public function getName() + { + return $this->__name; + } - foreach ($methods as $m) { - if (method_exists($this, $m)) { - $clone = $this->{$m}(...[$value, $name]); - } + public function setAlias(string|null $alias): void + { + $this->__alias = $alias; + } - $this->{$name} = $clone; - } - } catch (LifecycleException $exception) { - // We skip the rest and just assign the property. - } catch (Exception $exception) { - return $this; - } + public function getAlias(): string|null + { + return $this->__alias; + } - $this->{$name} = $value; - return $this; + public function hasAlias(): bool + { + return $this->__alias !== null; } - /** - * Assign/overwrite multiple public class properties at once. - * - * @param array $assignees - * @param bool $skipLifecycle - * @return $this - */ - public function fill(array $assignees, bool $skipLifecycle = false): self + public function skipRender($html = null): void { - foreach ($assignees as $assignee => $value) { - $this->assign($assignee, $value, $skipLifecycle); - } + store($this)->set('skipRender', $html ?: true); + } - return $this; + public function skipMount(): void + { + store($this)->set('skipMount', true); } - /** - * @return Template|null - */ - public function getParent(): ?Template + public function skipHydrate(): void { - return $this->parent; + store($this)->set('skipHydrate', true); } - /** - * @param Template $parent - * @return $this - */ - public function setParent(Template $parent): self + public function __isset($property) { - $this->parent = $parent; - return $this; + try { + $value = $this->__get($property); + + if (isset($value)) { + return true; + } + } catch (\Magewirephp\Magewire\Exceptions\PropertyNotFoundException $exception) { + } + + return false; } /** - * Get a (optional cached) array with all available - * (non-static) public property objects. - * - * @param bool $refresh - * @return array + * @throws PropertyNotFoundException */ - public function getPublicProperties(bool $refresh = false): array + public function __get($property) { - if (($refresh ? null : $this->publicProperties) === null) { - $properties = array_filter((new ReflectionClass($this))->getProperties(), static function ($property) { - return $property->isPublic() && !$property->isStatic(); - }); + $value = 'noneset'; - $data = []; + $returnValue = static function ($newValue) use (&$value) { + $value = $newValue; + }; - foreach ($properties as $property) { - $data[$property->getName()] = $property->getValue($this); - } + $finish = trigger('__get', $this, $property, $returnValue); + + $value = $finish($value); - $this->publicProperties = array_diff_key($data, array_flip(self::RESERVED_PROPERTIES)); + if ($value === 'noneset') { + throw new PropertyNotFoundException($property, $this->getName()); } - return $this->publicProperties; + return $value; } - /** - * Returns an optional array with uncallable method names - * who can not be executed by a subsequent request. - * - * These methods are still callable inside the component's template file. - * - * @return string[] - */ - public function getUncallables(): array + public function __unset($property) { - return $this->uncallables; + trigger('__unset', $this, $property); } - /** - * @param string $method - * @param array $args - * @return array|bool|mixed|null|void - * @throws LocalizedException - */ - public function __call(string $method, array $args) + public function __call($method, $params) { - if($this->ignoreCall($method)) { - return; - } + $value = 'noneset'; + + $returnValue = static function ($newValue) use (&$value) { + $value = $newValue; + }; + + $finish = trigger('__call', $this, $method, $params, $returnValue); + + $value = $finish($value); - $key = lcfirst(substr($method, 3)); - - switch (substr($method, 0, 3)) { - case 'get': - if (property_exists($this, $key)) { - return $this->{$key}; - } - break; - case 'has': - return property_exists($this, $key) && $this->{$key} !== null; - default: - throw new LocalizedException(__('Invalid method %1::%2', [get_class($this), $method])); + if ($value !== 'noneset') { + return $value; } + + throw new BadMethodCallException(sprintf('Method %s::%s does not exist.', static::class, $method)); } - /** - * @param string $method - * @return bool - */ - public function ignoreCall(string $method): bool + public function tap($callback): static { - if (in_array($method, ['boot', 'mount', 'hydrate', 'dehydrate', 'updating', 'updated'])) { - return true; - } - - foreach (['hydrate', 'dehydrate', 'updating', 'updated'] as $start) { - if (strncmp($method, $start, strlen($start)) === 0) { - return true; - } - } + $callback($this); - return false; + return $this; } } diff --git a/src/Component/Form.php b/src/Component/Form.php new file mode 100644 index 00000000..381e0381 --- /dev/null +++ b/src/Component/Form.php @@ -0,0 +1,108 @@ +validator = $validator; + } + + /** + * @throws AcceptableException + */ + public function validate( + array $rules = [], + array $messages = [], + array|null $data = null, + array $aliases = [], + bool $mergeWithClassProperties = true + ): bool { + $rules = $mergeWithClassProperties ? array_merge($this->rules, $rules) : $rules; + $data ??= $this->getPublicProperties(true); + + $messages = array_map( + static function ($message) { + return __($message); + }, + $mergeWithClassProperties ? array_merge($this->messages, $messages) : $messages + ); + + try { + $validation = $this->validator->make($data, $rules, $messages); + $validation->setAliases($aliases); + + $validation->setTranslations([ + 'or' => __('or'), + 'and' => __('and') + ]); + + foreach (array_keys($rules) as $attributeName) { + foreach ($validation->getAttribute($attributeName)->getRules() as $rule) { + $rule->setMessage((string) __($rule->getMessage())); + } + } + + $validation->validate(); + + if ($validation->fails()) { + foreach ($validation->errors()->toArray() as $key => $error) { + $this->error($key, current($error)); + } + + throw new ValidationException(__('Something went wrong while validating the form input')); + } + } catch (Exception $exception) { + throw new AcceptableException(__($exception->getMessage())); + } + + return true; + } + + /** + * @throws AcceptableException + */ + public function validateOnly(array $rules = [], array $messages = [], array|null $data = null): bool + { + return $this->validate($rules, $messages, $data, [], false); + } +} diff --git a/src/Component/Pagination.php b/src/Component/Pagination.php deleted file mode 100644 index b9f04829..00000000 --- a/src/Component/Pagination.php +++ /dev/null @@ -1,64 +0,0 @@ -getParent()) === null) { - throw new ComponentException(__('No block attached onto the current Magewire component')); - } - if (($block = $parent->getLayout()->getBlock('magewire.pagination.pager')) === null) { - throw new ComponentException(__('Pagination block could not be found')); - } - - $block->setData('component', $this); - $block->setTemplate($template ?? $this->pagerTemplate); - - return $block->toHtml(); - } catch (LocalizedException $exception) { - return __('Pager not available.')->render(); - } - } -} diff --git a/src/Component/PaginationInterface.php b/src/Component/PaginationInterface.php deleted file mode 100644 index 35522144..00000000 --- a/src/Component/PaginationInterface.php +++ /dev/null @@ -1,111 +0,0 @@ -magewireServiceProvider->getHandleRequestsMechanismFacade(); + + return $this->updateResultFactory->create($handleRequestsMechanismFacade->update()); + } catch (Exception $exception) { + try { + $handler = $this->exceptionManager->handle($exception); + + // Set a default exception render handler. + $handler ??= static function (HttpResponseInterface $response) use ($exception) { + $response->setBody('An unexpected error occurred while processing your request.'); + $response->setHttpResponseCode(500); + + return $response; + }; + + return $this->magewireUpdateResultFactory->create()->renderWith($handler); + } catch (Exception $exception) { + if ($this->applicationState->getMode() === ApplicationState::MODE_PRODUCTION) { + throw $exception; + } + + return $this->magewireUpdateResultFactory + ->create() + ->renderWith(static function (HttpResponseInterface $response) use ($exception) { + $response->setBody($exception->getMessage()); + $response->setHttpResponseCode(500); + + return $response; + }); + } + } + } + + /** + * @throws LocalizedException + */ + public function validateForCsrf(RequestInterface $request): bool|null + { + return Security::compareStrings($request->getParam('token'), $this->formKey->getFormKey()); + } + + public function createCsrfValidationException(RequestInterface $request): InvalidRequestException|null + { + return null; + } +} diff --git a/src/Controller/MagewireDeveloperAction.php b/src/Controller/MagewireDeveloperAction.php new file mode 100644 index 00000000..7ad7afca --- /dev/null +++ b/src/Controller/MagewireDeveloperAction.php @@ -0,0 +1,55 @@ +applicationState->getMode() === ApplicationState::MODE_PRODUCTION) { + return $this->forward(Base::NO_ROUTE); + } + + return $this->page(); + } + + protected function page(): Page + { + $page = $this->page ?? $this->pageFactory->create(); + $page->getConfig()->getTitle()->set($this->pageTitle); + + return $page; + } + + protected function forward(string $action): Forward + { + return $this->resultForwardFactory->create()->forward($action); + } +} diff --git a/src/Controller/MagewireRoute.php b/src/Controller/MagewireRoute.php new file mode 100644 index 00000000..34c3ea9e --- /dev/null +++ b/src/Controller/MagewireRoute.php @@ -0,0 +1,92 @@ +getMatchConditions(); + + /* + * Magewire routes must always start with the Magewire base route. + * Although this is already validated during the normal routing process, + * this object can be used independently, so the route must always be revalidated here before proceeding. + */ + array_unshift($conditions, fn () => $this->magewireRouteValidator->validate($request)); + + foreach ($conditions as $name => $condition) { + try { + if (! $condition($request)) { + return null; + } + } catch (Exception $exception) { + $this->log()->debug(sprintf('Route match condition "%s" threw an exception', $name), ['exception' => $exception]); + + return null; + } + } + + return $this->createAction($request); + } + + /** + * Returns an array of match conditions as closures to validate certain aspects of a request. + * + * Each condition is a closure that takes a `Request` object as a parameter and returns a boolean value + * indicating whether the request satisfies the specific condition. These conditions could be used to + * filter or validate incoming HTTP requests based on certain criteria like request method, URI, and content type. + * + * TODO: Currently, conditions are a flat array of callables, each of which must return true for the check to pass. + * If any callable returns false, the entire check fails. Future plans include supporting nested arrays, + * where each sub-array acts as an OR condition—if any callable in a sub-array returns true, + * that group is considered satisfied. + * + * @return array + */ + abstract public function getMatchConditions(): array; + + abstract public function createAction(RequestInterface $request): ActionInterface; + + public function actionFactory(): ActionFactory + { + return $this->actionFactory; + } + + public function log(): LoggerInterface + { + return $this->logger; + } +} diff --git a/src/Controller/MagewireUpdateRoute.php b/src/Controller/MagewireUpdateRoute.php new file mode 100644 index 00000000..cf5ef009 --- /dev/null +++ b/src/Controller/MagewireUpdateRoute.php @@ -0,0 +1,154 @@ +actionFactory, $this->logger, $this->magewireRouteValidator); + } + + /** + * Matches and processes Magewire update requests. + * + * Validates the request, boots Magewire in subsequent mode, and parses + * component data. Returns a forward action on failure or the matched + * action on success. + */ + public function match(RequestInterface $request): ActionInterface|null + { + $match = parent::match($request); + + if ($match === null) { + return null; + } + + /** + * Boot Magewire and initialize the request context. + * + * Magewire has two trigger points for initialization: + * 1. During component updates (the only feasible location after confirming an update request) + * 2. During page load via the view block observer + * + * This call marks the request as "subsequent" to distinguish between initial page loads + * and subsequent update requests, allowing system-wide determination of Magewire's state. + * + * @see \Magewirephp\Magewire\Observer\ViewBlockAbstractToHtmlBefore + */ + $this->magewireServiceProvider->boot(RequestMode::SUBSEQUENT); + + try { + $request->setParams($this->parseRequest($request)); + } catch (Exception $exception) { + $this->logger->critical($exception->getMessage(), ['exception' => $exception]); + + return null; + } + + return $match; + } + + /** + * Parses and validates the component update request payload. + * + * Deserializes component data from the request body, verifies checksums, + * validates component prerequisites (resolver/handle), and converts + * component data to service contract objects. + * + * @throws LocalizedException + * @throws FileSystemException + * @throws \Magento\Framework\Exception\RuntimeException + * @throws CorruptComponentPayloadException + */ + protected function parseRequest(RequestInterface $request): array + { + // @todo: Consider finding a better way for retrieving request parameters. + $input = $this->serializer->unserialize(file_get_contents('php://input')); + + foreach ($input[self::PARAM_COMPONENTS] as $key => $component) { + $component['snapshot'] = $this->serializer->unserialize($component['snapshot']); + + $this->checksum->verify($component['snapshot']); + trigger('snapshot-verified', $component['snapshot']); + + $handle = $component['snapshot']['memo']['handle'] ?? null; + $resolver = $component['snapshot']['memo']['resolver'] ?? null; + + /** + * Magewire requires at least a component resolver accessor and any necessary layout update handles. + * These layout handles must adhere to core Magento requirements for layout handles to proceed + * with handling the update. + * + * @see Magento_Framework::View/Layout/etc/elements.xsd + */ + if (! $resolver && (! $handle || preg_match('/^[a-zA-Z0-9][a-zA-Z\d\-_\.]*$/', $handle) !== 1)) { + throw new RuntimeException('Base component prerequisites not satisfied.'); + } + + $snapshot = $this->snapshotFactory->create([ + 'data' => $component['snapshot']['data'] ?? [], + 'memo' => $component['snapshot']['memo'] ?? [], + 'checksum' => $component['snapshot']['checksum'] ?? '', + ]); + + $input[self::PARAM_COMPONENTS][$key] = $this->componentRequestContextFactory->create([ + 'snapshot' => $snapshot, + 'calls' => $component['calls'] ?? [], + 'updates' => $component['updates'] ?? [], + ]); + } + + /** @var Request $request */ + $request->setParam('token', $input['_token'] ?? null); + + unset($input['_token']); + + return array_merge($request->getParams(), $input); + } +} diff --git a/src/Controller/MagewireUpdateRouteFrontend.php b/src/Controller/MagewireUpdateRouteFrontend.php new file mode 100644 index 00000000..e1cba0f3 --- /dev/null +++ b/src/Controller/MagewireUpdateRouteFrontend.php @@ -0,0 +1,34 @@ + static fn (Request $request): bool => $request->isPost(), + 'update_uri' => static fn (Request $request): bool => str_starts_with($request->getPathInfo(), '/magewire/update'), + 'content_type' => static fn (Request $request): bool => $request->getHeader('Content-Type') === 'application/json' + ]; + } + + public function createAction(RequestInterface $request): ActionInterface + { + return $this->actionFactory()->create(Update::class); + } +} diff --git a/src/Controller/Playwright/Directives.php b/src/Controller/Playwright/Directives.php new file mode 100644 index 00000000..d25fde97 --- /dev/null +++ b/src/Controller/Playwright/Directives.php @@ -0,0 +1,20 @@ +formKey = $formKey; - $this->componentHelper = $componentHelper; - $this->resultPageFactory = $resultPageFactory; - $this->serializer = $serializer; - $this->httpFactory = $httpFactory; - $this->resultJsonFactory = $resultJsonFactory; - } - - /** - * @return Json - */ - public function execute(): Json - { - $result = $this->resultJsonFactory->create(); - - try { - $post = $this->serializer->unserialize(file_get_contents('php://input')); - $block = $this->locateWireBlock($post); - - $component = $this->componentHelper->extractComponentFromBlock($block); - $component->setRequest($this->httpFactory->createRequest($post)->isSubsequent(true)); - - $html = $block->toHtml(); - $response = $component->getResponse(); - - if ($response === null) { - throw new MagewireException(__('Response object not found for component')); - } - - // Set final HTML for response - $response->effects['html'] = $html; - - return $result->setData([ - 'effects' => $response->getEffects(), - 'serverMemo' => $response->getServerMemo() - ]); - } catch (SubsequentRequestException $exception) { - $result->setStatusHeader(Response::STATUS_CODE_500, AbstractMessage::VERSION_11, 'Bad Request'); - - return $result->setData([ - 'message' => 'Something went wrong during the subsequent request lifecycle: ' . $exception->getMessage(), - 'code' => $exception->getCode() - ]); - } catch (Exception $exception) { - $result->setStatusHeader(Response::STATUS_CODE_500, AbstractMessage::VERSION_11, 'Bad Request'); - - return $result->setData([ - 'message' => 'Something went wrong outside the component: ' . $exception->getMessage(), - 'code' => $exception->getCode() - ]); - } - } - - /** - * @param array $post - * @return BlockInterface - * @throws SubsequentRequestException - */ - public function locateWireBlock(array $post): BlockInterface - { - $resultPage = $this->resultPageFactory->create(); - $resultPage->addHandle($post['fingerprint']['handle'])->initLayout(); - - $block = $resultPage->getLayout()->getBlock($post['fingerprint']['name']); - - if ($block === false) { - throw new SubsequentRequestException('Magewire component does not exist'); - } - - return $block; - } - - /** - * @inheritDoc - */ - public function createCsrfValidationException(RequestInterface $request): ?InvalidRequestException - { - return null; - } - - /** - * @inheritDoc - * @throws LocalizedException - */ - public function validateForCsrf(RequestInterface $request): ?bool - { - return $request->isPost() && Security::compareStrings($request->getHeader('X-CSRF-TOKEN'), $this->formKey->getFormKey()); - } -} diff --git a/src/Controller/Router.php b/src/Controller/Router.php new file mode 100644 index 00000000..9858d6bb --- /dev/null +++ b/src/Controller/Router.php @@ -0,0 +1,47 @@ +matchesMagewireSpecifics($request)) { + foreach (array_filter($this->routes) as $route) { + if ($result = $route->match($request)) { + return $result; + } + } + } + + return null; + } + + private function matchesMagewireSpecifics(RequestInterface $request): bool + { + return $request instanceof Request && $this->magewireRouteValidator->validate($request); + } +} diff --git a/src/Controller/Vintage/Livewire.php b/src/Controller/Vintage/Livewire.php deleted file mode 100644 index 19b512ea..00000000 --- a/src/Controller/Vintage/Livewire.php +++ /dev/null @@ -1,58 +0,0 @@ -origin = $origin; - } - - public function execute(): Json - { - return $this->origin->execute(); - } - - /** - * @inheritDoc - */ - public function createCsrfValidationException(RequestInterface $request): ?InvalidRequestException - { - return $this->origin->createCsrfValidationException($request); - } - - /** - * @inheritDoc - */ - public function validateForCsrf(RequestInterface $request): ?bool - { - return $this->origin->validateForCsrf($request); - } -} diff --git a/src/Exception/ComponentActionException.php b/src/Exception/ComponentActionException.php deleted file mode 100644 index 548b6e57..00000000 --- a/src/Exception/ComponentActionException.php +++ /dev/null @@ -1,18 +0,0 @@ -getMagewire(); - - if ($magewire) { - if (is_array($magewire)) { - return $magewire['type']; - } - if (is_object($magewire)) { - return $magewire; - } - } - - throw new MissingComponentException(__('Magewire component not found')); - } - - /** - * @param BlockInterface $block - * @param array $addition - * @return array - */ - public function extractDataFromBlock(BlockInterface $block, array $addition = []): array - { - $magewire = $block->getMagewire(); - - if ($magewire && is_array($magewire)) { - unset($magewire['type']); return array_merge_recursive($magewire, $addition); - } - - return $addition; - } -} diff --git a/src/Helper/Functions.php b/src/Helper/Functions.php deleted file mode 100644 index 789cc7d8..00000000 --- a/src/Helper/Functions.php +++ /dev/null @@ -1,77 +0,0 @@ -serializer = $serializer; - } - - /** - * @param callable $callback - * @param array $data - * @return array - */ - public function map(callable $callback, array $data): array - { - $keys = array_keys($data); - $items = array_map($callback, $data, $keys); - - return array_combine($keys, $items); - } - - /** - * @param callable $callback - * @param array $data - * @return array - */ - public function mapWithKeys(callable $callback, array $data): array - { - $result = []; - - foreach ($data as $key => $value) { - $assoc = $callback($value, $key); - - foreach ($assoc as $mapKey => $mapValue) { - $result[$mapKey] = $mapValue; - } - } - - return $result; - } - - /** - * @param $subject - * @return string - */ - public function escapeStringForHtml($subject): string - { - if (is_string($subject) || is_numeric($subject)) { - return htmlspecialchars($subject); - } - - return htmlspecialchars($this->serializer->serialize($subject)); - } -} diff --git a/src/Helper/Property.php b/src/Helper/Property.php deleted file mode 100644 index da2393a4..00000000 --- a/src/Helper/Property.php +++ /dev/null @@ -1,85 +0,0 @@ -arrayManager = $arrayManager; - } - - /** - * @param string $property - * @return bool - */ - public function containsDots(string $property): bool - { - return strpos($property, '.') !== false; - } - - /** - * @param string $path - * @param $value - * @param Component $component - * @return array - */ - public function transformDots(string $path, $value, Component $component): array - { - $pieces = explode('.', $path); - $property = $pieces[0]; - - array_shift($pieces); - $value = $this->arrayManager->set(implode('/', $pieces), $component->{$property}, $value); - - return compact('property', 'value'); - } - - /** - * Use a callback function to assign component property - * values except default reserved properties. - * - * NOTE: Will skip the lifecycle. - * - * @param callable $callback - * @param RequestInterface|ResponseInterface $subject (waiting for PHP 8.x support) - * @param Component $component - * @return void - */ - public function assign(callable $callback, $subject, Component $component): void - { - $publicProperties = $component->getPublicProperties(); - - foreach ($subject->memo['data'] as $property => $value) { - if (in_array($property, Component::RESERVED_PROPERTIES, true)) { - continue; - } - if (array_key_exists($property, $publicProperties) && ($component->{$property} !== $value)) { - $callback($component, $subject, $property, $value); - } - } - } -} diff --git a/src/Helper/Security.php b/src/Helper/Security.php deleted file mode 100644 index 0e3b8a00..00000000 --- a/src/Helper/Security.php +++ /dev/null @@ -1,68 +0,0 @@ -deployConfig = $deployConfig; - $this->serializer = $serializer; - } - - /** - * @param array $data1 - * @param array $data2 - * @return string - * @throws FileSystemException - * @throws RuntimeException - */ - public function generateChecksum(array $data1, array $data2): string - { - $key = $this->deployConfig->get('crypt/key'); - - $hash = '' . $this->serializer->serialize($data1) . $this->serializer->serialize($data2); - return hash_hmac('sha256', $hash, $key); - } - - /** - * @param string $checksum - * @param array $data1 - * @param array $data2 - * @return bool - * @throws FileSystemException - * @throws RuntimeException - */ - public function validateChecksum(string $checksum, array $data1, array $data2): bool - { - return hash_equals($this->generateChecksum($data1, $data2), $checksum); - } -} diff --git a/src/Magewire/Playwright/Directives/Base.php b/src/Magewire/Playwright/Directives/Base.php new file mode 100644 index 00000000..5280b1da --- /dev/null +++ b/src/Magewire/Playwright/Directives/Base.php @@ -0,0 +1,18 @@ +runtime()->state()->isMinimally(RuntimeState::SETUP)) { + return; + } + + try { + // Containers are always fully booted during setup. + $this->containers->boot(); + + // Before setup, but after containers service type booted. + trigger('magewire:setup', $this->runtime()); + + // Boot only persistent and above during setup. + $this->mechanisms->boot(ServiceTypeItemBootMode::PERSISTENT); + $this->features->boot(ServiceTypeItemBootMode::PERSISTENT); + + $this->runtime()->state(RuntimeState::SETUP); + } catch (Exception $exception) { + $this->runtime()->state(RuntimeState::FAILED); + + throw $exception; + } + } + + /** + * @throws Exception + */ + public function boot(RequestMode $mode, bool $force = false): void + { + if (! $force && $this->runtime()->state()->is(RuntimeState::UNINITIALIZED)) { + $this->setup(); + } + if (! $force && $this->runtime()->state()->isMinimally(RuntimeState::BOOTED)) { + return; + } + + try { + // Define runtime boot mode. + $this->runtime()->mode($mode); + // Define runtime booting state right before service items boot attempt. + $this->runtime()->state(RuntimeState::BOOTING); + + // Before boot. + $finish = trigger('magewire:boot', $this->runtime()); + + // Boot all remaining service types not yet booted during setup. + $boot['containers'] = $this->mechanisms->boot(); + $boot['mechanisms'] = $this->mechanisms->boot(); + $boot['features'] = $this->features->boot(); + + if (in_array(false, $boot, true)) { + throw new RuntimeException('One or more service types were unable to boot completely.'); + } + + // After boot without any exceptions. + $finish(); + + // Define runtime boot state as ready when all succeeded. + $this->runtime()->state(RuntimeState::BOOTED); + } catch (Exception $exception) { + $this->runtime()->state(RuntimeState::FAILED); + + throw $exception; + } + } + + public function runtime(): Runtime + { + return $this->runtime ??= $this->runtimeFactory->create(); + } + + public function __call($name, $arguments) + { + $matches = preg_match_all('/[A-Z][a-z]*/', $name, $matches) ? $matches[0] : []; + + $operation = $this->getServiceType($matches[count($matches) - 1]); + $operation ??= $this->getServiceType($matches[count($matches) - 2]); + + /* + * Enables the possibility to get either a mechanism- or a feature operation type or its + * belonging facade if it has any attached. + * + * @todo This lookup can be heavily optimized caching those that are found + * by there $name and when exist as key in a global class array, + * return that type instead of looking it up once again. + */ + if ($operation) { + $item = strtolower($matches[count($matches) - 1]); + + $argument = strtolower(implode('_', array_slice($matches, 0, count($matches) - 1))); + $argument = trim(preg_replace('/(?:container|mechanism|feature)\s*$/', '', $argument), '_'); + + try { + return match ($item) { + 'container', 'feature', 'mechanism' => $operation->item($argument), + 'facade' => $operation->facade($argument) + }; + } catch (Exception $exception) { + $this->logger->critical($exception->getMessage(), ['exception' => $exception]); + } + } + + throw new BadMethodCallException(); + } + + private function getServiceType(string $operation): ServiceType|null + { + $operation = strtolower(! str_ends_with($operation, 's') ? $operation . 's' : $operation); + + if (property_exists(self::class, $operation)) { + return $this->{$operation}; + } + + return null; + } +} diff --git a/src/Model/Action/CallMethod.php b/src/Model/Action/CallMethod.php deleted file mode 100644 index e21a5a66..00000000 --- a/src/Model/Action/CallMethod.php +++ /dev/null @@ -1,100 +0,0 @@ -typeFactory = $typeFactory; - $this->uncallableMethods = $uncallableMethods; - } - - /** - * @inheritdoc - * @throws ComponentActionException - * @throws LocalizedException - */ - public function handle(Component $component, array $payload) - { - $method = ltrim($payload['method'], '$'); - $params = $payload['params']; - - if ($this->isCallable($method, $component)) { - return $component->{$method}(...$params); - } - - // Determine the required type class by method in specific order - $type = $this->determineType($method); - // Add the component as a dynamic last method param - $params[] = $component; - - if ($this->isCallable($method, $type)) { - return $type->{$method}(...$params); - } - - throw new ComponentActionException(__('Method %1 does not exist or can not be called', [$method])); - } - - /** - * @param $method - * @param object $class - * @return bool - */ - public function isCallable($method, object $class): bool - { - $uncallables = $this->uncallableMethods; - - if ($class instanceof Component) { - $uncallables = array_merge($class->getUncallables(), $this->uncallableMethods); - } - - return in_array($method, array_diff(get_class_methods($class), $uncallables), true); - } - - /** - * @param string $method - * @return mixed|string - * @throws LocalizedException - */ - public function determineType(string $method) - { - foreach ([Magic::class] as $type) { - if (method_exists($type, $method)) { - return $this->typeFactory->create($type); - } - } - - throw new LocalizedException(__('Method %1 does not exist', [$method])); - } -} diff --git a/src/Model/Action/FireEvent.php b/src/Model/Action/FireEvent.php deleted file mode 100644 index 344ef28a..00000000 --- a/src/Model/Action/FireEvent.php +++ /dev/null @@ -1,69 +0,0 @@ -functionsHelper = $functionsHelper; - $this->callMethodHandler = $callMethodHandler; - $this->listenerHydrator = $listenerHydrator; - } - - /** - * @inheritdoc - * - * @throws ComponentActionException|LocalizedException - */ - public function handle(Component $component, array $payload) - { - $listeners = $this->listenerHydrator->assimilateListeners($component); - $method = $listeners[$payload['event']] ?? false; - $parameters = $payload['params'][0] ?? []; - - if ($method === false) { - throw new ComponentActionException(__('Method %1 does not exist or can not be called', [$method])); - } - - $this->callMethodHandler->handle($component, [ - 'method' => $method, - 'params' => array_values($parameters) - ]); - } -} diff --git a/src/Model/Action/SyncInput.php b/src/Model/Action/SyncInput.php deleted file mode 100644 index 560d3337..00000000 --- a/src/Model/Action/SyncInput.php +++ /dev/null @@ -1,61 +0,0 @@ -propertyHelper = $propertyHelper; - } - - /** - * @inheritdoc - * - * @throws ComponentActionException - */ - public function handle(Component $component, array $payload) - { - if (!isset($payload['name'], $payload['value'])) { - throw new ComponentActionException(__('Invalid update payload')); - } - - if ($this->propertyHelper->containsDots($payload['name'])) { - $transform = $this->propertyHelper->transformDots($payload['name'], $payload['value'], $component); - - // Define a new method since it's a nested property - $method = preg_replace_callback('/[-_.](.?)/', static function ($matches) { - return ucfirst($matches[1]); - }, $payload['name']); - - // Re-assign original method properties - $payload['name'] = $transform['property']; - $payload['value'] = $transform['value']; - } - - $component->assign($payload['name'], $payload['value'], false, $method ?? null); - } -} diff --git a/src/Model/Action/Type/Factory.php b/src/Model/Action/Type/Factory.php deleted file mode 100644 index 06417a1e..00000000 --- a/src/Model/Action/Type/Factory.php +++ /dev/null @@ -1,40 +0,0 @@ -objectManager = $objectManager; - } - - /** - * @param string $type - * @return mixed|string - */ - public function create(string $type) - { - return $this->objectManager->create($type); - } -} diff --git a/src/Model/Action/Type/Magic.php b/src/Model/Action/Type/Magic.php deleted file mode 100644 index 2169bb7d..00000000 --- a/src/Model/Action/Type/Magic.php +++ /dev/null @@ -1,73 +0,0 @@ -propertyHelper = $propertyHelper; - } - - /** - * Magic method ($toggle) to toggle boolean properties. - * - * Example: - * - * @param string $property - * @param $component - * @return void - */ - public function toggle(string $property, $component): void - { - $this->set($property, !$component->{$property}, $component); - } - - /** - * Magic method ($set) to update the value of a property. - * - * Example: - * - * @param string $property - * @param $value - * @param $component - * @return void - */ - public function set(string $property, $value, $component): void - { - if ($this->propertyHelper->containsDots($property)) { - $transform = $this->propertyHelper->transformDots($property, $value, $component); - - // Re-assign original method properties - $property = $transform['property']; - $value = $transform['value']; - } - - $component->assign($property, $value); - } - - public function refresh(): void - { - return; - } -} diff --git a/src/Model/ActionInterface.php b/src/Model/ActionInterface.php deleted file mode 100644 index c5b0c7dd..00000000 --- a/src/Model/ActionInterface.php +++ /dev/null @@ -1,30 +0,0 @@ -getMessage(), $exception->getCode(), $exception->getPrevious()); + } + + return $exception; + } + + function handleWithBlock(AbstractBlock $block, Exception $exception, bool $subsequent = false): AbstractBlock + { + if ($block instanceof Template) { + $block->setTemplate('Magewirephp_Magewire::magewire/exception.phtml'); + } + + return $block; + } +} diff --git a/src/Model/App/ExceptionHandler.php b/src/Model/App/ExceptionHandler.php new file mode 100644 index 00000000..1b591710 --- /dev/null +++ b/src/Model/App/ExceptionHandler.php @@ -0,0 +1,30 @@ +setData('application_state', $this->state); + + return $block; + } +} diff --git a/src/Model/App/ExceptionManager.php b/src/Model/App/ExceptionManager.php new file mode 100644 index 00000000..92b7838d --- /dev/null +++ b/src/Model/App/ExceptionManager.php @@ -0,0 +1,137 @@ +> $specificExceptionHandlerPool + */ + function __construct( + private readonly LoggerInterface $logger, + private readonly AbstractExceptionHandler $precedingHandler, + private readonly AbstractExceptionHandler $subsequentHandler, + private readonly MagewireServiceProvider $magewireServiceProvider, + private readonly array $specificExceptionHandlerPool = [] + ) { + } + + /** + * @throws Exception + */ + function handle(Exception $exception, bool $log = true): callable|null + { + $subsequent = $this->magewireServiceProvider + ->runtime() + ->mode() + ->isSubsequent(); + + try { + $exception = $this->resolveExceptionHandler($exception, $subsequent)->handle($exception, $subsequent); + } catch (Exception $parent) { + $exception = $parent; + } + + if ($exception instanceof SilentException) { + return null; + } elseif (is_callable($exception)) { + return $exception; + } + + if ($log) { + $this->logger->info(sprintf('Magewire: %s', $exception->getMessage()), ['exception' => $exception]); + } + + throw $exception; + } + + /** + * @throws Exception + */ + function handleWithBlock(AbstractBlock $block, Exception $exception, bool $log = true): AbstractBlock + { + $subsequent = $this->magewireServiceProvider + ->runtime() + ->mode() + ->isSubsequent(); + + if ($subsequent) { + $this->handle($exception, $log); + } + + // Prevent cyclic loops from re-triggering the entire Magewire lifecycle. + $block->unsetData('magewire'); + // Making sure the exception is always present on the block. + $block->setData('exception', $exception); + + try { + $block = $this->resolveExceptionHandler($exception, $subsequent)->handleWithBlock($block, $exception, $subsequent); + } catch (Exception $parent) { + $this->handle(new SilentException($parent->getMessage(), $parent->getCode(), $parent->getPrevious())); + } + + if ($log) { + $this->logger->info($exception->getMessage(), ['exception' => $exception]); + } + + return $block; + } + + private function resolveExceptionHandler(Exception $exception, bool $subsequent = false): AbstractExceptionHandler + { + $exceptionGroup = $subsequent ? 'subsequent' : 'preceding'; + $exceptionClass = $exception::class; + $exceptionHandlerPool = $this->specificExceptionHandlerPool; + + // Check for a general subsequent tor preceding handler. + if (is_object($exceptionHandlerPool[$exceptionGroup] ?? null)) { + $handler = $this->specificExceptionHandlerPool[$exceptionClass] ?? $exceptionHandlerPool[$exceptionGroup]; + + if ($handler instanceof AbstractExceptionHandler) { + return $handler; + } + + // Enable to continue the search by switching the group into an array. + $exceptionHandlerPool[$exceptionGroup] = []; + } + + $fallback = $subsequent ? $this->subsequentHandler : $this->precedingHandler; + + // Check for a grouped handler for the specific exception class. + $handler = $exceptionHandlerPool[$exceptionGroup][$exceptionClass] ?? null + ? $exceptionHandlerPool[$exceptionGroup][$exceptionClass] + // Check for a handler for the specific exception class. + : ( + $exceptionHandlerPool[$exceptionClass] ?? null + ? $exceptionHandlerPool[$exceptionClass] + // Continue with the fallback if nothing specific was found. + : $fallback + ); + + if (! $handler instanceof AbstractExceptionHandler) { + return $fallback; + } + + return $handler; + } +} diff --git a/src/Model/ComponentManager.php b/src/Model/ComponentManager.php deleted file mode 100644 index 0ce6f601..00000000 --- a/src/Model/ComponentManager.php +++ /dev/null @@ -1,214 +0,0 @@ -localeResolver = $localeResolver; - $this->updateActionsPool = $updateActionsPool; - $this->httpFactory = $httpFactory; - - /** - * Important: specific order, don't change this spontaneously! - */ - $this->hydrationPool = $this->sortHydrators($hydrationPool, [ - $hydratorContext->getSecurityHydrator(), - $hydratorContext->getBrowserEventHydrator(), - $hydratorContext->getFlashMessageHydrator(), - $hydratorContext->getErrorHydrator(), - $hydratorContext->getHashHydrator(), - $hydratorContext->getComponentHydrator(), - $hydratorContext->getQueryStringHydrator(), - $hydratorContext->getPropertyHydrator(), - $hydratorContext->getListenerHydrator(), - $hydratorContext->getLoaderHydrator(), - $hydratorContext->getEmitHydrator(), - $hydratorContext->getRedirectHydrator() - ]); - } - - /** - * @param Component $component - * @param array $updates - * @throws LocalizedException - * @throws ComponentActionException - */ - public function processUpdates(Component $component, array $updates): void - { - if ($component->hasRequest() === false) { - throw new LocalizedException(__('No request object found')); - } - - foreach ($updates as $update) { - try { - $this->updateActionsPool[$update['type']]->handle($component, $update['payload']); - } catch (ValidationException $exception) { - continue; - } catch (Exception $exception) { - throw new LocalizedException(__($exception->getMessage())); - } - } - } - - /** - * Runs on every request, after the component is hydrated, - * but before an action is performed, or the layout block - * has been rendered. - * - * @param Component $component - * @return void - * @throws LifecycleException - */ - public function hydrate(Component $component): void - { - foreach ($this->hydrationPool as $hydrator) { - try { - $hydrator->hydrate($component, $component->getRequest()); - } catch (Exception $exception) { - throw new LifecycleException( - __('An error occurred while hydrating %1: %2', [get_class($hydrator), $exception->getMessage()]) - ); - } - } - } - - /** - * Runs on every request, before the component is dehydrated, - * right before the layout block gets rendered. - * - * @param Component $component - * @throws LifecycleException - */ - public function dehydrate(Component $component): void - { - foreach (array_reverse($this->hydrationPool) as $dehydrator) { - try { - $dehydrator->dehydrate($component, $component->getResponse()); - } catch (Exception $exception) { - throw new LifecycleException( - __('An error occurred while dehydrating %1: %2', [get_class($dehydrator), $exception->getMessage()]) - ); - } - } - } - - /** - * @param Template $block - * @param Component $component - * @param array $arguments - * @param string|null $handle - * @return Request - * @throws LocalizedException - */ - public function createInitialRequest( - Template $block, - Component $component, - array $arguments, - string $handle = null - ): Request { - $properties = $component->getPublicProperties(); - $request = $block->getRequest(); - $data = array_intersect_key(array_replace($properties, $arguments), $properties); - - /** - * SHA1 hashing the wire:id value is an idea which can change in the future. I'm still tumbling around the - * acceptance of just using the block name which has to be unique which is the most important part. I need to - * look into the security aspect when switching to an un-hashed version of the wire:id attribute. - */ - $id = $component->id ?? sha1($block->getNameInLayout()); - - $name = $block->getNameInLayout(); - $handle = $handle ?? $request->getFullActionName(); - $locale = $this->localeResolver->getLocale(); - - return $this->httpFactory->createRequest([ - 'fingerprint' => [ - 'id' => $id, - 'name' => $name, - 'locale' => $locale, - 'path' => '/', - 'method' => 'GET', - - // Custom relative to Livewire's core. - 'handle' => $handle, - 'type' => $component::COMPONENT_TYPE - ], - 'serverMemo' => [ - 'data' => $data, - 'errors' => [], - - // Custom relative to Livewire's core. - 'custom' => [], - ] - ]); - } - - /** - * [ - * "class" => object, - * "order" => int - * ] - * - * @param array $hydrators - * @param $systemHydrators - * @return array - * - * @see ComponentManager::dehydrate() - * @see ComponentManager::hydrate() - */ - protected function sortHydrators(array $hydrators, $systemHydrators): array - { - usort($hydrators, static function ($x, $y) { - return $x['order'] - $y['order']; - }); - - return array_merge($systemHydrators, array_column($hydrators, 'class')); - } -} diff --git a/src/Model/Concern/BrowserEvent.php b/src/Model/Concern/BrowserEvent.php deleted file mode 100644 index 14ba3628..00000000 --- a/src/Model/Concern/BrowserEvent.php +++ /dev/null @@ -1,36 +0,0 @@ -dispatchQueue; - } - - /** - * @param $event - * @param null $data - */ - public function dispatchBrowserEvent($event, $data = null): void - { - $this->dispatchQueue[] = compact('event', 'data'); - } -} diff --git a/src/Model/Concern/Conversation.php b/src/Model/Concern/Conversation.php deleted file mode 100644 index f924cf62..00000000 --- a/src/Model/Concern/Conversation.php +++ /dev/null @@ -1,102 +0,0 @@ -hasRequest() && is_array($this->request->getSectionByName($section)); - } - - return $this->request !== null; - } - - /** - * @param string|null $section - * @return RequestInterface|array|null - */ - public function getRequest($section = null) - { - if (is_string($section)) { - return $this->request->getSectionByName($section); - } - - return $this->request; - } - - /** - * @param RequestInterface $request - * @return $this - */ - public function setRequest(RequestInterface $request): self - { - $this->request = $request; - return $this; - } - - /** - * @param string|null $section - * @return bool - */ - public function hasResponse($section = null): bool - { - if (is_string($section)) { - try { - return $this->hasResponse() && is_array($this->response->getSectionByName($section)); - } catch (LocalizedException $exception) { - return false; - } - } - - return $this->response !== null; - } - - /** - * @param string|null $section - * @return ResponseInterface|array - */ - public function getResponse($section = null) - { - if (is_string($section)) { - return $this->response->getSectionByName($section); - } - - return $this->response; - } - - /** - * @param ResponseInterface $response - * @return $this - */ - public function setResponse(ResponseInterface $response): self - { - $this->response = $response; - return $this; - } -} diff --git a/src/Model/Concern/Emit.php b/src/Model/Concern/Emit.php deleted file mode 100644 index a27c26af..00000000 --- a/src/Model/Concern/Emit.php +++ /dev/null @@ -1,85 +0,0 @@ -eventQueue; - } - - /** - * @param string $event - * @param ...$params - * @return Event - */ - public function emit(string $event, ...$params): Event - { - return $this->eventQueue[] = new Event($event, $params); - } - -// /** -// * @param string $event -// * @param ...$params -// * @return Event -// */ -// public function emitUp(string $event, ...$params): Event -// { -// return $this->emit($event, ...$params)->up(); -// } - - /** - * Only emit an event on the component that fired the event. - * - * @param string $event - * @param ...$params - * @return Event - */ - public function emitSelf(string $event, ...$params): Event - { - return $this->emit($event, ...$params)->self(); - } - - /** - * Only emit an event to other components of the same type. - * - * @param string $name - * @param string $event - * @param ...$params - * @return Event - */ - public function emitTo(string $name, string $event, ...$params): Event - { - return $this->emit($event, ...$params)->component($name); - } - - /** - * Only emit a "refresh" event to other components of the same type. - * - * @param string $name - * @return Event - */ - public function emitToRefresh(string $name): Event - { - return $this->emitTo($name, 'refresh'); - } -} diff --git a/src/Model/Concern/Event.php b/src/Model/Concern/Event.php deleted file mode 100644 index 95aa2771..00000000 --- a/src/Model/Concern/Event.php +++ /dev/null @@ -1,27 +0,0 @@ -listeners; - } -} diff --git a/src/Model/Concern/FlashMessage.php b/src/Model/Concern/FlashMessage.php deleted file mode 100644 index 707d77fb..00000000 --- a/src/Model/Concern/FlashMessage.php +++ /dev/null @@ -1,98 +0,0 @@ -dispatchMessage(FlashMessageElement::ERROR, $message); - } - - /** - * @param Phrase|string $message - * @return FlashMessageElement - */ - public function dispatchWarningMessage($message): FlashMessageElement - { - return $this->dispatchMessage(FlashMessageElement::WARNING, $message); - } - - /** - * @param Phrase|string $message - * @return FlashMessageElement - */ - public function dispatchNoticeMessage($message): FlashMessageElement - { - return $this->dispatchMessage(FlashMessageElement::NOTICE, $message); - } - - /** - * @param Phrase|string $message - * @return FlashMessageElement - */ - public function dispatchSuccessMessage($message): FlashMessageElement - { - return $this->dispatchMessage(FlashMessageElement::SUCCESS, $message); - } - - /** - * @param string $type - * @param Phrase|string $message - * @return FlashMessageElement - */ - public function dispatchMessage(string $type, $message): FlashMessageElement - { - return $this->messages[] = new FlashMessageElement($message, $type); - } - - /** - * Check if any flash messages have been dispatched. - * - * @return bool - */ - public function hasFlashMessages(): bool - { - return count($this->messages) !== 0; - } - - /** - * Get all flash messages. - * - * @return FlashMessageElement[] - */ - public function getFlashMessages(): array - { - return $this->messages; - } - - /** - * Reset all flash messages. - * - * @return void - */ - public function clearFlashMessages(): void - { - $this->messages = []; - } -} diff --git a/src/Model/Concern/QueryString.php b/src/Model/Concern/QueryString.php deleted file mode 100644 index 631449bd..00000000 --- a/src/Model/Concern/QueryString.php +++ /dev/null @@ -1,34 +0,0 @@ -queryString; - } - - /** - * @return bool - */ - public function hasQueryString(): bool - { - return !empty($this->getQueryString()); - } -} diff --git a/src/Model/Concern/Redirect.php b/src/Model/Concern/Redirect.php deleted file mode 100644 index c3087804..00000000 --- a/src/Model/Concern/Redirect.php +++ /dev/null @@ -1,41 +0,0 @@ -redirect = new RedirectElement($path, $params); - } - - /** - * @return RedirectElement|null - */ - public function getRedirect(): ?RedirectElement - { - return $this->redirect; - } -} diff --git a/src/Model/Concern/View.php b/src/Model/Concern/View.php deleted file mode 100644 index d352eae7..00000000 --- a/src/Model/Concern/View.php +++ /dev/null @@ -1,63 +0,0 @@ -skipRender = true; - return $this; - } - - /** - * Check if the component can be rendered. - * - * @return bool - */ - public function canRender(): bool - { - return !$this->skipRender; - } - - /** - * Switch template of the parent block. - * - * @param string $template - */ - public function switchTemplate(string $template): void - { - if ($parent = $this->getParent()) { - $parent->setTemplate($template); - } - } - - /** - * @return bool|array - */ - public function getLoader() - { - return $this->loader; - } -} diff --git a/src/Model/Context/Hydrator.php b/src/Model/Context/Hydrator.php deleted file mode 100644 index 20618f6c..00000000 --- a/src/Model/Context/Hydrator.php +++ /dev/null @@ -1,203 +0,0 @@ -hashHydrator = $hashHydrator; - $this->listenerHydrator = $listenerHydrator; - $this->emit = $emit; - $this->componentHydrator = $componentHydrator; - $this->propertyHydrator = $propertyHydrator; - $this->queryStringHydrator = $queryStringHydrator; - $this->errorHydrator = $errorHydrator; - $this->redirectHydrator = $redirectHydrator; - $this->flashMessageHydrator = $flashMessageHydrator; - $this->browserEventHydrator = $browserEventHydrator; - $this->securityHydrator = $securityHydrator; - $this->loaderHydrator = $loaderHydrator; - } - - /** - * @return Hash - */ - public function getHashHydrator(): Hash - { - return $this->hashHydrator; - } - - /** - * @return Listener - */ - public function getListenerHydrator(): Listener - { - return $this->listenerHydrator; - } - - /** - * @return Emit - */ - public function getEmitHydrator(): Emit - { - return $this->emit; - } - - /** - * @return BrowserEvent - */ - public function getBrowserEventHydrator(): BrowserEvent - { - return $this->browserEventHydrator; - } - - /** - * @return Component - */ - public function getComponentHydrator(): Component - { - return $this->componentHydrator; - } - - /** - * @return QueryString - */ - public function getQueryStringHydrator(): QueryString - { - return $this->queryStringHydrator; - } - - /** - * @return Property - */ - public function getPropertyHydrator(): Property - { - return $this->propertyHydrator; - } - - /** - * @return Error - */ - public function getErrorHydrator(): Error - { - return $this->errorHydrator; - } - - /** - * @return Redirect - */ - public function getRedirectHydrator(): Redirect - { - return $this->redirectHydrator; - } - - /** - * @return FlashMessage - */ - public function getFlashMessageHydrator(): FlashMessage - { - return $this->flashMessageHydrator; - } - - /** - * @return Security - */ - public function getSecurityHydrator(): Security - { - return $this->securityHydrator; - } - - /** - * @return Loader - */ - public function getLoaderHydrator(): Loader - { - return $this->loaderHydrator; - } -} diff --git a/src/Model/Csp.php b/src/Model/Csp.php new file mode 100644 index 00000000..235c7f0d --- /dev/null +++ b/src/Model/Csp.php @@ -0,0 +1,53 @@ +nonceProvider === null) { + $this->nonceProvider = $this->isCspAvailable() ? ObjectManager::getInstance()->get('Magento\Csp\Helper\CspNonceProvider') : false; + } + + return $this->nonceProvider; + } + + public function isCspAvailable(): bool + { + return $this->nonceProvider !== false && class_exists('Magento\Csp\Helper\CspNonceProvider') || is_object($this->nonceProvider) && method_exists($this->nonceProvider, 'generateNonce'); + } + + public function generateHash(string $content): string + { + return base64_encode(hash(self::HASH_ALGORITHM, $content, true)); + } + + public function getHashAlgorithm(): string + { + return self::HASH_ALGORITHM; + } +} diff --git a/src/Model/Element/Event.php b/src/Model/Element/Event.php deleted file mode 100644 index ce095826..00000000 --- a/src/Model/Element/Event.php +++ /dev/null @@ -1,92 +0,0 @@ -name = $name; - $this->params = $params; - } - - /** - * @return $this - */ - public function up(): Event - { - $this->up = true; - return $this; - } - - /** - * @return $this - */ - public function self(): Event - { - $this->self = true; - return $this; - } - - /** - * @param string $name - * @return $this - */ - public function component(string $name): Event - { - $this->component = $name; - return $this; - } - - /** - * @return $this - */ - public function to(): Event - { - return $this; - } - - /** - * @return array - */ - public function serialize(): array - { - $output = [ - 'event' => $this->name, - 'params' => $this->params, - ]; - - if ($this->up) { - $output['ancestorsOnly'] = true; - } - if ($this->self) { - $output['selfOnly'] = true; - } - if ($this->component) { - $output['to'] = $this->component; - } - - return $output; - } -} diff --git a/src/Model/Element/FlashMessage.php b/src/Model/Element/FlashMessage.php deleted file mode 100644 index 687a977f..00000000 --- a/src/Model/Element/FlashMessage.php +++ /dev/null @@ -1,62 +0,0 @@ -message = is_string($message) ? __($message) : $message; - $this->type = $type; - } - - /** - * @return Phrase - */ - public function getMessage(): Phrase - { - return $this->message; - } - - /** - * @return string - */ - public function getType(): string - { - return $this->type; - } -} diff --git a/src/Model/Element/Redirect.php b/src/Model/Element/Redirect.php deleted file mode 100644 index 2979b685..00000000 --- a/src/Model/Element/Redirect.php +++ /dev/null @@ -1,56 +0,0 @@ -url = $path; - $this->params = $params; - } - - /** - * @return string - */ - public function getUrl(): string - { - return $this->url; - } - - /** - * @return array - */ - public function getParams(): array - { - return $this->params; - } - - /** - * @return bool - */ - public function hasParams(): bool - { - return !empty($this->params); - } -} diff --git a/src/Model/HttpFactory.php b/src/Model/HttpFactory.php deleted file mode 100644 index eacdc6af..00000000 --- a/src/Model/HttpFactory.php +++ /dev/null @@ -1,58 +0,0 @@ -serviceInputProcessor = $serviceInputProcessor; - } - - /** - * @param array $data - * @return RequestInterface - * @throws LocalizedException - */ - public function createRequest(array $data): RequestInterface - { - return $this->serviceInputProcessor->convertValue($data, RequestInterface::class); - } - - /** - * @param RequestInterface $request - * @param array $data - * @return ResponseInterface - * @throws LocalizedException - */ - public function createResponse(RequestInterface $request, array $data = []): ResponseInterface - { - return $this->serviceInputProcessor->convertValue(array_merge([ - 'request' => $request, - 'fingerprint' => $request->getFingerprint(), - 'serverMemo' => $request->getServerMemo(), - 'effects' => [] - ], $data), ResponseInterface::class); - } -} diff --git a/src/Model/Hydrator/BrowserEvent.php b/src/Model/Hydrator/BrowserEvent.php deleted file mode 100644 index 2fd49c46..00000000 --- a/src/Model/Hydrator/BrowserEvent.php +++ /dev/null @@ -1,41 +0,0 @@ -getBrowserEvents(); - - if (!empty($browserEvents)) { - $response->effects['dispatches'] = $browserEvents; - } - } -} diff --git a/src/Model/Hydrator/Component.php b/src/Model/Hydrator/Component.php deleted file mode 100644 index 7561ecf5..00000000 --- a/src/Model/Hydrator/Component.php +++ /dev/null @@ -1,78 +0,0 @@ -componentHelper = $componentHelper; - } - - /** - * @lifecyclehook boot - * @lifecyclehook hydrate - * @lifecyclehook mount - * - * @inheritdoc - */ - public function hydrate(MagewireComponent $component, RequestInterface $request): void - { - $data = $this->componentHelper->extractDataFromBlock($component->getParent(), ['request' => $request]); - $component->boot(...array_values($data)); - - if ($request->isSubsequent()) { - $this->executePropertyLifecycleHook($component, 'hydrate', $request); - $component->hydrate($request); - } else { - $component->mount(...array_values($data)); - } - } - - /** - * @lifecyclehook dehydrate - * - * @inheritdoc - */ - public function dehydrate(MagewireComponent $component, ResponseInterface $response): void - { - $component->dehydrate($response); - $this->executePropertyLifecycleHook($component, 'dehydrate', $response); - } - - /** - * @param MagewireComponent $component - * @param string $type - * @param object $object - */ - public function executePropertyLifecycleHook(MagewireComponent $component, string $type, object $object): void - { - foreach ($component->getPublicProperties() as $property => $value) { - $component->{strtolower($type) . ucfirst($property)}($value, $object); - } - } -} diff --git a/src/Model/Hydrator/Emit.php b/src/Model/Hydrator/Emit.php deleted file mode 100644 index bc635fa4..00000000 --- a/src/Model/Hydrator/Emit.php +++ /dev/null @@ -1,39 +0,0 @@ -getEventQueue() as $event) { - $response->effects['emits'][] = $event->serialize(); - } - } -} diff --git a/src/Model/Hydrator/Error.php b/src/Model/Hydrator/Error.php deleted file mode 100644 index 4917397f..00000000 --- a/src/Model/Hydrator/Error.php +++ /dev/null @@ -1,37 +0,0 @@ -memo['errors'] = $component->getErrors(); - } -} diff --git a/src/Model/Hydrator/FlashMessage.php b/src/Model/Hydrator/FlashMessage.php deleted file mode 100644 index 70e7e017..00000000 --- a/src/Model/Hydrator/FlashMessage.php +++ /dev/null @@ -1,65 +0,0 @@ -messageManager = $messageManager; - } - - /** - * @inheritdoc - */ - public function hydrate(Component $component, RequestInterface $request): void - { - // - } - - /** - * @inheritdoc - */ - public function dehydrate(Component $component, ResponseInterface $response): void - { - if ($component->hasFlashMessages()) { - $messages = array_map(static function($message) { - return ['text' => $message->getMessage()->render(), 'type' => $message->getType()]; - }, $component->getFlashMessages()); - - if (isset($response->effects['redirect']) || $response->getRequest()->isPreceding()) { - $this->messageManager->addMessages(array_map(function($message) { - return $this->messageManager->createMessage($message['type'])->setText($message['text']); - }, $messages)); - - return; - } - - $component->dispatchBrowserEvent('messages-loaded', ['messages' => $messages]); - } - } -} diff --git a/src/Model/Hydrator/Hash.php b/src/Model/Hydrator/Hash.php deleted file mode 100644 index d037c816..00000000 --- a/src/Model/Hydrator/Hash.php +++ /dev/null @@ -1,56 +0,0 @@ -fingerprint['id'], $request->fingerprint['name'])) { - throw new ComponentHydrationException(__('Request fingerprint doesn\'t have all data available')); - } - - $component->id = $request->fingerprint['id']; - $component->name = $request->fingerprint['name']; - - if (isset($request->memo['htmlHash'])) { - $this->domHashes[$component->id] = $request->memo['htmlHash']; - } - } - - /** - * @inheritdoc - */ - public function dehydrate(Component $component, ResponseInterface $response): void - { - $hash = $this->domHashes[$component->id] ?? null; - $response->memo['htmlHash'] = hash('crc32b', $response->effects['html']); - - if (empty($response->effects['html']) || $hash === $response->memo['htmlHash']) { - $response->effects['html'] = null; - } - } -} diff --git a/src/Model/Hydrator/Listener.php b/src/Model/Hydrator/Listener.php deleted file mode 100644 index 1a68c739..00000000 --- a/src/Model/Hydrator/Listener.php +++ /dev/null @@ -1,79 +0,0 @@ -functionsHelper = $functionsHelper; - $this->listeners = $listeners; - } - - /** - * @inheritdoc - */ - public function hydrate(Component $component, RequestInterface $request): void - { - // - } - - /** - * @inheritdoc - */ - public function dehydrate(Component $component, ResponseInterface $response): void - { - if ($response->getRequest()->isPreceding()) { - $response->effects['listeners'] = array_keys($this->assimilateListeners($component)); - } - } - - /** - * Merges optional injected event listeners for the given Component. - * - * @param Component $component - * @return array - */ - public function assimilateListeners(Component $component): array - { - $listeners = $component->getListeners(); - - if (isset($this->listeners[$component->name])) { - $listeners = array_merge($this->listeners[$component->name], $listeners); - } - - // Global event's for each component - $listeners['refresh'] = '$refresh'; - - return $this->functionsHelper->mapWithKeys(function ($value, $key) { - return [is_numeric($key) ? $value : $key => $value]; - }, $listeners); - } -} diff --git a/src/Model/Hydrator/Loader.php b/src/Model/Hydrator/Loader.php deleted file mode 100644 index c2e9fdd0..00000000 --- a/src/Model/Hydrator/Loader.php +++ /dev/null @@ -1,39 +0,0 @@ -getLoader()) && $loader && $response->getRequest()->isPreceding()) { - $response->effects['loader'] = $loader; - } - } -} diff --git a/src/Model/Hydrator/Property.php b/src/Model/Hydrator/Property.php deleted file mode 100644 index 5cdb4e06..00000000 --- a/src/Model/Hydrator/Property.php +++ /dev/null @@ -1,70 +0,0 @@ -propertyHelper = $propertyHelper; - } - - /** - * @inheritdoc - */ - public function hydrate(Component $component, RequestInterface $request): void - { - if ($request->isPreceding()) { - $request->memo['data'] = array_merge($request->memo['data'], $component->getPublicProperties(true)); - } - - // Bind regular properties - $this->propertyHelper->assign(function(Component $component, $request, $property, $value) { - $component->assign($property, $value, true); - }, $request, $component); - - // Flush properties cache - $component->getPublicProperties(true); - } - - /** - * @inheritdoc - */ - public function dehydrate(Component $component, ResponseInterface $response): void - { - if ($response->getRequest()->isSubsequent()) { - $response->effects['dirty'] = []; - - $this->propertyHelper->assign(function (Component $component, $response, $property) { - // A lot have could happen to the property, so lets set it one more time - $response->memo['data'][$property] = $component->{$property}; - // The property can be seen as changed and dirty data, who needs a refresh - $response->effects['dirty'][] = $property; - }, $response, $component); - } - } -} diff --git a/src/Model/Hydrator/QueryString.php b/src/Model/Hydrator/QueryString.php deleted file mode 100644 index ffb6867f..00000000 --- a/src/Model/Hydrator/QueryString.php +++ /dev/null @@ -1,97 +0,0 @@ -request = $request; - $this->functionsHelper = $functionsHelper; - } - - /** - * @inheritdoc - */ - public function hydrate(Component $component, RequestInterface $request): void - { - if ($request->isPreceding() && $component->hasQueryString()) { - $properties = $this->getQueryParamsFromComponentProperties($component); - $aliases = $this->filterQueryStringSetting($component, 'alias'); - - foreach (array_keys($properties) as $property) { - if ($value = $this->request->getParam($aliases[$property] ?? $property)) { - $component->assign($property, $value); - } - } - } - } - - /** - * @inheritDoc - */ - public function dehydrate(Component $component, ResponseInterface $response): void - { - // - } - - /** - * Try to filter out any excepts "property:except-value" pairs. - * - * @param Component $component - * @param string $setting - * @return array|false - */ - public function filterQueryStringSetting(Component $component, string $setting = 'except') - { - $excepts = $this->functionsHelper->mapWithKeys(static function ($value, $key) use ($setting) { - return [$key => $value[$setting]]; - }, array_filter($component->getQueryString(), static function ($value) use ($setting) { - return isset($value[$setting]); - }, ARRAY_FILTER_USE_BOTH)); - - return empty($excepts) ? false : $excepts; - } - - /** - * @param Component $component - * @return array - */ - public function getQueryParamsFromComponentProperties(Component $component): array - { - return $this->functionsHelper->mapWithKeys(function($value, $key) use ($component) { - $key = is_string($key) ? $key : $value; - return [$key => $component->{$key}]; - }, $component->getQueryString()); - } -} diff --git a/src/Model/Hydrator/Redirect.php b/src/Model/Hydrator/Redirect.php deleted file mode 100644 index cd665d7f..00000000 --- a/src/Model/Hydrator/Redirect.php +++ /dev/null @@ -1,61 +0,0 @@ -builder = $builder; - } - - /** - * @inheritdoc - */ - public function hydrate(Component $component, RequestInterface $request): void - { - // - } - - /** - * A redirect can both be set on the initial and/or subsequent request. - * - * @inheritdoc - */ - public function dehydrate(Component $component, ResponseInterface $response): void - { - if ($redirect = $component->getRedirect()) { - $url = $redirect->getUrl(); - - if ($redirect->hasParams()) { - $url = $this->builder->getUrl($url, $redirect->getParams()); - } - - $response->effects['redirect'] = $url; - } - } -} diff --git a/src/Model/Hydrator/Security.php b/src/Model/Hydrator/Security.php deleted file mode 100644 index b775ebd1..00000000 --- a/src/Model/Hydrator/Security.php +++ /dev/null @@ -1,74 +0,0 @@ -securityHelper = $securityHelper; - } - - /** - * @inheritdoc - * - * @param Component $component - * @param RequestInterface $request - * @throws CorruptPayloadException - * @throws FileSystemException - * @throws RuntimeException - */ - public function hydrate(Component $component, RequestInterface $request): void - { - if (!isset($request->memo['checksum'])) { - return; - } - - $checksum = $request->memo['checksum']; - unset($request->memo['checksum']); - - $memo = array_diff_key($request->getServerMemo(), array_flip(['children'])); - - if (!$this->securityHelper->validateChecksum($checksum, $request->getFingerprint(), $memo)) { - throw new CorruptPayloadException('Temporary Component Name (todo)'); - } - } - - /** - * @inheritdoc - * - * @throws FileSystemException - * @throws RuntimeException - */ - public function dehydrate(Component $component, ResponseInterface $response): void - { - $response->memo['checksum'] = $this->securityHelper->generateChecksum($response->getFingerprint(), $response->getServerMemo()); - } -} diff --git a/src/Model/Magento/System/ConfigMagewire.php b/src/Model/Magento/System/ConfigMagewire.php new file mode 100644 index 00000000..22abf87a --- /dev/null +++ b/src/Model/Magento/System/ConfigMagewire.php @@ -0,0 +1,110 @@ +scopeConfig->getValue($this->createPath($path, $group), $scopeType, $scopeCode); + } + + /** + * Retrieve a grouped config flag by path and scope. + */ + public function isGroupFlag( + string $path, + string|null $group = null, + string $scopeType = ScopeInterface::SCOPE_STORE, + $scopeCode = null + ): bool { + return $this->scopeConfig->isSetFlag($this->createPath($path, $group), $scopeType, $scopeCode); + } + + /** + * Retrieve features group config value by path and scope. + */ + public function getFeaturesGroupValue( + string $path, + string $scopeType = ScopeInterface::SCOPE_STORE, + $scopeCode = null + ): mixed { + return $this->getGroupValue($path, self::GROUP_FEATURES, $scopeType, $scopeCode); + } + + /** + * Retrieve a features group config flag by path and scope. + */ + public function isFeatureGroupFlag( + string $path, + string $scopeType = ScopeInterface::SCOPE_STORE, + $scopeCode = null + ): bool { + return $this->scopeConfig->isSetFlag($this->createPath($path, self::GROUP_FEATURES), $scopeType, $scopeCode); + } + + /** + * Retrieve debug group config value by path and scope. + */ + public function getDebugGroupValue( + string $path, + string $scopeType = ScopeInterface::SCOPE_STORE, + $scopeCode = null + ): mixed { + return $this->getGroupValue($path, self::GROUP_DEBUG, $scopeType, $scopeCode); + } + + /** + * Retrieve a debug group config flag by path and scope. + */ + public function isDebugGroupFlag( + string $path, + string $scopeType = ScopeInterface::SCOPE_STORE, + $scopeCode = null + ): bool { + return $this->scopeConfig->isSetFlag($this->createPath($path, self::GROUP_DEBUG), $scopeType, $scopeCode); + } + + /** + * Retrieve if Magewire is in debug mode. + */ + public function isInDebugMode(): bool + { + return $this->isDebugGroupFlag('enable'); + } + + /** + * Returns a formatted path based on the provided path and optional group. + */ + private function createPath(string $path, string|null $group = null): string + { + return $group ? sprintf('magewire/%s/%s', $group, trim($path)) : sprintf('magewire/%s', trim($path)); + } +} diff --git a/src/Model/Magento/System/ConfigMagewireGroup.php b/src/Model/Magento/System/ConfigMagewireGroup.php new file mode 100644 index 00000000..f9e30d75 --- /dev/null +++ b/src/Model/Magento/System/ConfigMagewireGroup.php @@ -0,0 +1,25 @@ +config; + } +} diff --git a/src/Model/Magewire/Notifier/NotificationStateEnum.php b/src/Model/Magewire/Notifier/NotificationStateEnum.php new file mode 100644 index 00000000..d9c86328 --- /dev/null +++ b/src/Model/Magewire/Notifier/NotificationStateEnum.php @@ -0,0 +1,40 @@ +value; + } + + public function getCssClass(): string + { + return 'state-' . $this->value; + } + + public function getLevel(): int + { + $level = array_search($this, self::cases(), true); + return $level !== false ? $level : 0; + } +} diff --git a/src/Model/Magewire/Notifier/NotificationTypeEnum.php b/src/Model/Magewire/Notifier/NotificationTypeEnum.php new file mode 100644 index 00000000..b36cf363 --- /dev/null +++ b/src/Model/Magewire/Notifier/NotificationTypeEnum.php @@ -0,0 +1,30 @@ +value; + } + + public function getCssClass(): string + { + return 'notification-' . $this->value; + } +} diff --git a/src/Model/Request.php b/src/Model/Request.php deleted file mode 100644 index 946ccd7b..00000000 --- a/src/Model/Request.php +++ /dev/null @@ -1,132 +0,0 @@ -fingerprint)) { - return $this->fingerprint[$index] ?? null; - } - - return $this->fingerprint; - } - - /** - * @inheritdoc - */ - public function setFingerprint($fingerprint): RequestInterface - { - $this->fingerprint = $fingerprint; - return $this; - } - - /** - * @inheritdoc - */ - public function getServerMemo(string $index = null) - { - if ($index !== null && is_array($this->memo)) { - return $this->memo[$index] ?? null; - } - - return $this->memo; - } - - /** - * @inheritdoc - */ - public function setServerMemo($memo): RequestInterface - { - $this->memo = $memo; - return $this; - } - - /** - * @inheritdoc - */ - public function getUpdates(string $index = null) - { - if ($index !== null && is_array($this->updates)) { - return $this->updates[$index] ?? null; - } - - return $this->updates; - } - - /** - * @inheritdoc - */ - public function setUpdates($updates): RequestInterface - { - $this->updates = $updates; - return $this; - } - - /** - * @inheritdoc - * - * @throws LocalizedException - */ - public function getSectionByName(string $section): ?array - { - if (in_array($section, ['fingerprint', 'serverMemo', 'updates'])) { - return $this->{$section}; - } - - throw new LocalizedException(__('Request section %s does not exist', $section)); - } - - /** - * @inheritdoc - */ - public function isSubsequent(bool $flag = null) - { - // Just return the update status - if ($flag === null) { - return $this->isSubsequent ?? false; - } - - // Lock this property so it can't be changed later on - if ($this->isSubsequent === null) { - $this->isSubsequent = $flag; - } - - return $this; - } - - /** - * @inheritdoc - */ - public function isPreceding(): bool - { - return !$this->isSubsequent(); - } -} diff --git a/src/Model/Response.php b/src/Model/Response.php deleted file mode 100644 index 38c69478..00000000 --- a/src/Model/Response.php +++ /dev/null @@ -1,149 +0,0 @@ -functionsHelper = $functionsHelper; - } - - /** - * @inheritdoc - */ - public function getRequest() - { - return $this->request; - } - - /** - * @inheritdoc - */ - public function setRequest($request): ResponseInterface - { - $this->request = $request; - return $this; - } - - /** - * @inheritdoc - */ - public function getFingerprint() - { - return $this->fingerprint; - } - - /** - * @inheritdoc - */ - public function setFingerprint($fingerprint): ResponseInterface - { - $this->fingerprint = $fingerprint; - return $this; - } - - /** - * @inheritdoc - */ - public function getServerMemo() - { - return $this->memo; - } - - /** - * @inheritdoc - */ - public function setServerMemo($memo): ResponseInterface - { - $this->memo = $memo; - return $this; - } - - /** - * @inheritdoc - */ - public function getEffects() - { - return $this->effects; - } - - /** - * @inheritdoc - */ - public function setEffects($effects): ResponseInterface - { - $this->effects = $effects; - return $this; - } - - /** - * @return array - */ - public function toArrayWithoutHtml(): array - { - return [ - 'fingerprint' => $this->getFingerprint(), - 'effects' => array_diff_key($this->getEffects(), ['html' => null]), - 'serverMemo' => $this->getServerMemo(), - ]; - } - - /** - * @inheritdoc - * - * @throws LocalizedException - * @throws RootTagMissingFromViewException - */ - public function renderWithRootAttribute(array $data, bool $secure = false): string - { - $effects = $this->getEffects(); - $html = $effects['html'] ?? false; - - if ($html === false) { - throw new LocalizedException(__('No response HTML found')); - } - - preg_match('/<([a-zA-Z0-9\-]*)/', $html, $matches, PREG_OFFSET_CAPTURE); - - if (count($matches) === 0) { - throw new RootTagMissingFromViewException(); - } - - $attributes = implode(' ', $this->functionsHelper->map(function($value, $key) { - return sprintf('%s="%s"', $key, $value); - }, $this->functionsHelper->mapWithKeys(function ($value, $key) { - return ["wire:{$key}" => $this->functionsHelper->escapeStringForHtml($value)]; - }, $data))); - - return substr_replace($html, ' ' . $attributes, $matches[1][1] + strlen($matches[1][0]), 0); - } -} diff --git a/src/Model/View/Element/Attributes.php b/src/Model/View/Element/Attributes.php new file mode 100644 index 00000000..95d1082e --- /dev/null +++ b/src/Model/View/Element/Attributes.php @@ -0,0 +1,63 @@ +all(); + + if (empty($attributes)) { + return ''; + } + + $parts = []; + + foreach ($attributes as $name => $value) { + if (is_bool($value)) { + if ($value) { + $parts[] = $name; + } + + continue; + } + + if ($value === null) { + continue; + } + if (is_array($value)) { + $value = implode(' ', $value); + } + + $parts[] = sprintf('%s="%s"', $name, $this->escaper->escapeHtmlAttr($value)); + } + + return empty($parts) ? '' : ' ' . implode(' ', $parts); + } +} diff --git a/src/Model/View/Element/Properties.php b/src/Model/View/Element/Properties.php new file mode 100644 index 00000000..684c4cf3 --- /dev/null +++ b/src/Model/View/Element/Properties.php @@ -0,0 +1,18 @@ + $validators */ + private array $validators = []; + + /** + * @param array $modifiers + */ + public function __construct( + protected LoggerInterface $logger, + protected Escaper $escaper, + private array $modifiers = [] + ) { + $this->id = Random::alphabetical(10); + } + + public function id(): string + { + return $this->id; + } + + /** + * Begins output buffering for the fragment. + * + * Prepares the fragment by calling setup and initiates output buffering + * so that any output can later be captured and processed. + */ + public function start(): static + { + if ($this->buffering) { + return $this; + } + + ob_start(); + + $this->buffering = true; + $this->level ??= ob_get_level(); + + return $this; + } + + /** + * Ends output buffering, processes and echos the captured content. + * + * Retrieves the buffered output as raw content, applies an optional transformation + * to produce the final content, and triggers the inspection process. + */ + public function end(): static + { + if (! $this->buffering || ob_get_level() !== $this->level) { + return $this; + } + + // Stores the output to be able to flag buffering as false. + $this->raw = trim(ob_get_clean()); + $this->buffering = false; + + try { + $this->output = $this->render(); + } catch (EmptyFragmentException $exception) { + // Unreachable: fragment buffering state verified in method preconditions. + } + + // Echo result (hide content when not allowed to render). + echo $this->echo ? $this->output : ''; + + return $this; + } + + /** + * Locks the fragment so that it cannot be modified. + */ + public function lock(): static + { + $this->mutable = false; + return $this; + } + + /** + * Avoid output echoing. + */ + public function mute(): static + { + $this->echo = false; + return $this; + } + + public function withTag(string $tag): static + { + if ($this->canTagFragment($tag)) { + return $this->withTagOrigin($tag); + } + + return $this; + } + + /** + * Retrieve the raw output content. + */ + protected function getRawOutput(): string + { + return $this->raw === false ? '' : $this->raw; + } + + /** + * @throws FragmentValidationException + */ + protected function validate(): bool + { + if ($this->buffering) { + return false; + } + + foreach ($this->validators as $validator) { + if ($validator($this->raw)) { + continue; + } + + throw new FragmentValidationException('Fragment did not pass validation.'); + } + + return true; + } + + /** + * Sets a validation callback who can only return true or false. + */ + protected function withValidator(callable $callback): static + { + $this->validators[] = $callback; + + return $this; + } + + /** + * Set a fragment modifier. + */ + protected function withModifier(FragmentModifier|callable $modifier): static + { + $this->modifiers[] = $modifier; + + return $this; + } + + protected function handleModifierException(Throwable $exception): static + { + $message = 'An unexpected exception occurred while modifying a fragment.'; + $this->logger->critical($message, ['exception' => $exception]); + + return $this; + } + + protected function handleValidationException(Throwable $exception): string + { + if ($exception instanceof EmptyFragmentException) { + return ''; + } + + $message = 'A validation exception occurred while processing the fragment.'; + $this->logger->critical($message, ['exception' => $exception]); + + return $this->raw ?: ''; + } + + protected function handleRenderException(Throwable $exception): string + { + $message = 'A render exception occurred while processing the fragment.'; + $this->logger->critical($message, ['exception' => $exception]); + + return ''; + } + + /** + * Returns the final fragment output. + */ + protected function render(): string + { + try { + $output = $this->raw; + + if ($output) { + try { + if ($this->validate()) { + $this->modify(); + } + } catch (Throwable $exception) { + $output = $this->handleValidationException($exception); + } + + return $output; + } + + if ($this->buffering) { + throw new EmptyFragmentException('Unclosed output buffer detected. Fragment buffering must be properly terminated.'); + } + } catch (Throwable $exception) { + return $this->handleRenderException($exception); + } + + return ''; + } + + /** + * Runs all injected fragment modifiers. + */ + private function modify(): static + { + $output = $this->raw; + + if ($this->mutable && $output) { + foreach ($this->modifiers as $modifier) { + try { + if (is_callable($modifier)) { + $modifier($this); + } elseif ($modifier instanceof FragmentModifier) { + $modifier->modify($this); + } + } catch (Throwable $exception) { + $this->handleModifierException($exception); + } + } + } + + return $this; + } + + private function canTagFragment(string|null $tag = null): bool + { + if (is_string($tag) && $this->hasTags([$tag])) { + return false; + } + if (! $this->mutable || $this->buffering || is_string($this->raw)) { + return false; + } + + return true; + } +} diff --git a/src/Model/View/Fragment/Component.php b/src/Model/View/Fragment/Component.php new file mode 100644 index 00000000..44ecb0d3 --- /dev/null +++ b/src/Model/View/Fragment/Component.php @@ -0,0 +1,34 @@ +block; + } +} diff --git a/src/Model/View/Fragment/Element.php b/src/Model/View/Fragment/Element.php new file mode 100644 index 00000000..8e5154da --- /dev/null +++ b/src/Model/View/Fragment/Element.php @@ -0,0 +1,93 @@ +dictionary ??= Factory::create(DataArray::class); + } + + /** + * @return DataArray + */ + public function attributes(): DataArray + { + return $this->data()->attributes(); + } + + /** + * Returns the element properties/settings. + */ + public function properties(): DataArray + { + return $this->data()->properties(); + } + + public function prop(string $name, mixed $default = null) + { + return $this->properties()->get($name, $default); + } + + public function data(): DomElementData + { + return $this->data ??= Factory::create(DomElementData::class); + } + + /** + * Returns the elements parent block. + */ + public function parent(): AbstractBlock + { + return $this->block; + } + + public function track(): static + { + $this->slotsRegistry->track($this); + return $this; + } + + public function untrack(): static + { + $this->slotsRegistry->untrack(); + return $this; + } +} diff --git a/src/Model/View/Fragment/Element/Slot.php b/src/Model/View/Fragment/Element/Slot.php new file mode 100644 index 00000000..61d8d184 --- /dev/null +++ b/src/Model/View/Fragment/Element/Slot.php @@ -0,0 +1,92 @@ +slotsRegistry->register($this->variant, $this); + + return parent::start(); + } + + /** + * Complete slot content capture and register the output. + * + * Finalizes the fragment rendering to ensure all buffered output is captured, + * then registers the captured content with the SlotsRegistry under this slot's + * variant name, making it available for retrieval in parent templates. + */ + public function end(): static + { + // First, complete the fragment rendering to ensure output is fully buffered. + parent::end(); + + // Register the captured output as the content for this named slot (identified by variant). + // This makes it available in the component view as a slot variable (e.g., $header). + $this->slotsRegistry->update($this->variant, $this->output); + + return $this; + } + + /** + * Override to prevent slot area tracking. + * + * Slots do not create nested tracking contexts in the registry since they + * simply register content as named slots rather than managing their own + * slot collections. This no-op implementation ensures track() calls are + * safely ignored. + */ + public function track(): static + { + return $this; + } + + /** + * Override to prevent slot area untracking. + * + * Slots do not manage tracking contexts. This no-op implementation ensures + * untrack() calls are safely ignored, maintaining the current area's tracking + * state without interfering with the parent fragment's lifecycle. + */ + public function untrack(): static + { + return $this; + } +} diff --git a/src/Model/View/Fragment/Element/Unknown.php b/src/Model/View/Fragment/Element/Unknown.php new file mode 100644 index 00000000..2f9271de --- /dev/null +++ b/src/Model/View/Fragment/Element/Unknown.php @@ -0,0 +1,48 @@ +raw = sprintf('', $this->variant); + + if ($this->applicationState->getMode() === ApplicationState::MODE_PRODUCTION) { + $this->raw = ''; + } + + return parent::render(); + } +} diff --git a/src/Model/View/Fragment/Exceptions/EmptyFragmentException.php b/src/Model/View/Fragment/Exceptions/EmptyFragmentException.php new file mode 100644 index 00000000..cfe14f88 --- /dev/null +++ b/src/Model/View/Fragment/Exceptions/EmptyFragmentException.php @@ -0,0 +1,18 @@ +buffering = true; + $this->raw = $input; + + try { + $this->start(); + $output = $this->render(); + $this->end(); + + return $output; + } catch (Throwable $exception) { + return $this->handleRenderException($exception); + } + } + + public function withAttribute(string $name, string|float|int|null $value = null, string $area = 'root'): static + { + if ($value === null) { + $this->attributes[$area][] = $name; + } else { + $this->attributes[$area][$name] = $value; + } + + return $this; + } + + public function withAttributes(array $attributes, string $area = 'root'): static + { + foreach ($attributes as $name => $value) { + $this->withAttribute($name, $value, $area); + } + + return $this; + } + + protected function render(): string + { + $render = parent::render(); + $attributes = $this->getAreaAttributes('root'); + + if (! empty($attributes)) { + $attributeStrings = []; + + foreach ($attributes as $attribute => $value) { + $attributeStrings[] = is_numeric($attribute) ? $value : $attribute . '="' . $this->escaper->escapeHtmlAttr($value) . '"'; + } + + if (! empty($attributeStrings)) { + $attributeString = ' ' . implode(' ', $attributeStrings); + $render = preg_replace('/^(<[^>\s]+)/', '$1' . $attributeString, $render, 1); + } + } + + return trim($render); + } + + protected function getAttributes(): array + { + return $this->attributes; + } + + protected function getAreaAttributes(string $area): array + { + if ($area === 'root') { + // Filter those who are not an 'area' (an array value). + $attributes = array_filter($this->attributes, static fn ($value) => ! is_array($value)); + + return array_merge($this->attributes[$area] ?? [], $attributes); + } + + return $this->attributes[$area] ?? []; + } +} diff --git a/src/Model/View/Fragment/Javascript.php b/src/Model/View/Fragment/Javascript.php new file mode 100644 index 00000000..5be5b097 --- /dev/null +++ b/src/Model/View/Fragment/Javascript.php @@ -0,0 +1,18 @@ +csp->isCspAvailable()) { + return $fragment; + } + + if ($this->useCspHeader()) { + $this->includeCspHeader($fragment); + } else { + $fragment->withAttribute('nonce', $this->csp->getMagentoCspNonceProvider()->generateNonce()); + } + + return $fragment; + } + + private function includeCspHeader(Fragment\Script $fragment): void + { + // Generate a hash based on the DOM node content. + $hash = $this->csp->generateHash($fragment->getScriptCode()); + $algorithm = $this->csp->getHashAlgorithm(); + + $this->dynamicCspCollector->add(new FetchPolicy('script-src', false, [], [], false, false, false, [], [$hash => $algorithm])); + } + + private function useCspHeader(): bool + { + return $this->cacheState->isEnabled(FPC::TYPE_IDENTIFIER) && $this->layout->isCacheable(); + } +} diff --git a/src/Model/View/Fragment/Modifier/Developer.php b/src/Model/View/Fragment/Modifier/Developer.php new file mode 100644 index 00000000..56491c59 --- /dev/null +++ b/src/Model/View/Fragment/Modifier/Developer.php @@ -0,0 +1,33 @@ +applicationState->getMode() === ApplicationState::MODE_DEVELOPER) { + $fragment->withAttribute('magewire-fragment'); + } + + return $fragment; + } +} diff --git a/src/Model/View/Fragment/Script.php b/src/Model/View/Fragment/Script.php new file mode 100644 index 00000000..cf6d37bb --- /dev/null +++ b/src/Model/View/Fragment/Script.php @@ -0,0 +1,43 @@ +withValidator(static fn ($script) => str_starts_with($script, 'withValidator(static fn ($script) => str_ends_with($script, '')); + } + + /** + * Returns the content between the script tags. + */ + public function getScriptCode(): string + { + if (is_string($this->code)) { + return $this->code; + } + + $start = strpos($this->raw, '>'); + $end = strrpos($this->raw, '<'); + + if ($start !== false && $end !== false && $start < $end) { + $this->code = trim(substr($this->raw, $start + 1, $end - $start - 1)); + } + + return $this->code ?? ''; + } +} diff --git a/src/Model/View/Fragment/Style.php b/src/Model/View/Fragment/Style.php new file mode 100644 index 00000000..e2a9eba9 --- /dev/null +++ b/src/Model/View/Fragment/Style.php @@ -0,0 +1,16 @@ +element('slot', $block, $target); + } + + /** + * @template T of Element + * @param class-string $type + * @return T + * @throws LogicException + */ + public function element(string $type, AbstractBlock $block, string $variant = 'default'): Element + { + $type = $this->elements[$type] ?? Element\Unknown::class; + + return $this->create($type, ['variant' => $variant, 'block' => $block]); + } + + /** + * @template T of Fragment + * @param class-string $type + * @return T + * @throws LogicException + */ + private function create(string $type, array $arguments = []): Fragment + { + $fragment = Factory::create($type, $arguments); + + if ($fragment instanceof Fragment) { + return $fragment; + } + + throw new LogicException(sprintf('Class "%s" does not implement Fragment interface. Expected Fragment, got %s.', $type, get_debug_type($fragment))); + } +} diff --git a/src/Model/View/FragmentFactory.php b/src/Model/View/FragmentFactory.php new file mode 100644 index 00000000..0e7bc1c2 --- /dev/null +++ b/src/Model/View/FragmentFactory.php @@ -0,0 +1,100 @@ + $types + */ + public function __construct( + private FragmentElementFactory $elementFactory, + private array $types = [] + ) { + } + + public function html(): Html + { + return $this->create(Html::class); + } + + public function javascript(): Script + { + return $this->create(Javascript::class); + } + + public function script(): Script + { + return $this->create(Script::class); + } + + public function style(): Style + { + return $this->create(Style::class); + } + + public function elements(): FragmentElementFactory + { + return $this->elementFactory; + } + + public function component(AbstractBlock $block): Component + { + return $this->create(Component::class, ['block' => $block]); + } + + /** + * @template T of Fragment + * @param string $name + * @return T + * @throws InvalidArgumentException + * @phpstan-return T + */ + public function custom(string $name, array $arguments = []): Fragment + { + if (array_key_exists($name, $this->types)) { + return $this->create($this->types[$name], $arguments); + } + if (class_exists($name)) { + return $this->create($name, $arguments); + } + + throw new InvalidArgumentException(sprintf('Unknown fragment type "%s".', $name)); + } + + /** + * @template T of Fragment + * @param class-string $type + * @return T + * @throws LogicException + */ + private function create(string $type, array $arguments = []): Fragment + { + $fragment = Factory::create($type, $arguments); + + if ($fragment instanceof Fragment) { + return $fragment; + } + + throw new LogicException(sprintf('Class "%s" does not implement Fragment interface. Expected Fragment, got %s.', $type, get_debug_type($fragment))); + } +} diff --git a/src/Model/View/FragmentModifier.php b/src/Model/View/FragmentModifier.php new file mode 100644 index 00000000..a4d82d5b --- /dev/null +++ b/src/Model/View/FragmentModifier.php @@ -0,0 +1,32 @@ +name; + } + + /** + * Update the slot's content. + * + * Sets the rendered content for this slot. Once set, this content will be + * returned when the slot is cast to a string. + */ + public function update(string $content): static + { + $this->content = $content; + + return $this; + } + + /** + * Retrieve a property value from the slot's element. + * + * Properties are custom data attributes associated with the element + * that owns this slot. + */ + public function prop(string $name, mixed $default = null) + { + return $this->element + ->data() + ->properties() + ->get($name, $default); + } + + /** + * Retrieve an HTML attribute value from the slot's element. + * + * Attributes are standard HTML attributes (class, id, data-*, etc.) + * associated with the element that owns this slot. + */ + public function attr(string $name, mixed $default = '') + { + return $this->element + ->data() + ->attributes() + ->get($name, $default); + } + + /** + * Get the element that owns this slot. + * + * Provides access to the parent fragment element for retrieving additional + * context, data, or performing element-level operations. + */ + public function element(): Element + { + return $this->element; + } + + /** + * Convert the slot to its string representation. + */ + public function __toString(): string + { + return $this->content ??= sprintf('', $this->name); + } +} diff --git a/src/Model/View/SlotSnapshot.php b/src/Model/View/SlotSnapshot.php new file mode 100644 index 00000000..69b92d8d --- /dev/null +++ b/src/Model/View/SlotSnapshot.php @@ -0,0 +1,59 @@ + $slots + */ + public function __construct( + private readonly array $slots = [] + ) { + } + + /** + * Retrieve a slot by name. + * + * Allows the snapshot to be called as a function to access slots. + * Returns null if the requested slot doesn't exist. + */ + public function __invoke(string $name = 'default'): Slot|string|null + { + return $this->slots[$name] ?? null; + } + + /** + * Convert the snapshot to its string representation. + * + * Returns the content of the default slot when the snapshot is used + * in a string context (e.g., echo, concatenation). + */ + public function __toString() + { + return $this->slots['default']->__toString(); + } +} diff --git a/src/Model/View/SlotsRegistry.php b/src/Model/View/SlotsRegistry.php new file mode 100644 index 00000000..28852f2f --- /dev/null +++ b/src/Model/View/SlotsRegistry.php @@ -0,0 +1,199 @@ +> $slots */ + private array $slots = []; + /** @var array $counters */ + private array $counters = []; + /** @var array */ + private array $elements = []; + + private string $area = 'root-0'; + + /** + * Start tracking slots for a new area. + * + * Pushes a new slot collection onto the stack, initializes counters for the area, + * and automatically creates a 'default' slot to capture the area's overall content. + * The area identifier is generated by combining the element ID with an incremental counter. + */ + public function track(Element $element): void + { + $area ??= $element->id(); + + // Initialize counter for this base key if it doesn't exist. + if (! isset($this->counters[$area])) { + $this->counters[$area] = -1; + } + + $this->counters[$area]++; + // Register the latest area. + $this->area = $area . '-' . $this->counters[$area]; + // Register the slot bag. + $this->slots[$this->area] = []; + // Register the current element. + $this->elements[$this->area] = $element; + + // Register the default slot, meant for the overall content. + $this->register('default', $element); + } + + /** + * Stop tracking slots for the current area and return its slots. + * + * Pops the current area from the stack, returning all slots that were + * registered within it. Automatically switches context back to the + * previous area in the stack. + * + * @return array + */ + public function untrack(): array + { + $area ??= $this->area; + + // Get the slots for this area (not array_pop) + $slots = $this->slots[$area] ?? []; + // Remove this area from the slots tracking + unset($this->slots[$area]); + // Set the current area to the last remaining area + $this->area = array_key_last($this->slots) ?? 'root-0'; + + return $slots; + } + + /** + * Retrieve a slot by name from the current area. + * + * Returns null if the slot doesn't exist. Does not create the slot + * if missing - use register() for slot creation. + */ + public function get(string $name): Slot|null + { + $current = $this->slots(); + + return $current[$name] ?? null; + } + + /** + * Create and register a new slot in the current area. + * + * Instantiates a new Slot object and adds it to the current area's + * collection. The slot is immediately available for content updates. + */ + public function register(string $name, Element $element): Slot + { + $slot = Factory::create(Slot::class, ['name' => $name, 'element' => $element]); + $this->slots[$this->area][$slot->name()] = $slot; + + return $slot; + } + + /** + * Update the content of an existing slot. + * + * Retrieves the slot by name and updates its content. The slot must + * already exist in the current area. + */ + public function update(string $name, string $content): static + { + $this->get($name)->update($content); + + return $this; + } + + /** + * Render a slot's content as a string. + * + * Convenience method that retrieves a slot and returns its string + * representation, useful for direct output in templates. + */ + public function print(string $name): string + { + return $this->get($name)->__toString(); + } + + /** + * Check if a slot exists in the current area. + */ + public function exists(string $name): bool + { + return isset($this->slots[$this->area][$name]); + } + + /** + * Check if any areas are currently being tracked. + * + * Useful for determining if the registry has active slot contexts + * beyond the root level. + */ + public function hasAreas(): bool + { + return count($this->slots) !== 0; + } + + /** + * Create an immutable snapshot of the current area's slots. + * + * Generates a SlotSnapshot containing all slots from the current area. + * The snapshot can be passed between template layers or stored for + * deferred rendering. + */ + public function snapshot(): SlotSnapshot + { + return Factory::create(SlotSnapshot::class, [ + 'slots' => $this->slots() + ]); + } + + /** + * Get the element that owns the current area. + * + * Returns the parent element for the currently active area, providing + * access to element data, attributes, and properties. + */ + public function element(): Element|null + { + return $this->elements[$this->area] ?? null; + } + + /** + * Get all slots registered in the current area. + * + * Returns the complete slot collection for the currently active area. + * Used internally by other methods to access the current context. + * + * @return array + */ + private function slots(): array + { + return $this->slots[$this->area] ?? []; + } +} diff --git a/src/Model/View/Utils.php b/src/Model/View/Utils.php new file mode 100644 index 00000000..906135e3 --- /dev/null +++ b/src/Model/View/Utils.php @@ -0,0 +1,124 @@ + $utilities + */ + public function __construct( + private AlpineViewUtil $alpine, + private ApplicationViewUtil $application, + private EnvironmentViewUtil $environment, + private FragmentViewUtil $fragment, + private LayoutViewUtil $layout, + private MagewireViewUtil $magewire, + private SecurityViewUtil $security, + private TailwindViewUtil $tailwind, + private TemplateViewUtil $template, + private CspViewUtil $csp, + private array $utilities = [] + ) { + } + + public function alpinejs(): AlpineViewUtil + { + return $this->alpine; + } + + public function application(): ApplicationViewUtil + { + return $this->application; + } + + public function env(): EnvironmentViewUtil + { + return $this->environment; + } + + public function layout(): LayoutViewUtil + { + return $this->layout; + } + + public function magewire(): MagewireViewUtil + { + return $this->magewire; + } + + public function security(): SecurityViewUtil + { + return $this->security; + } + + public function tailwind(): TailwindViewUtil + { + return $this->tailwind; + } + + public function template(): TemplateViewUtil + { + return $this->template; + } + + public function csp(): CspViewUtil + { + return $this->csp; + } + + public function fragment(): FragmentViewUtil + { + return $this->fragment; + } + + /** + * @throws BadMethodCallException + */ + public function __call(string $utility, array $arguments = []): UtilsInterface + { + if (array_key_exists($utility, $this->utilities)) { + $subject = $this->utilities[$utility]; + + if ($subject instanceof UtilsInterface) { + if (count($arguments) === 0) { + return $subject; + } + + if (empty($arguments)) { + return $this->utilities[$utility] = Factory::get($subject::class); + } + + return Factory::create($subject::class, $arguments); + } + } + + throw new BadMethodCallException(sprintf('Utility "%s" was called but does not exist.', $utility)); + } +} diff --git a/src/Model/View/Utils/Alpine.php b/src/Model/View/Utils/Alpine.php new file mode 100644 index 00000000..8755c55e --- /dev/null +++ b/src/Model/View/Utils/Alpine.php @@ -0,0 +1,18 @@ +csp->isCspAvailable() ? $this->csp->getMagentoCspNonceProvider()->generateNonce() : ''; + } + + public function generateNonceAttribute(string $format = ' %s'): string + { + return $this->csp->isCspAvailable() ? sprintf(sprintf($format, 'nonce="%s"'), $this->escaper->escapeHtmlAttr($this->generateNonce())) : ''; + } +} diff --git a/src/Model/View/Utils/Environment.php b/src/Model/View/Utils/Environment.php new file mode 100644 index 00000000..52635f1b --- /dev/null +++ b/src/Model/View/Utils/Environment.php @@ -0,0 +1,33 @@ +applicationState->getMode() === ApplicationState::MODE_DEVELOPER; + } + + public function isProductionMode(): bool + { + return $this->applicationState->getMode() === ApplicationState::MODE_PRODUCTION; + } +} diff --git a/src/Model/View/Utils/Fragment.php b/src/Model/View/Utils/Fragment.php new file mode 100644 index 00000000..e78ff5e9 --- /dev/null +++ b/src/Model/View/Utils/Fragment.php @@ -0,0 +1,46 @@ +types (e.g., 'header', 'footer') + * 2. Direct class names + * + * @param string|null $name + * @return \Magewirephp\Magewire\Model\View\Fragment|FragmentFactory + * @throws InvalidArgumentException + * + * @see FragmentFactory::$types For registering custom fragment types via DI + */ + public function make(string|null $name = null): FragmentFactory|\Magewirephp\Magewire\Model\View\Fragment + { + if ($name) { + return $this->fragmentFactory->custom($name); + } + + return $this->fragmentFactory; + } +} diff --git a/src/Model/View/Utils/Layout.php b/src/Model/View/Utils/Layout.php new file mode 100644 index 00000000..388994ae --- /dev/null +++ b/src/Model/View/Utils/Layout.php @@ -0,0 +1,97 @@ + + */ + public function containerizeBlock(AbstractBlock|false $block): array + { + if ($block) { + return array_filter(array_map(function (string $child) use ($block) { + try { + return $block->getChildBlock($block->getLayout()->getElementAlias($child) ?? $child); + } catch (LocalizedException $exception) { + $this->logger->critical(sprintf('Can not retrieve child block: %s', $child), ['exception' => $exception]); + } + + return false; + }, $block->getChildNames())); + } + + return []; + } + + /** + * Determines whether the current block can be containerized. + */ + public function canContainerizeBlock(AbstractBlock|false $block): bool + { + if ($block) { + return count($this->containerizeBlock($block)) !== 0; + } + + return false; + } + + /** + * Renders all child blocks of the given block. + */ + public function renderBlockAsContainer(AbstractBlock|false $block): string + { + if ($block) { + return implode('', array_map(static function (object $child) { + return ( $child instanceof AbstractBlock ? $child->toHtml() : '' ) . PHP_EOL; + }, $this->containerizeBlock($block))); + } + + return ''; + } + + public function getChild(AbstractBlock|false $block, string $alias, array $data = []): AbstractBlock|null + { + if ($block) { + $child = $block->getChildBlock($alias); + + if ($child instanceof AbstractBlock) { + if (! empty($data)) { + $child->addData($data); + } + + return $child; + } + } + + return null; + } + + public function getChildHtml(AbstractBlock|false $block, string $alias, array $data = []): string + { + $child = $this->getChild($block, $alias, $data); + + return $child ? $child->toHtml() : ''; + } +} diff --git a/src/Model/View/Utils/Magewire.php b/src/Model/View/Utils/Magewire.php new file mode 100644 index 00000000..bb9aba9b --- /dev/null +++ b/src/Model/View/Utils/Magewire.php @@ -0,0 +1,76 @@ +features; + } + + public function mechanisms(): MechanismsViewUtil + { + return $this->mechanisms; + } + + public function config(): MagewireSystemConfig + { + return $this->config; + } + + public function getUpdateUri(): string + { + return '/magewire/update'; + } + + public function logger(): LoggerInterface + { + return $this->logger; + } + + public function canRequireMagewireJsLibrary(): bool + { + try { + return $this->mechanisms()->resolveComponents()->doesPageHaveComponents(); + } catch (BadMethodCallException $exception) { + $this->logger()->critical($exception->getMessage(), ['exception' => $exception]); + } + + return false; + } + + /** + * @deprecated Work in Progress. + */ + public function build(): Builder + { + return $this->builder; + } +} diff --git a/src/Model/View/Utils/Magewire/Builder.php b/src/Model/View/Utils/Magewire/Builder.php new file mode 100644 index 00000000..a80362cf --- /dev/null +++ b/src/Model/View/Utils/Magewire/Builder.php @@ -0,0 +1,37 @@ +featuresServiceType->viewModel($utility); + } catch (NotFoundException $exception) { + $this->logger->critical($exception->getMessage(), ['exception' => $exception]); + + throw new BadMethodCallException(sprintf('Feature view model "%1" does not exist.', $utility)); + } + } +} diff --git a/src/Model/View/Utils/Magewire/Mechanisms.php b/src/Model/View/Utils/Magewire/Mechanisms.php new file mode 100644 index 00000000..b13befb2 --- /dev/null +++ b/src/Model/View/Utils/Magewire/Mechanisms.php @@ -0,0 +1,49 @@ +mechanismsServiceType->viewModel($utility); + } catch (NotFoundException $exception) { + $this->logger->critical($exception->getMessage(), ['exception' => $exception]); + + throw new BadMethodCallException(sprintf('Mechanism view model "%s" does not exist.', $utility)); + } + } +} diff --git a/src/Model/View/Utils/Security.php b/src/Model/View/Utils/Security.php new file mode 100644 index 00000000..285e5cf1 --- /dev/null +++ b/src/Model/View/Utils/Security.php @@ -0,0 +1,37 @@ +formKey->getFormKey(); + } catch (LocalizedException $exception) { + $this->logger->critical($exception->getMessage(), ['exception' => $exception]); + } + + return 'unknown-csrf-token'; + } +} diff --git a/src/Model/View/Utils/Tailwind.php b/src/Model/View/Utils/Tailwind.php new file mode 100644 index 00000000..2613acab --- /dev/null +++ b/src/Model/View/Utils/Tailwind.php @@ -0,0 +1,18 @@ +escaper->escapeHtml($text) . '. -->' . PHP_EOL; + } + + return ''; + } +} diff --git a/src/Model/View/UtilsInterface.php b/src/Model/View/UtilsInterface.php new file mode 100644 index 00000000..10bf7dc3 --- /dev/null +++ b/src/Model/View/UtilsInterface.php @@ -0,0 +1,16 @@ +magewireServiceProvider->setup(); + } +} diff --git a/src/Observer/Frontend/HyvaConfigGenerateBefore.php b/src/Observer/Frontend/HyvaConfigGenerateBefore.php new file mode 100644 index 00000000..8d512a25 --- /dev/null +++ b/src/Observer/Frontend/HyvaConfigGenerateBefore.php @@ -0,0 +1,48 @@ +componentRegistrar = $componentRegistrar; + } + + function execute(Observer $event) + { + $config = $event->getData('config'); + $extensions = $config->hasData('extensions') ? $config->getData('extensions') : []; + + $path = $this->componentRegistrar->getPath(ComponentRegistrar::MODULE, 'Magewirephp_Magewire'); + + // Since Hyva is the first-party, it needs to also register Magewire itself. + $extensions[] = ['src' => substr($path, strlen(BP) + 1)]; + + $moduleName = implode('_', array_slice(explode('\\', __CLASS__), 0, 2)); + $path = $this->componentRegistrar->getPath(ComponentRegistrar::MODULE, $moduleName); + + // Only use the path relative to the Magento base dir. + $extensions[] = ['src' => substr($path, strlen(BP) + 1)]; + + $config->setData('extensions', $extensions); + } +} diff --git a/src/Observer/Frontend/ViewBlockAbstract.php b/src/Observer/Frontend/ViewBlockAbstract.php deleted file mode 100644 index 9a83981b..00000000 --- a/src/Observer/Frontend/ViewBlockAbstract.php +++ /dev/null @@ -1,100 +0,0 @@ -applicationState = $applicationState; - $this->componentHelper = $componentHelper; - $this->componentManager = $componentManager; - $this->httpFactory = $httpFactory; - } - - /** - * @return ComponentHelper - */ - public function getComponentHelper(): ComponentHelper - { - return $this->componentHelper; - } - - /** - * @return ComponentManager - */ - public function getComponentManager(): ComponentManager - { - return $this->componentManager; - } - - /** - * @return HttpFactory - */ - public function getHttpFactory(): HttpFactory - { - return $this->httpFactory; - } - - /** - * @param Template $block - * @param Exception $exception - * @throws SubsequentRequestException - */ - public function throwException(Template $block, Exception $exception): void - { - $magewire = $block->getMagewire(); - - if ($magewire->getRequest() && $magewire->getRequest()->isSubsequent()) { - throw new SubsequentRequestException($exception->getMessage()); - } - - // Detach the component who's given the problems - $block->unsetData('magewire'); - - $block->setTemplate('Magewirephp_Magewire::component/exception.phtml'); - $block->setException($exception); - $block->setApplicationState($this->applicationState); - } -} diff --git a/src/Observer/Frontend/ViewBlockAbstractToHtmlAfter.php b/src/Observer/Frontend/ViewBlockAbstractToHtmlAfter.php deleted file mode 100644 index 55815892..00000000 --- a/src/Observer/Frontend/ViewBlockAbstractToHtmlAfter.php +++ /dev/null @@ -1,79 +0,0 @@ -getBlock(); - - if ($block->hasMagewire()) { - try { - $component = $this->getComponentHelper()->extractComponentFromBlock($block); - $response = $component->getResponse(); - - if ($response === null) { - throw new LifecycleException(__('Component response object not found')); - } - - $observer->getTransport()->setHtml( - $this->renderToView($response, $component, $observer->getTransport()->getHtml()) - ); - } catch (Exception $exception) { - $this->throwException($block, $exception); - } - } - } - - /** - * @param ResponseInterface $response - * @param Component $component - * @param string $html - * @return string|null - * @throws LifecycleException - */ - public function renderToView(ResponseInterface $response, Component $component, string $html): ?string - { - // Bind intended HTML onto the Response - $response->effects['html'] = $html; - // Dehydration lifecycle step - $this->getComponentManager()->dehydrate($component); - - $data = ['id' => $response->fingerprint['id']]; - - if ($response->getRequest()->isPreceding()) { - $data['initial-data'] = $response->toArrayWithoutHtml(); - } - - if ($component->canRender() === false) { - $response->effects['html'] = null; - } else if (is_string($response->effects['html'])) { - $response->effects['html'] = $response->renderWithRootAttribute($data); - } - - return $response->effects['html']; - } -} diff --git a/src/Observer/Frontend/ViewBlockAbstractToHtmlBefore.php b/src/Observer/Frontend/ViewBlockAbstractToHtmlBefore.php deleted file mode 100644 index d5a20427..00000000 --- a/src/Observer/Frontend/ViewBlockAbstractToHtmlBefore.php +++ /dev/null @@ -1,112 +0,0 @@ -getBlock(); - - if ($block->hasMagewire()) { - try { - $component = $this->getComponentHelper()->extractComponentFromBlock($block); - $component->setParent($this->determineTemplate($block)); - - $request = $component->getRequest(); - $data = $this->getComponentHelper()->extractDataFromBlock($block); - - // Fix for subsequent rendered wired children via e.g. a getChildHtml() - if ($request !== null && $request->isSubsequent()) { - $this->overwriteUpdateHandle($request->getFingerprint('handle')); - } - if (($request === null) || ($request->isPreceding())) { - $request = $this->getComponentManager()->createInitialRequest( - $block, $component, $data, $this->getUpdateHandle() - ); - } - - // Hydration lifecycle step - $this->getComponentManager()->hydrate($component->setRequest($request)); - - if ($component->hasRequest('updates')) { - $this->getComponentManager()->processUpdates($component, $request->getUpdates()); - } - - // Finalize component with its Response object - $component->setResponse($this->getHttpFactory()->createResponse($component->getRequest())); - // Re-attach the component onto the block - $block->setData('magewire', $component); - } catch (Exception $exception) { - $this->throwException($block, $exception); - } - } - } - - /** - * Determines the template by a default template path - * when the path is not defined within the layout. - * - * Results in: {Module_Name::magewire/dashed-class-name.phtml} - * - * @param Template $block - * @return Template - * @throws MissingComponentException - */ - public function determineTemplate(Template $block): Template - { - if ($block->getTemplate() === null) { - $magewire = $this->getComponentHelper()->extractComponentFromBlock($block); - $module = explode('\\', get_class($magewire)); - - $prefix = $module[0] . '_' . $module[1]; - $affix = strtolower(preg_replace('/(?setTemplate($prefix . '::magewire/' . $affix . '.phtml'); - } - - return $block; - } - - /** - * @param string $handle - * @return string - */ - public function overwriteUpdateHandle(string $handle): string - { - return $this->updateHandle = $handle; - } - - /** - * @return string|null - */ - public function getUpdateHandle(): ?string - { - return $this->updateHandle; - } -} diff --git a/src/Observer/ViewBlockAbstractToHtmlAfter.php b/src/Observer/ViewBlockAbstractToHtmlAfter.php new file mode 100644 index 00000000..1932ce33 --- /dev/null +++ b/src/Observer/ViewBlockAbstractToHtmlAfter.php @@ -0,0 +1,75 @@ +getData('block'); + /** @var Component|mixed $magewire */ + $magewire = $block->getData('magewire'); + /** @var DataObject $transport */ + $transport = $observer->getData('transport'); + + if ($magewire) { + $html = $transport->getHtml(); + + try { + if (! $magewire instanceof Component) { + throw new ComponentNotFoundException('Something went wrong.'); + } + + if (! store($magewire)->get('magewire:update', false)) { + [$block, $html] = $this->magewireManager->render($block, $html); + $observer->setData('block', $block); + } + } catch (Exception $exception) { + $block = $this->exceptionManager->handleWithBlock($block, $exception); + // Making sure we do not end up in a cyclic event loop. + $block->unsetData('magewire'); + + $html = $block->toHtml(); + } + + $transport->setHtml($html); + } + + $lifecycle = $this->componentRenderLifecycleManager->target('magewire')->pop(); + trigger('magento:block:rendered', $lifecycle, $block); + } +} diff --git a/src/Observer/ViewBlockAbstractToHtmlBefore.php b/src/Observer/ViewBlockAbstractToHtmlBefore.php new file mode 100644 index 00000000..888fda0c --- /dev/null +++ b/src/Observer/ViewBlockAbstractToHtmlBefore.php @@ -0,0 +1,120 @@ +getData('block'); + /** @var mixed $magewire */ + $magewire = $block->getData('magewire'); + + $lifecycle = $this->componentRenderLifecycleManager->target('magewire')->push($block); + trigger('magento:block:render', $lifecycle, $block); + + if ($magewire) { + try { + if ($magewire instanceof Component) { + // Update flag for use during subsequent updates to maintain synchronization and data consistency. + $update = store($magewire)->get('magewire:update', false); + + if ($update) { + $this->handleUpdate($update, $block); + + return; + } + } + + /** + * Magewire has two trigger points for booting itself. One occurs during the rendering of a block. + * This is the only feasible location, after confirming we are not on an update request, + * where we should attempt to boot. A reboot is unlikely as the boot method includes a + * safety trigger to prevent such an occurrence. [1/2] + * + * @see \Magewirephp\Magewire\Controller\Router + */ + $this->magewireServiceProvider->boot(RequestMode::PRECEDING); + + $construct = trigger('magewire:component:construct', $block); + $block = $construct(); + + $this->handleMount($block); + } catch (Exception $exception) { + $this->exceptionManager->handleWithBlock($block, $exception); + } + } + } + + /** + * @throws FileSystemException + * @throws RuntimeException + * @throws NotFoundException + */ + private function handleUpdate(ComponentRequestContext $update, AbstractBlock $block): void + { + $this->magewireManager->update( + $update->getSnapshot(), + $update->getUpdates(), + $update->getCalls(), + $block + ); + } + + /** + * @throws NotFoundException + * @throws RuntimeException + */ + private function handleMount(AbstractBlock $block): void + { + /** @var Component $component */ + $component = $block->getData('magewire'); + + $this->magewireManager->mount( + $component->getName(), + $component->magewireResolver()->arguments()->forMount(), + $block->getCacheKey(), + $block, + $component + ); + } +} diff --git a/src/Plugin/Magento/Framework/View/TemplateEngine/Php.php b/src/Plugin/Magento/Framework/View/TemplateEngine/Php.php new file mode 100644 index 00000000..f6db19c5 --- /dev/null +++ b/src/Plugin/Magento/Framework/View/TemplateEngine/Php.php @@ -0,0 +1,47 @@ + $block, + 'filename' => $filename, + 'dictionary' => $dictionary, + ]); + + $this->renderers[] = trigger('magento:template:render', $dto, $subject); + + return [$dto->block(), $dto->filename(), $dto->dictionary()]; + } + + public function afterRender(Subject $subject, string $html): string + { + $finish = array_pop($this->renderers); + + return $finish ? $finish($html, $subject) : $html; + } +} diff --git a/src/Setup/Patch/Data/EnableCache.php b/src/Setup/Patch/Data/EnableCache.php new file mode 100644 index 00000000..bf2e7c31 --- /dev/null +++ b/src/Setup/Patch/Data/EnableCache.php @@ -0,0 +1,45 @@ +cacheManager->setEnabled(array_intersect($this->cacheManager->getAvailableTypes(), [MagewireCacheType::TYPE_IDENTIFIER]), true); + + $this->cacheManager->clean($enableMagewireCache); + return $this; + } +} diff --git a/src/ViewModel/Magewire.php b/src/ViewModel/Magewire.php index f78cf56f..66481adc 100644 --- a/src/ViewModel/Magewire.php +++ b/src/ViewModel/Magewire.php @@ -1,4 +1,5 @@ -environment() should be used instead. + * @see ViewUtils\Environment + */ + function isDeveloperMode(): bool + { + return $this->utils()->env()->isDeveloperMode(); + } - /** @var ProductMetadataInterface $productMetaData */ - protected $productMetaData; + /** + * @deprecated To make backwards compatible, utils()->environment() should be used instead. + * @see ViewUtils\Environment + */ + function isProductionMode(): bool + { + return $this->utils()->env()->isProductionMode(); + } /** - * @param FormKey $formKey - * @param ApplicationState $applicationState - * @param ProductMetadataInterface $productMetadata + * @deprecated To make backwards compatible, utils()->magewire() should be used instead. + * @see ViewUtils\Magewire */ - public function __construct( - FormKey $formKey, - ApplicationState $applicationState, - ProductMetadataInterface $productMetadata - ) { - $this->formKey = $formKey; - $this->applicationState = $applicationState; - $this->productMetaData = $productMetadata; + function getSystemConfig(): MagewireSystemConfig + { + return $this->utils()->magewire()->config(); } /** - * @return bool + * @deprecated To make backwards compatible, utils()->magewire() method should be used instead. + * @see ViewUtils\Magewire */ - public function isDeveloperMode(): bool + function pageRequiresMagewire(): bool { - return $this->applicationState->getMode() === ApplicationState::MODE_DEVELOPER; + return $this->utils() + ->magewire() + ->mechanisms() + ->resolveComponents() + ->doesPageHaveComponents(); } /** - * @return bool + * @deprecated To make backwards compatible, utils()->magewire() method should be used instead. + * @see ViewUtils\Magewire */ - public function isBeforeTwoFourZero(): bool + function getUpdateUri(): string { - return version_compare($this->productMetaData->getVersion(), '2.4.0', '<'); + return $this->utils()->magewire()->getUpdateUri(); } /** - * @return string + * @deprecated To make backwards compatible, utils()->magewire() method should be used instead. + * @see ViewUtils\Magewire */ - public function getPostRoute(): string + function getScriptPath(): string { - return $this->isBeforeTwoFourZero() ? '/magewire/vintage' : '/magewire/post'; + return $this->utils() + ->magewire() + ->mechanisms() + ->frontendAssets() + ->getScriptPath(); } } diff --git a/src/etc/acl.xml b/src/etc/acl.xml new file mode 100644 index 00000000..9c07ceef --- /dev/null +++ b/src/etc/acl.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/etc/adminhtml/di.xml b/src/etc/adminhtml/di.xml new file mode 100644 index 00000000..f7cf8d43 --- /dev/null +++ b/src/etc/adminhtml/di.xml @@ -0,0 +1,553 @@ + + + + + + + + + + Magewirephp\Magewire\Mechanisms\ResolveComponents\ResolveComponents + 1000 + Magewirephp\Magewire\Mechanisms\ResolveComponents\ResolveComponentsViewModel + + + + Magewirephp\Magewire\Mechanisms\PersistentMiddleware\PersistentMiddleware + 1050 + + + + Magewirephp\Magewire\Mechanisms\HandleComponents\HandleComponents + Magewirephp\Magewire\Mechanisms\HandleComponents\HandleComponentsFacade + 1100 + + + + Magewirephp\Magewire\Mechanisms\HandleRequests\HandleRequests + Magewirephp\Magewire\Mechanisms\HandleRequests\HandleRequestFacade + 1200 + + + + Magewirephp\Magewire\Mechanisms\FrontendAssets\FrontendAssets + Magewirephp\Magewire\Mechanisms\FrontendAssets\FrontendAssetsFacade + Magewirephp\Magewire\Mechanisms\FrontendAssets\FrontendAssetsViewModel + 1400 + + + + + Magewirephp_Magewire::js/magewire.min.js + + + + + df3a17f2 + + + + + true + + + + + + + Magewirephp\Magewire\Mechanisms\DataStore + 1250 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Magewirephp\Magewire\Features\SupportMagewireViewModel\SupportMagewireViewModel + 700 + 30 + + + + Magewirephp\Magewire\Features\SupportMagewireExceptionHandling\SupportMagewireExceptionHandling + 800 + + + + Magewirephp\Magewire\Features\SupportMagewireRateLimiting\SupportMagewireRateLimiting + 900 + + + + Magewirephp\Magewire\Features\SupportNestingComponents\SupportNestingComponents + 1000 + + + + Magewirephp\Magewire\Features\SupportMagewireNestingComponents\SupportMagewireNestingComponents + 1100 + + + + Magewirephp\Magewire\Features\SupportAttributes\SupportAttributes + 1200 + + + + Magewirephp\Magewire\Features\SupportRedirects\SupportRedirects + 1300 + + + + Magewirephp\Magewire\Features\SupportStreaming\SupportStreaming + 1400 + + + + Magewirephp\Magewire\Features\SupportLocales\SupportLocales + 1500 + + + + Magewirephp\Magewire\Features\SupportEvents\SupportEvents + 1600 + + + + Magewirephp\Magewire\Features\SupportMagentoLayouts\SupportMagentoLayouts + 5000 + + + + + Magewirephp\Magewire\Features\SupportMagentoFlashMessages\SupportMagentoFlashMessages + 5100 + + + + Magewirephp\Magewire\Features\SupportMagewireLoaders\SupportMagewireLoaders + 5200 + + + + Magewirephp\Magewire\Features\SupportMagewireNotifications\SupportMagewireNotifications + Magewirephp\Magewire\Features\SupportMagewireNotifications\MagewireNotificationsFacade + 5200 + + + + + Magewirephp\Magewire\Features\SupportLifecycleHooks\SupportLifecycleHooks + 99000 + + + + Magewirephp\Magewire\Features\SupportMagewireCompiling\SupportMagewireCompiling + 99300 + + + + Magewirephp\Magewire\Features\SupportMagentoObserverEvents\SupportMagentoObserverEvents + 99400 + + + + + + + + + + + + Magewirephp\Magewire\Features\SupportMagewireRateLimiting\RateLimiterExceptionHandler + + + + + + + + + + + Magewirephp\Magewire\Features\SupportMagewireCompiling\View\Compiler\MagentoTemplateCompiler + + + + + + + + + + Magewirephp\Magewire\Features\SupportMagewireCompiling\View\Directive\Json + + + Magewirephp\Magewire\Features\SupportMagewireCompiling\View\Directive\Scope + Magewirephp\Magewire\Features\SupportMagewireCompiling\View\Directive\Scope + + + Magewirephp\Magewire\Features\SupportMagewireCompiling\View\Directive\Script + Magewirephp\Magewire\Features\SupportMagewireCompiling\View\Directive\Fragment + Magewirephp\Magewire\Features\SupportMagewireCompiling\View\Directive\Base\Proxy + Magewirephp\Magewire\Features\SupportMagewireCompiling\View\Directive\Template\Proxy + + + + + + Magewirephp\Magewire\Features\SupportMagewireCompiling\View\Directive\Base\Proxy + Magewirephp\Magewire\Features\SupportMagewireCompiling\View\Directive\Base\Proxy + + + Magewirephp\Magewire\Features\SupportMagewireCompiling\View\Directive\Magento\Auth\Proxy + Magewirephp\Magewire\Features\SupportMagewireCompiling\View\Directive\Magento\Auth\Proxy + + + + + + + + + + Magewirephp\Magewire\Features\SupportMagewireCompiling\View\Directive\Magento\Block\Proxy + Magewirephp\Magewire\Features\SupportMagewireCompiling\View\Directive\Magento\Block\Proxy + + + + + + + + + + Magewirephp\Magewire\Features\SupportMagewireCompiling\View\Directive\Magento\Escape\Proxy + Magewirephp\Magewire\Features\SupportMagewireCompiling\View\Directive\Magento\Escape\Proxy + Magewirephp\Magewire\Features\SupportMagewireCompiling\View\Directive\Magento\Escape\Proxy + Magewirephp\Magewire\Features\SupportMagewireCompiling\View\Directive\Magento\Escape\Proxy + Magewirephp\Magewire\Features\SupportMagewireCompiling\View\Directive\Magento\Escape\Proxy + + + + + + + + + + + + + + + + + + + + + + + + Magewirephp\Magewire\Features\SupportMagewireCompiling\View\BaseDirectiveArea + + + + + Magewirephp\Magewire\Features\SupportMagewireCompiling\View\MagentoBlockDirectiveArea + Magewirephp\Magewire\Features\SupportMagewireCompiling\View\MagentoEscapeDirectiveArea + Magewirephp\Magewire\Features\SupportMagewireCompiling\View\MagewireDirectiveArea + Magewirephp\Magewire\Features\SupportMagewireCompiling\View\AlpinejsDirectiveArea + Magewirephp\Magewire\Features\SupportMagewireCompiling\View\TailwindcssDirectiveArea + + + + + + + + + + Magewirephp\Magewire\Features\SupportMagewireCompiling\View\Action\Magewire\Proxy + + + + Magewirephp\Magewire\Features\SupportMagewireCompiling\View\Action\Magento\Auth\Proxy + + + + + + + + + + Magewirephp\Magewire\Containers\Livewire + Magewirephp\Magewire\Containers\Redirect + + + + + + + + + Magewirephp\Magewire\Model\View\Fragment\Modifier\Developer + + + + Magewirephp\Magewire\Model\View\Fragment\Modifier\Csp + + + + + + + + + + + Magewirephp\Magewire\Mechanisms\ResolveComponents\ComponentResolver\LayoutResolver + + + + + + + + + + true + + + + + + + + + + dev/magewire/debug + + + + + + + + + Magewirephp\Magento\Framework\Reflection\TypeProcessor + + + + + + + + Magewirephp\Magewire\Virtual\Magento\Framework\Reflection\MethodsMap + + + + + + + + + + Magewirephp\Magewire\Mechanisms\HandleComponents\Synthesizers\DataObjectSynth + + + Magewirephp\Magewire\Mechanisms\HandleComponents\Synthesizers\ArraySynth + + + Magewirephp\Magewire\Mechanisms\HandleComponents\Synthesizers\EnumSynth + + + Magewirephp\Magewire\Mechanisms\HandleComponents\Synthesizers\FloatSynth + + + Magewirephp\Magewire\Mechanisms\HandleComponents\Synthesizers\IntSynth + + + Magewirephp\Magewire\Mechanisms\HandleComponents\Synthesizers\StdClassSynth + + + + + + + + + + + + + + + Magewirephp\Magewire\Features\SupportMagewireBackwardsCompatibility\Resolver\UpdatingUpdatedArgumentSwapResolver + + + + + diff --git a/src/etc/adminhtml/system.xml b/src/etc/adminhtml/system.xml new file mode 100644 index 00000000..56624dc8 --- /dev/null +++ b/src/etc/adminhtml/system.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/src/etc/adminhtml/system/magewire.xml b/src/etc/adminhtml/system/magewire.xml new file mode 100644 index 00000000..dc00d77e --- /dev/null +++ b/src/etc/adminhtml/system/magewire.xml @@ -0,0 +1,19 @@ + + +
+ + advanced + Magewirephp_Magewire::magewire + + + +
+
diff --git a/src/etc/adminhtml/system/magewire/debug.xml b/src/etc/adminhtml/system/magewire/debug.xml new file mode 100644 index 00000000..dbb0942c --- /dev/null +++ b/src/etc/adminhtml/system/magewire/debug.xml @@ -0,0 +1,25 @@ + + + + + + + + Magento\Config\Model\Config\Source\Yesno + + + diff --git a/src/etc/adminhtml/system/magewire/features.xml b/src/etc/adminhtml/system/magewire/features.xml new file mode 100644 index 00000000..c62b5fa3 --- /dev/null +++ b/src/etc/adminhtml/system/magewire/features.xml @@ -0,0 +1,16 @@ + + + + + + + + diff --git a/src/etc/adminhtml/system/magewire/features/support-rate-limiting/support-rate-limiting.xml b/src/etc/adminhtml/system/magewire/features/support-rate-limiting/support-rate-limiting.xml new file mode 100644 index 00000000..82cb498d --- /dev/null +++ b/src/etc/adminhtml/system/magewire/features/support-rate-limiting/support-rate-limiting.xml @@ -0,0 +1,79 @@ + + + + + Rate limiting can be applied globally to all Magewire requests, and/or individually per component. Global settings take precedence over component-specific limits when both are configured. + + + + Magewirephp\Magewire\Features\SupportMagewireRateLimiting\Config\Source\RateLimitingVariant + + + + + Note: These settings only take effect when "requests" rate limiting is enabled. + + + + Determine whether rate limiting applies across all components (shared) or individually per component (isolated). + + Magewirephp\Magewire\Features\SupportMagewireRateLimiting\Config\Source\RequestsScope + + + + + Maximum number of requests allowed globally within the decay seconds period configured below. + + required-entry integer + + + + + Time window in seconds for the max attempts limit above. After this period, the attempt counter resets. + + required-entry integer + + + + diff --git a/src/etc/cache.xml b/src/etc/cache.xml new file mode 100644 index 00000000..2903554f --- /dev/null +++ b/src/etc/cache.xml @@ -0,0 +1,9 @@ + + + + + Magewire cache + + diff --git a/src/etc/config.xml b/src/etc/config.xml new file mode 100644 index 00000000..c1592170 --- /dev/null +++ b/src/etc/config.xml @@ -0,0 +1,20 @@ + + + + + + + 0 + + + 0 + 4 + 5 + + + + + + diff --git a/src/etc/di.xml b/src/etc/di.xml index 2d40a4df..9fbfccf2 100644 --- a/src/etc/di.xml +++ b/src/etc/di.xml @@ -2,8 +2,54 @@ - - + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/etc/events.xml b/src/etc/events.xml new file mode 100644 index 00000000..6e32d42e --- /dev/null +++ b/src/etc/events.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + diff --git a/src/etc/frontend/di.xml b/src/etc/frontend/di.xml index 4eb521be..4567c3a3 100644 --- a/src/etc/frontend/di.xml +++ b/src/etc/frontend/di.xml @@ -2,45 +2,592 @@ - + + + + + + + + + + + + + + Magewirephp\Magewire\Mechanisms\ResolveComponents\ResolveComponents + 1000 + Magewirephp\Magewire\Mechanisms\ResolveComponents\ResolveComponentsViewModel + + + + Magewirephp\Magewire\Mechanisms\PersistentMiddleware\PersistentMiddleware + 1050 + + + + Magewirephp\Magewire\Mechanisms\HandleComponents\HandleComponents + Magewirephp\Magewire\Mechanisms\HandleComponents\HandleComponentsFacade + 1100 + + + + Magewirephp\Magewire\Mechanisms\HandleRequests\HandleRequests + Magewirephp\Magewire\Mechanisms\HandleRequests\HandleRequestFacade + 1200 + + + + Magewirephp\Magewire\Mechanisms\FrontendAssets\FrontendAssets + Magewirephp\Magewire\Mechanisms\FrontendAssets\FrontendAssetsFacade + Magewirephp\Magewire\Mechanisms\FrontendAssets\FrontendAssetsViewModel + 1400 + 30 + + + + + Magewirephp_Magewire::js/magewire.csp.min.js + + + + + 69cd902e + + + + + true + + + + + + + Magewirephp\Magewire\Mechanisms\DataStore + 1250 + + + + + + + + + + + + + Magewirephp\Magewire\Features\SupportMagewireViewModel\SupportMagewireViewModel + 700 + 30 + + + + Magewirephp\Magewire\Features\SupportMagewireExceptionHandling\SupportMagewireExceptionHandling + 800 + + + + Magewirephp\Magewire\Features\SupportMagewireRateLimiting\SupportMagewireRateLimiting + 900 + + + + Magewirephp\Magewire\Features\SupportNestingComponents\SupportNestingComponents + 1000 + + + + Magewirephp\Magewire\Features\SupportMagewireNestingComponents\SupportMagewireNestingComponents + 1100 + + + + Magewirephp\Magewire\Features\SupportAttributes\SupportAttributes + 1200 + + + + Magewirephp\Magewire\Features\SupportRedirects\SupportRedirects + 1300 + + + + Magewirephp\Magewire\Features\SupportStreaming\SupportStreaming + 1400 + + + + Magewirephp\Magewire\Features\SupportLocales\SupportLocales + 1500 + + + + Magewirephp\Magewire\Features\SupportEvents\SupportEvents + 1600 + + + + Magewirephp\Magewire\Features\SupportMagentoLayouts\SupportMagentoLayouts + 5000 + + + + + Magewirephp\Magewire\Features\SupportMagentoFlashMessages\SupportMagentoFlashMessages + 5100 + + + + Magewirephp\Magewire\Features\SupportMagewireLoaders\SupportMagewireLoaders + 5200 + + + + Magewirephp\Magewire\Features\SupportMagewireNotifications\SupportMagewireNotifications + Magewirephp\Magewire\Features\SupportMagewireNotifications\MagewireNotificationsFacade + 5200 + + + + + Magewirephp\Magewire\Features\SupportLifecycleHooks\SupportLifecycleHooks + 99000 + + + + Magewirephp\Magewire\Features\SupportMagewireBackwardsCompatibility\SupportMagewireBackwardsCompatibility + 99100 + + + + Magewirephp\Magewire\Features\SupportMagewireFlakes\SupportMagewireFlakes + 99200 + + + + Magewirephp\Magewire\Features\SupportMagewireCompiling\SupportMagewireCompiling + 99300 + + + + Magewirephp\Magewire\Features\SupportMagentoObserverEvents\SupportMagentoObserverEvents + 99400 + + + + + + + + + + + + Magewirephp\Magewire\Features\SupportMagewireRateLimiting\RateLimiterExceptionHandler + + + + + + + + + + Magewirephp\Magewire\Features\SupportMagewireCompiling\View\Compiler\MagentoTemplateCompiler + + + + + + + + + Magewirephp\Magewire\Features\SupportMagewireCompiling\View\Directive\Json + + + Magewirephp\Magewire\Features\SupportMagewireCompiling\View\Directive\Scope + Magewirephp\Magewire\Features\SupportMagewireCompiling\View\Directive\Scope + + + Magewirephp\Magewire\Features\SupportMagewireCompiling\View\Directive\Script + Magewirephp\Magewire\Features\SupportMagewireCompiling\View\Directive\Fragment + Magewirephp\Magewire\Features\SupportMagewireCompiling\View\Directive\Base\Proxy + Magewirephp\Magewire\Features\SupportMagewireCompiling\View\Directive\Template\Proxy + + + + + + Magewirephp\Magewire\Features\SupportMagewireCompiling\View\Directive\Base\Proxy + Magewirephp\Magewire\Features\SupportMagewireCompiling\View\Directive\Base\Proxy + + + Magewirephp\Magewire\Features\SupportMagewireCompiling\View\Directive\Magento\Auth\Proxy + Magewirephp\Magewire\Features\SupportMagewireCompiling\View\Directive\Magento\Auth\Proxy + + + + + + + + + + Magewirephp\Magewire\Features\SupportMagewireCompiling\View\Directive\Magento\Block\Proxy + Magewirephp\Magewire\Features\SupportMagewireCompiling\View\Directive\Magento\Block\Proxy + + + + + + + + + + Magewirephp\Magewire\Features\SupportMagewireCompiling\View\Directive\Magento\Escape\Proxy + Magewirephp\Magewire\Features\SupportMagewireCompiling\View\Directive\Magento\Escape\Proxy + Magewirephp\Magewire\Features\SupportMagewireCompiling\View\Directive\Magento\Escape\Proxy + Magewirephp\Magewire\Features\SupportMagewireCompiling\View\Directive\Magento\Escape\Proxy + Magewirephp\Magewire\Features\SupportMagewireCompiling\View\Directive\Magento\Escape\Proxy + + + + + + + + + + + + + + + + + + + + Magewirephp\Magewire\Features\SupportMagewireCompiling\View\Directive\Magewire\Slot\Proxy + Magewirephp\Magewire\Features\SupportMagewireCompiling\View\Directive\Magewire\Component\Proxy + + + + + + + + + + + + + Magewirephp\Magewire\Features\SupportMagewireCompiling\View\BaseDirectiveArea + + + + Magewirephp\Magewire\Features\SupportMagewireCompiling\View\MagentoBlockDirectiveArea + Magewirephp\Magewire\Features\SupportMagewireCompiling\View\MagentoEscapeDirectiveArea + Magewirephp\Magewire\Features\SupportMagewireCompiling\View\MagentoCustomerDirectiveArea + Magewirephp\Magewire\Features\SupportMagewireCompiling\View\MagewireDirectiveArea + Magewirephp\Magewire\Features\SupportMagewireCompiling\View\AlpinejsDirectiveArea + Magewirephp\Magewire\Features\SupportMagewireCompiling\View\TailwindcssDirectiveArea + + + + + + + + + Magewirephp\Magewire\Features\SupportMagewireCompiling\View\Action\Magewire\Proxy + + + Magewirephp\Magewire\Features\SupportMagewireCompiling\View\Action\Magento\Auth\Proxy + Magewirephp\Magewire\Features\SupportMagewireCompiling\View\Action\Magewire\Proxy + Magewirephp\Magewire\Features\SupportMagewireFlakes\View\Action\Magewire\FlakeViewAction\Proxy + + + + + + + + + + Magewirephp\Magewire\Containers\Livewire + Magewirephp\Magewire\Containers\Redirect + + + + + + + + + Magewirephp\Magewire\Model\View\Fragment\Modifier\Developer + + + + Magewirephp\Magewire\Model\View\Fragment\Modifier\Csp + + + + + + + + + + Magewirephp\Magewire\Features\SupportMagewireFlakes\View\Fragment\FlakeFragment + + + + + + + + + + Magewirephp\Magewire\Model\View\Fragment\Element\Slot + + Magewirephp\Magewire\Features\SupportMagewireFlakes\View\Fragment\Element\Flake + + + + + + + + + + Magewirephp\Magewire\Features\SupportMagewireFlakes\Mechanisms\ResolveComponent\ComponentResolver\FlakeResolver + Magewirephp\Magewire\Mechanisms\ResolveComponents\ComponentResolver\LayoutResolver + + + + + - - Magewirephp\Magewire\Model\Action\CallMethod - Magewirephp\Magewire\Model\Action\FireEvent - Magewirephp\Magewire\Model\Action\SyncInput + + + true - + + - - + + + dev/magewire/debug + + + - - mount - hydrate - dehydrate - updating - updated + - getParent - setParent - getPublicProperties - getUncallables - __call + The MethodsMap in the Magento framework is a useful class for extracting information about a class's methods. + However, it relies on a TypeProcessor that reflects the method's docblock to determine type details, which can + break methods lacking a docblock or having incomplete information. Since the Magewire Wrapped class heavily + uses this functionality, a custom MethodsMap `virtual type` with a modified TypeProcessor resolves the issue + by returning an empty array when needed, ensuring compatibility. + --> + + + Magewirephp\Magento\Framework\Reflection\TypeProcessor + + - - setRequest - getRequest - setResponse - getResponse - getEventQueue + + + Magewirephp\Magewire\Virtual\Magento\Framework\Reflection\MethodsMap + + - - renderPagination + + + + + + + Magewirephp\Magewire\Mechanisms\HandleComponents\Synthesizers\DataObjectSynth + + + Magewirephp\Magewire\Mechanisms\HandleComponents\Synthesizers\ArraySynth + + + Magewirephp\Magewire\Mechanisms\HandleComponents\Synthesizers\EnumSynth + + + Magewirephp\Magewire\Mechanisms\HandleComponents\Synthesizers\FloatSynth + + + Magewirephp\Magewire\Mechanisms\HandleComponents\Synthesizers\IntSynth + + + Magewirephp\Magewire\Mechanisms\HandleComponents\Synthesizers\StdClassSynth + + + + + + + + + + + + + + + Magewirephp\Magewire\Features\SupportMagewireBackwardsCompatibility\Resolver\UpdatingUpdatedArgumentSwapResolver + + + + + + + + + + Magewirephp\Magewire\Controller\MagewireUpdateRouteFrontend + + + + + + + + + Magewirephp\Magewire\Controller\Router + false + 5 + diff --git a/src/etc/frontend/events.xml b/src/etc/frontend/events.xml index 99cf3aca..b8551b2f 100644 --- a/src/etc/frontend/events.xml +++ b/src/etc/frontend/events.xml @@ -2,17 +2,10 @@ - - - - - - - - + + + + diff --git a/src/etc/module.xml b/src/etc/module.xml index 792397e1..26720488 100644 --- a/src/etc/module.xml +++ b/src/etc/module.xml @@ -2,9 +2,5 @@ - - - - - + diff --git a/src/i18n/de_DE.csv b/src/i18n/de_DE.csv deleted file mode 100644 index db14e976..00000000 --- a/src/i18n/de_DE.csv +++ /dev/null @@ -1 +0,0 @@ -"Pager not available.","Paginierung nicht verfügbar." diff --git a/src/i18n/en_US.csv b/src/i18n/en_US.csv index 2a531fc2..3c1055e6 100644 --- a/src/i18n/en_US.csv +++ b/src/i18n/en_US.csv @@ -1 +1,2 @@ -"Pager not available.","Pager not available." +"An exception occurred while parsing and/or rendering the Magewire component.","An exception occurred while parsing and/or rendering the Magewire component." +"An exception occurred while parsing and/or rendering the Magewire component associated with a block named: %1s.","An exception occurred while parsing and/or rendering the Magewire component associated with a block named: %1s." diff --git a/src/i18n/fr_FR.csv b/src/i18n/fr_FR.csv deleted file mode 100644 index 0fe08bb3..00000000 --- a/src/i18n/fr_FR.csv +++ /dev/null @@ -1 +0,0 @@ -"Pager not available.","Pagination non disponible." diff --git a/src/i18n/nl_NL.csv b/src/i18n/nl_NL.csv deleted file mode 100644 index b631fb67..00000000 --- a/src/i18n/nl_NL.csv +++ /dev/null @@ -1 +0,0 @@ -"Pager not available.","Paginering niet beschikbaar." diff --git a/src/registration.php b/src/registration.php index b11e52fe..29925854 100644 --- a/src/registration.php +++ b/src/registration.php @@ -1,15 +1,29 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + false + + + + + + + + + + + + + + diff --git a/src/view/base/templates/js/alpinejs/components/magewire-notifier.phtml b/src/view/base/templates/js/alpinejs/components/magewire-notifier.phtml new file mode 100644 index 00000000..97f472d9 --- /dev/null +++ b/src/view/base/templates/js/alpinejs/components/magewire-notifier.phtml @@ -0,0 +1,140 @@ +getData('view_model'); +$magewireTemplate = $magewireViewModel->utils()->template(); +$magewireFragment = $magewireViewModel->utils()->fragment(); + +/** @internal Do not modify to ensure Magewire continues to function correctly. */ +?> +echoCodeComment('magewire notifier', false, 'Alpine JS') ?> +make()->script()->start() ?> + +end() ?> + +getChildBlock('ac') ?> diff --git a/src/view/base/templates/js/alpinejs/directives/.gitkeep b/src/view/base/templates/js/alpinejs/directives/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/view/base/templates/js/alpinejs/magewire.phtml b/src/view/base/templates/js/alpinejs/magewire.phtml new file mode 100644 index 00000000..22200233 --- /dev/null +++ b/src/view/base/templates/js/alpinejs/magewire.phtml @@ -0,0 +1,32 @@ +getData('view_model'); +$magewireFragment = $magewireViewModel->utils()->fragment(); + +/** + * Alpine JS - Global + * + * @internal Do not modify to ensure Magewire continues to function correctly. + */ +?> +make()->script()->start() ?> + +end() ?> diff --git a/src/view/base/templates/js/magewire/addons.phtml b/src/view/base/templates/js/magewire/addons.phtml new file mode 100644 index 00000000..2c441f41 --- /dev/null +++ b/src/view/base/templates/js/magewire/addons.phtml @@ -0,0 +1,11 @@ + +getChildHtml() ?> diff --git a/src/view/base/templates/js/magewire/addons/notifier.phtml b/src/view/base/templates/js/magewire/addons/notifier.phtml new file mode 100644 index 00000000..61d85703 --- /dev/null +++ b/src/view/base/templates/js/magewire/addons/notifier.phtml @@ -0,0 +1,225 @@ +getData('view_model'); +$magewireFragment = $magewireViewModel->utils()->fragment(); + +/** @internal Do not modify to ensure Magewire continues to function correctly. */ +?> +make()->script()->start() ?> + +end() ?> diff --git a/src/view/base/templates/js/magewire/directives.phtml b/src/view/base/templates/js/magewire/directives.phtml new file mode 100644 index 00000000..2c441f41 --- /dev/null +++ b/src/view/base/templates/js/magewire/directives.phtml @@ -0,0 +1,11 @@ + +getChildHtml() ?> diff --git a/src/view/base/templates/js/magewire/directives/mage-notify.phtml b/src/view/base/templates/js/magewire/directives/mage-notify.phtml new file mode 100644 index 00000000..100a1783 --- /dev/null +++ b/src/view/base/templates/js/magewire/directives/mage-notify.phtml @@ -0,0 +1,115 @@ +getData('view_model'); +$magewireFragment = $magewireViewModel->utils()->fragment(); +?> +make()->script()->start() ?> + +end() ?> diff --git a/src/view/base/templates/js/magewire/directives/mage-throttle.phtml b/src/view/base/templates/js/magewire/directives/mage-throttle.phtml new file mode 100644 index 00000000..53099b37 --- /dev/null +++ b/src/view/base/templates/js/magewire/directives/mage-throttle.phtml @@ -0,0 +1,81 @@ +getData('view_model'); +$magewireFragment = $magewireViewModel->utils()->fragment(); +?> +make()->script()->start() ?> + +end() ?> diff --git a/src/view/base/templates/js/magewire/directives/select.phtml b/src/view/base/templates/js/magewire/directives/select.phtml new file mode 100644 index 00000000..af7d8358 --- /dev/null +++ b/src/view/base/templates/js/magewire/directives/select.phtml @@ -0,0 +1,86 @@ +getData('view_model'); +$magewireFragment = $magewireViewModel->utils()->fragment(); +?> +make()->script()->start() ?> + +end() ?> diff --git a/src/view/base/templates/js/magewire/features.phtml b/src/view/base/templates/js/magewire/features.phtml new file mode 100644 index 00000000..2c441f41 --- /dev/null +++ b/src/view/base/templates/js/magewire/features.phtml @@ -0,0 +1,11 @@ + +getChildHtml() ?> diff --git a/src/view/base/templates/js/magewire/global.phtml b/src/view/base/templates/js/magewire/global.phtml new file mode 100644 index 00000000..99a9899b --- /dev/null +++ b/src/view/base/templates/js/magewire/global.phtml @@ -0,0 +1,139 @@ +getData('view_model'); +$magewireFragment = $magewireViewModel->utils()->fragment(); +$templateUtil = $magewireViewModel->utils()->template(); + +/** @internal Do not modify to ensure Magewire continues to function correctly. */ +?> +getChildHtml('before') ?> + +make()->script()->start() ?> + +end() ?> + +echoCodeComment('utilities', false, 'container') ?> +getChildHtml('utilities') ?> +echoCodeComment('addons', false, 'container') ?> +getChildHtml('addons') ?> +getChildHtml('after') ?> diff --git a/src/view/base/templates/js/magewire/internal.phtml b/src/view/base/templates/js/magewire/internal.phtml new file mode 100644 index 00000000..7c21e0a0 --- /dev/null +++ b/src/view/base/templates/js/magewire/internal.phtml @@ -0,0 +1,31 @@ +getData('view_model'); +$magewireFragment = $magewireViewModel->utils()->fragment(); + +/** @internal Do not modify to ensure Magewire continues to function correctly. */ +?> +make()->script()->start() ?> + +end() ?> + +getChildHtml('internal.backwards-compatibility') ?> diff --git a/src/view/base/templates/js/magewire/internal/.gitkeep b/src/view/base/templates/js/magewire/internal/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/view/base/templates/js/magewire/state.phtml b/src/view/base/templates/js/magewire/state.phtml new file mode 100644 index 00000000..bbe29d93 --- /dev/null +++ b/src/view/base/templates/js/magewire/state.phtml @@ -0,0 +1,28 @@ +getData('view_model'); +$magewireFragment = $magewireViewModel->utils()->fragment(); + +$state = $block->getData('state') ? 'active' : 'inactive'; + +/** @internal Do not modify to ensure Magewire continues to function correctly. */ +?> +make()->script()->start() ?> + +end() ?> diff --git a/src/view/base/templates/js/magewire/utilities.phtml b/src/view/base/templates/js/magewire/utilities.phtml new file mode 100644 index 00000000..2c441f41 --- /dev/null +++ b/src/view/base/templates/js/magewire/utilities.phtml @@ -0,0 +1,11 @@ + +getChildHtml() ?> diff --git a/src/view/base/templates/js/magewire/utilities/cookie.phtml b/src/view/base/templates/js/magewire/utilities/cookie.phtml new file mode 100644 index 00000000..56df7e04 --- /dev/null +++ b/src/view/base/templates/js/magewire/utilities/cookie.phtml @@ -0,0 +1,72 @@ +getData('view_model'); +$magewireFragment = $magewireViewModel->utils()->fragment(); + +/** @internal Do not modify to ensure Magewire continues to function correctly. */ +?> +make()->script()->start() ?> + +end() ?> diff --git a/src/view/base/templates/js/magewire/utilities/dom.phtml b/src/view/base/templates/js/magewire/utilities/dom.phtml new file mode 100644 index 00000000..0e5f0958 --- /dev/null +++ b/src/view/base/templates/js/magewire/utilities/dom.phtml @@ -0,0 +1,57 @@ +getData('view_model'); +$magewireFragment = $magewireViewModel->utils()->fragment(); + +/** @internal Do not modify to ensure Magewire continues to function correctly. */ +?> +make()->script()->start() ?> + +end() ?> diff --git a/src/view/base/templates/js/magewire/utilities/loader.phtml b/src/view/base/templates/js/magewire/utilities/loader.phtml new file mode 100644 index 00000000..0c996187 --- /dev/null +++ b/src/view/base/templates/js/magewire/utilities/loader.phtml @@ -0,0 +1,84 @@ +getData('view_model'); +$magewireFragment = $magewireViewModel->utils()->fragment(); + +/** @internal Do not modify to ensure Magewire continues to function correctly. */ +?> +make()->script()->start() ?> + +end() ?> diff --git a/src/view/base/templates/js/magewire/utilities/str.phtml b/src/view/base/templates/js/magewire/utilities/str.phtml new file mode 100644 index 00000000..080c5574 --- /dev/null +++ b/src/view/base/templates/js/magewire/utilities/str.phtml @@ -0,0 +1,48 @@ +getData('view_model'); +$magewireFragment = $magewireViewModel->utils()->fragment(); + +/** @internal Do not modify to ensure Magewire continues to function correctly. */ +?> +make()->script()->start() ?> + +end() ?> diff --git a/src/view/base/templates/magewire-features/support-magewire-loaders/support-magewire-loaders.phtml b/src/view/base/templates/magewire-features/support-magewire-loaders/support-magewire-loaders.phtml new file mode 100644 index 00000000..8b9de18d --- /dev/null +++ b/src/view/base/templates/magewire-features/support-magewire-loaders/support-magewire-loaders.phtml @@ -0,0 +1,351 @@ +getData('view_model'); +$magewireFragment = $magewireViewModel->utils()->fragment(); + +/** @internal Do not modify to ensure Magewire continues to function correctly. */ +?> +make()->script()->start() ?> + +end() ?> diff --git a/src/view/base/templates/magewire-features/support-magewire-rate-limiting/support-magewire-rate-limiting.phtml b/src/view/base/templates/magewire-features/support-magewire-rate-limiting/support-magewire-rate-limiting.phtml new file mode 100644 index 00000000..28066406 --- /dev/null +++ b/src/view/base/templates/magewire-features/support-magewire-rate-limiting/support-magewire-rate-limiting.phtml @@ -0,0 +1,41 @@ +getData('view_model'); +$magewireFragment = $magewireViewModel->utils()->fragment(); + +/** @internal Do not modify to ensure Magewire continues to function correctly. */ +?> +make()->script()->start() ?> + +end() ?> diff --git a/src/view/base/templates/magewire/exception.phtml b/src/view/base/templates/magewire/exception.phtml new file mode 100644 index 00000000..d5b3075c --- /dev/null +++ b/src/view/base/templates/magewire/exception.phtml @@ -0,0 +1,44 @@ +getNameInLayout() ?? ''; + +$state = $block->getData('application_state'); +$exception = $block->getData('exception'); +?> +getMode() === ApplicationState::MODE_DEVELOPER): ?> +
+
+        Exception: escapeHtml($exception->getMessage()) ?>
+    
+ +

+ + escapeHtml(__('An exception occurred while parsing and/or rendering the Magewire component.')) ?> + + escapeHtml(__('An exception occurred while parsing and/or rendering the Magewire component associated with a block named: %1.', $id)) ?> + +

+
+ + + + + + + diff --git a/src/view/base/templates/magewire/flakes/.gitkeep b/src/view/base/templates/magewire/flakes/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/view/base/templates/magewire/ui-components/notifier.phtml b/src/view/base/templates/magewire/ui-components/notifier.phtml new file mode 100644 index 00000000..f4cce165 --- /dev/null +++ b/src/view/base/templates/magewire/ui-components/notifier.phtml @@ -0,0 +1,63 @@ +getChildBlock('notification.before'); +$notificationsAfterBlock = $block->getChildBlock('notification.after'); + +$magewireViewModel = $block->getData('view_model'); +$templateUtil = $magewireViewModel->utils()->template(); +$layoutUtil = $magewireViewModel->utils()->layout(); +?> +echoCodeComment('notifier', false, 'UI Component') ?> +
+ +
diff --git a/src/view/base/templates/magewire/ui-components/notifier/activity-state.phtml b/src/view/base/templates/magewire/ui-components/notifier/activity-state.phtml new file mode 100644 index 00000000..2e376e9d --- /dev/null +++ b/src/view/base/templates/magewire/ui-components/notifier/activity-state.phtml @@ -0,0 +1,19 @@ +getChildBlock('loader') ?> +
+ + +
+ toHtml() ?> +
+ +
diff --git a/src/view/base/templates/magewire/utils/icons/loading.phtml b/src/view/base/templates/magewire/utils/icons/loading.phtml new file mode 100644 index 00000000..e8ef0cb9 --- /dev/null +++ b/src/view/base/templates/magewire/utils/icons/loading.phtml @@ -0,0 +1,19 @@ + + + + + diff --git a/src/view/base/templates/root.phtml b/src/view/base/templates/root.phtml new file mode 100644 index 00000000..7e708d48 --- /dev/null +++ b/src/view/base/templates/root.phtml @@ -0,0 +1,49 @@ +getData('view_model'); + +$templateUtil = $magewireViewModel->utils()->template(); +$layoutUtil = $magewireViewModel->utils()->layout(); +$magewireUtil = $magewireViewModel->utils()->magewire(); + +$canRequireMagewire = $magewireUtil->canRequireMagewireJsLibrary(); +?> + +echoCodeComment('start', true) ?> +echoCodeComment('global', false, 'container') ?> +getChildHtml($block, 'global', ['require_magewire' => $canRequireMagewire]) ?> + +echoCodeComment('before', false, 'container') ?> +getChildHtml('before') ?> + + + echoCodeComment('internal before', false, 'container') ?> + getChildHtml('before.internal') ?> + echoCodeComment('script') ?> + getChildHtml('script') ?> + echoCodeComment('internal') ?> + getChildHtml('internal') ?> + echoCodeComment('directives', false, 'container') ?> + getChildHtml('directives') ?> + echoCodeComment('features', false, 'container') ?> + getChildHtml('features') ?> + echoCodeComment('internal after', false, 'container') ?> + getChildHtml('after.internal') ?> + + echoCodeComment('disabled', false, 'container') ?> + getChildHtml('disabled') ?> + + +echoCodeComment('after', false, 'container') ?> +getChildHtml('after') ?> +echoCodeComment('end', true) ?> diff --git a/src/view/base/web/css/magewire.css b/src/view/base/web/css/magewire.css new file mode 100644 index 00000000..aae43e2d --- /dev/null +++ b/src/view/base/web/css/magewire.css @@ -0,0 +1,1031 @@ +/*! tailwindcss v4.1.18 | MIT License | https://tailwindcss.com */ +@layer properties { + @supports (((-webkit-hyphens: none)) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color: rgb(from red r g b)))) { + *, :before, :after, ::backdrop { + --tw-rotate-x: initial; + --tw-rotate-y: initial; + --tw-rotate-z: initial; + --tw-skew-x: initial; + --tw-skew-y: initial; + --tw-border-style: solid; + --tw-font-weight: initial; + --tw-shadow: 0 0 #0000; + --tw-shadow-color: initial; + --tw-shadow-alpha: 100%; + --tw-inset-shadow: 0 0 #0000; + --tw-inset-shadow-color: initial; + --tw-inset-shadow-alpha: 100%; + --tw-ring-color: initial; + --tw-ring-shadow: 0 0 #0000; + --tw-inset-ring-color: initial; + --tw-inset-ring-shadow: 0 0 #0000; + --tw-ring-inset: initial; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-offset-shadow: 0 0 #0000; + --tw-blur: initial; + --tw-brightness: initial; + --tw-contrast: initial; + --tw-grayscale: initial; + --tw-hue-rotate: initial; + --tw-invert: initial; + --tw-opacity: initial; + --tw-saturate: initial; + --tw-sepia: initial; + --tw-drop-shadow: initial; + --tw-drop-shadow-color: initial; + --tw-drop-shadow-alpha: 100%; + --tw-drop-shadow-size: initial; + --tw-space-y-reverse: 0; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-translate-z: 0; + --tw-outline-style: solid; + --tw-duration: initial; + --tw-ease: initial; + --tw-leading: initial; + } + } +} + +@layer utilities { + @layer theme { + :root, :host { + --font-sans: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + --color-red-50: oklch(97.1% .013 17.38); + --color-red-600: oklch(57.7% .245 27.325); + --color-red-700: oklch(50.5% .213 27.518); + --color-yellow-50: oklch(98.7% .026 102.212); + --color-yellow-600: oklch(68.1% .162 75.834); + --color-yellow-700: oklch(55.4% .135 66.442); + --color-green-50: oklch(98.2% .018 155.826); + --color-green-600: oklch(62.7% .194 149.214); + --color-green-700: oklch(52.7% .154 150.069); + --color-blue-50: oklch(97% .014 254.604); + --color-blue-600: oklch(54.6% .245 262.881); + --color-blue-700: oklch(48.8% .243 264.376); + --color-gray-400: oklch(70.7% .022 261.325); + --color-gray-600: oklch(44.6% .03 256.802); + --color-gray-700: oklch(37.3% .034 259.733); + --color-black: #000; + --color-white: #fff; + --spacing: .25rem; + --container-xs: 20rem; + --text-xs: .75rem; + --text-xs--line-height: calc(1 / .75); + --text-sm: .875rem; + --text-sm--line-height: calc(1.25 / .875); + --text-lg: 1.125rem; + --text-lg--line-height: calc(1.75 / 1.125); + --font-weight-semibold: 600; + --font-weight-bold: 700; + --radius-md: .375rem; + --ease-out: cubic-bezier(0, 0, .2, 1); + --animate-spin: spin 1s linear infinite; + --default-transition-duration: .15s; + --default-transition-timing-function: cubic-bezier(.4, 0, .2, 1); + --default-font-family: var(--font-sans); + --default-mono-font-family: var(--font-mono); + } + } + + @layer base { + *, :after, :before, ::backdrop { + box-sizing: border-box; + border: 0 solid; + margin: 0; + padding: 0; + } + + ::file-selector-button { + box-sizing: border-box; + border: 0 solid; + margin: 0; + padding: 0; + } + + html, :host { + -webkit-text-size-adjust: 100%; + tab-size: 4; + line-height: 1.5; + font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"); + font-feature-settings: var(--default-font-feature-settings, normal); + font-variation-settings: var(--default-font-variation-settings, normal); + -webkit-tap-highlight-color: transparent; + } + + hr { + height: 0; + color: inherit; + border-top-width: 1px; + } + + abbr:where([title]) { + -webkit-text-decoration: underline dotted; + text-decoration: underline dotted; + } + + h1, h2, h3, h4, h5, h6 { + font-size: inherit; + font-weight: inherit; + } + + a { + color: inherit; + -webkit-text-decoration: inherit; + -webkit-text-decoration: inherit; + -webkit-text-decoration: inherit; + text-decoration: inherit; + } + + b, strong { + font-weight: bolder; + } + + code, kbd, samp, pre { + font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace); + font-feature-settings: var(--default-mono-font-feature-settings, normal); + font-variation-settings: var(--default-mono-font-variation-settings, normal); + font-size: 1em; + } + + small { + font-size: 80%; + } + + sub, sup { + vertical-align: baseline; + font-size: 75%; + line-height: 0; + position: relative; + } + + sub { + bottom: -.25em; + } + + sup { + top: -.5em; + } + + table { + text-indent: 0; + border-color: inherit; + border-collapse: collapse; + } + + :-moz-focusring { + outline: auto; + } + + progress { + vertical-align: baseline; + } + + summary { + display: list-item; + } + + ol, ul, menu { + list-style: none; + } + + img, svg, video, canvas, audio, iframe, embed, object { + vertical-align: middle; + display: block; + } + + img, video { + max-width: 100%; + height: auto; + } + + button, input, select, optgroup, textarea { + font: inherit; + font-feature-settings: inherit; + font-variation-settings: inherit; + letter-spacing: inherit; + color: inherit; + opacity: 1; + background-color: #0000; + border-radius: 0; + } + + ::file-selector-button { + font: inherit; + font-feature-settings: inherit; + font-variation-settings: inherit; + letter-spacing: inherit; + color: inherit; + opacity: 1; + background-color: #0000; + border-radius: 0; + } + + :where(select:is([multiple], [size])) optgroup { + font-weight: bolder; + } + + :where(select:is([multiple], [size])) optgroup option { + padding-inline-start: 20px; + } + + ::file-selector-button { + margin-inline-end: 4px; + } + + ::placeholder { + opacity: 1; + } + + @supports (not ((-webkit-appearance: -apple-pay-button))) or (contain-intrinsic-size: 1px) { + ::placeholder { + color: currentColor; + } + + @supports (color: color-mix(in lab, red, red)) { + ::placeholder { + color: color-mix(in oklab, currentcolor 50%, transparent); + } + } + } + + textarea { + resize: vertical; + } + + ::-webkit-search-decoration { + -webkit-appearance: none; + } + + ::-webkit-date-and-time-value { + min-height: 1lh; + text-align: inherit; + } + + ::-webkit-datetime-edit { + display: inline-flex; + } + + ::-webkit-datetime-edit-fields-wrapper { + padding: 0; + } + + ::-webkit-datetime-edit { + padding-block: 0; + } + + ::-webkit-datetime-edit-year-field { + padding-block: 0; + } + + ::-webkit-datetime-edit-month-field { + padding-block: 0; + } + + ::-webkit-datetime-edit-day-field { + padding-block: 0; + } + + ::-webkit-datetime-edit-hour-field { + padding-block: 0; + } + + ::-webkit-datetime-edit-minute-field { + padding-block: 0; + } + + ::-webkit-datetime-edit-second-field { + padding-block: 0; + } + + ::-webkit-datetime-edit-millisecond-field { + padding-block: 0; + } + + ::-webkit-datetime-edit-meridiem-field { + padding-block: 0; + } + + ::-webkit-calendar-picker-indicator { + line-height: 1; + } + + :-moz-ui-invalid { + box-shadow: none; + } + + button, input:where([type="button"], [type="reset"], [type="submit"]) { + appearance: button; + } + + ::file-selector-button { + appearance: button; + } + + ::-webkit-inner-spin-button { + height: auto; + } + + ::-webkit-outer-spin-button { + height: auto; + } + + [hidden]:where(:not([hidden="until-found"])) { + display: none !important; + } + } + + @layer components; + + @layer utilities { + .collapse { + visibility: collapse; + } + + .invisible { + visibility: hidden; + } + + .visible { + visibility: visible; + } + + .absolute { + position: absolute; + } + + .fixed { + position: fixed; + } + + .relative { + position: relative; + } + + .static { + position: static; + } + + .isolate { + isolation: isolate; + } + + .container { + width: 100%; + } + + @media (min-width: 40rem) { + .container { + max-width: 40rem; + } + } + + @media (min-width: 48rem) { + .container { + max-width: 48rem; + } + } + + @media (min-width: 64rem) { + .container { + max-width: 64rem; + } + } + + @media (min-width: 80rem) { + .container { + max-width: 80rem; + } + } + + @media (min-width: 96rem) { + .container { + max-width: 96rem; + } + } + + .mt-2 { + margin-top: calc(var(--spacing) * 2); + } + + .mt-8 { + margin-top: calc(var(--spacing) * 8); + } + + .mb-6 { + margin-bottom: calc(var(--spacing) * 6); + } + + .block { + display: block; + } + + .contents { + display: contents; + } + + .flex { + display: flex; + } + + .grid { + display: grid; + } + + .hidden { + display: none; + } + + .inline { + display: inline; + } + + .inline-block { + display: inline-block; + } + + .inline-flex { + display: inline-flex; + } + + .table { + display: table; + } + + .size-5 { + width: calc(var(--spacing) * 5); + height: calc(var(--spacing) * 5); + } + + .h-full { + height: 100%; + } + + .w-full { + width: 100%; + } + + .flex-shrink-0 { + flex-shrink: 0; + } + + .transform { + transform: var(--tw-rotate-x, ) var(--tw-rotate-y, ) var(--tw-rotate-z, ) var(--tw-skew-x, ) var(--tw-skew-y, ); + } + + .transform\! { + transform: var(--tw-rotate-x, ) var(--tw-rotate-y, ) var(--tw-rotate-z, ) var(--tw-skew-x, ) var(--tw-skew-y, ) !important; + } + + .animate-spin { + animation: var(--animate-spin); + } + + .resize { + resize: both; + } + + .grid-cols-1 { + grid-template-columns: repeat(1, minmax(0, 1fr)); + } + + .grid-cols-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .justify-center { + justify-content: center; + } + + .gap-x-8 { + column-gap: calc(var(--spacing) * 8); + } + + .gap-y-96 { + row-gap: calc(var(--spacing) * 96); + } + + .overflow-auto { + overflow: auto; + } + + .rounded { + border-radius: .25rem; + } + + .border { + border-style: var(--tw-border-style); + border-width: 1px; + } + + .border-gray-600 { + border-color: var(--color-gray-600); + } + + .p-4 { + padding: calc(var(--spacing) * 4); + } + + .px-6 { + padding-inline: calc(var(--spacing) * 6); + } + + .py-3 { + padding-block: calc(var(--spacing) * 3); + } + + .text-left { + text-align: left; + } + + .text-lg { + font-size: var(--text-lg); + line-height: var(--tw-leading, var(--text-lg--line-height)); + } + + .text-sm { + font-size: var(--text-sm); + line-height: var(--tw-leading, var(--text-sm--line-height)); + } + + .text-xs { + font-size: var(--text-xs); + line-height: var(--tw-leading, var(--text-xs--line-height)); + } + + .font-bold { + --tw-font-weight: var(--font-weight-bold); + font-weight: var(--font-weight-bold); + } + + .whitespace-pre-line { + white-space: pre-line; + } + + .text-gray-600 { + color: var(--color-gray-600); + } + + .text-red-600 { + color: var(--color-red-600); + } + + .capitalize { + text-transform: capitalize; + } + + .lowercase { + text-transform: lowercase; + } + + .uppercase { + text-transform: uppercase; + } + + .italic { + font-style: italic; + } + + .opacity-25 { + opacity: .25; + } + + .shadow { + --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, #0000001a), 0 1px 2px -1px var(--tw-shadow-color, #0000001a); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + + .shadow-md { + --tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, #0000001a), 0 2px 4px -2px var(--tw-shadow-color, #0000001a); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + + .blur { + --tw-blur: blur(8px); + filter: var(--tw-blur, ) var(--tw-brightness, ) var(--tw-contrast, ) var(--tw-grayscale, ) var(--tw-hue-rotate, ) var(--tw-invert, ) var(--tw-saturate, ) var(--tw-sepia, ) var(--tw-drop-shadow, ); + } + + .filter { + filter: var(--tw-blur, ) var(--tw-brightness, ) var(--tw-contrast, ) var(--tw-grayscale, ) var(--tw-hue-rotate, ) var(--tw-invert, ) var(--tw-saturate, ) var(--tw-sepia, ) var(--tw-drop-shadow, ); + } + + .filter\! { + filter: var(--tw-blur, ) var(--tw-brightness, ) var(--tw-contrast, ) var(--tw-grayscale, ) var(--tw-hue-rotate, ) var(--tw-invert, ) var(--tw-saturate, ) var(--tw-sepia, ) var(--tw-drop-shadow, ) !important; + } + + .transition { + transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, content-visibility, overlay, pointer-events; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + } + + .\[wire\:model\=\"\'\+i\+\'\"\] { + wire: model= "'+i+'"; + } + + .\[wire\:model\=\\\"\'\+expression\+\'\\\"\] { + wire: model= \""+expression+"\"; + } + + .\[wire\:model\] { + wire: model; + } + } +} + +.magewire-notifier { + right: calc(var(--spacing) * 3); + bottom: calc(var(--spacing) * 3); + left: calc(var(--spacing) * 3); + z-index: 50; + flex-direction: column; + display: flex; + position: fixed; +} + +:where(.magewire-notifier > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse))); +} + +@media (min-width: 40rem) { + .magewire-notifier { + right: auto; + bottom: calc(var(--spacing) * 4); + left: calc(var(--spacing) * 4); + width: 100%; + max-width: var(--container-xs); + } +} + +.magewire-notifier__notification { + --tw-translate-y: calc(var(--spacing) * 0); + translate: var(--tw-translate-x) var(--tw-translate-y); + border-radius: var(--radius-md); + border-left-style: var(--tw-border-style); + opacity: 1; + --tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, #0000001a), 0 4px 6px -4px var(--tw-shadow-color, #0000001a); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + outline-style: var(--tw-outline-style); + border-left-width: 6px; + outline-width: 1px; + outline-color: #0000000d; +} + +@supports (color: color-mix(in lab, red, red)) { + .magewire-notifier__notification { + outline-color: color-mix(in oklab, var(--color-black) 5%, transparent); + } +} + +.magewire-notifier__notification { + transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, content-visibility, overlay, pointer-events; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + --tw-duration: .3s; + --tw-ease: var(--ease-out); + transition-duration: .3s; + transition-timing-function: var(--ease-out); +} + +@media (min-width: 40rem) { + .magewire-notifier__notification { + --tw-translate-x: calc(var(--spacing) * 0); + translate: var(--tw-translate-x) var(--tw-translate-y); + } +} + +@starting-style { + .magewire-notifier__notification { + --tw-translate-y: calc(var(--spacing) * 2); + translate: var(--tw-translate-x) var(--tw-translate-y); + } +} + +@starting-style { + .magewire-notifier__notification { + opacity: 0; + } +} + +@media (min-width: 40rem) { + @starting-style { + .magewire-notifier__notification { + --tw-translate-x: calc(var(--spacing) * -2); + translate: var(--tw-translate-x) var(--tw-translate-y); + } + } + + @starting-style { + .magewire-notifier__notification { + --tw-translate-y: calc(var(--spacing) * 0); + translate: var(--tw-translate-x) var(--tw-translate-y); + } + } +} + +.magewire-notifier__notification { + background-color: var(--notifier-bg, var(--color-white)); + border-left-color: var(--notifier-border, var(--color-gray-400)); +} + +.magewire-notifier__notification-content { + align-items: flex-start; + gap: calc(var(--spacing) * 3); + padding: calc(var(--spacing) * 4); + display: flex; +} + +.magewire-notifier__notification-before { + flex-shrink: 0; + align-items: center; + display: flex; +} + +.magewire-notifier__notification-body { + min-width: calc(var(--spacing) * 0); + flex: 1; +} + +.magewire-notifier__notification-title { + font-size: var(--text-sm); + line-height: var(--tw-leading, var(--text-sm--line-height)); + --tw-leading: calc(var(--spacing) * 5); + line-height: calc(var(--spacing) * 5); + --tw-font-weight: var(--font-weight-semibold); + font-weight: var(--font-weight-semibold); + color: var(--notifier-title, var(--color-gray-700)); +} + +.magewire-notifier__notification-message { + margin-top: calc(var(--spacing) * .5); + font-size: var(--text-sm); + line-height: var(--tw-leading, var(--text-sm--line-height)); + color: var(--color-gray-600); +} + +.magewire-notifier__notification-after { + align-items: center; + gap: calc(var(--spacing) * 2); + flex-shrink: 0; + display: flex; +} + +.magewire-notifier__notification--success { + --notifier-bg: var(--color-green-50); + --notifier-border: var(--color-green-600); + --notifier-title: var(--color-green-700); +} + +.magewire-notifier__notification--error { + --notifier-bg: var(--color-red-50); + --notifier-border: var(--color-red-600); + --notifier-title: var(--color-red-700); +} + +.magewire-notifier__notification--warning { + --notifier-bg: var(--color-yellow-50); + --notifier-border: var(--color-yellow-600); + --notifier-title: var(--color-yellow-700); +} + +.magewire-notifier__notification--info { + --notifier-bg: var(--color-blue-50); + --notifier-border: var(--color-blue-600); + --notifier-title: var(--color-blue-700); +} + +@property --tw-rotate-x { + syntax: "*"; + inherits: false +} + +@property --tw-rotate-y { + syntax: "*"; + inherits: false +} + +@property --tw-rotate-z { + syntax: "*"; + inherits: false +} + +@property --tw-skew-x { + syntax: "*"; + inherits: false +} + +@property --tw-skew-y { + syntax: "*"; + inherits: false +} + +@property --tw-border-style { + syntax: "*"; + inherits: false; + initial-value: solid; +} + +@property --tw-font-weight { + syntax: "*"; + inherits: false +} + +@property --tw-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} + +@property --tw-shadow-color { + syntax: "*"; + inherits: false +} + +@property --tw-shadow-alpha { + syntax: ""; + inherits: false; + initial-value: 100%; +} + +@property --tw-inset-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} + +@property --tw-inset-shadow-color { + syntax: "*"; + inherits: false +} + +@property --tw-inset-shadow-alpha { + syntax: ""; + inherits: false; + initial-value: 100%; +} + +@property --tw-ring-color { + syntax: "*"; + inherits: false +} + +@property --tw-ring-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} + +@property --tw-inset-ring-color { + syntax: "*"; + inherits: false +} + +@property --tw-inset-ring-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} + +@property --tw-ring-inset { + syntax: "*"; + inherits: false +} + +@property --tw-ring-offset-width { + syntax: ""; + inherits: false; + initial-value: 0; +} + +@property --tw-ring-offset-color { + syntax: "*"; + inherits: false; + initial-value: #fff; +} + +@property --tw-ring-offset-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} + +@property --tw-blur { + syntax: "*"; + inherits: false +} + +@property --tw-brightness { + syntax: "*"; + inherits: false +} + +@property --tw-contrast { + syntax: "*"; + inherits: false +} + +@property --tw-grayscale { + syntax: "*"; + inherits: false +} + +@property --tw-hue-rotate { + syntax: "*"; + inherits: false +} + +@property --tw-invert { + syntax: "*"; + inherits: false +} + +@property --tw-opacity { + syntax: "*"; + inherits: false +} + +@property --tw-saturate { + syntax: "*"; + inherits: false +} + +@property --tw-sepia { + syntax: "*"; + inherits: false +} + +@property --tw-drop-shadow { + syntax: "*"; + inherits: false +} + +@property --tw-drop-shadow-color { + syntax: "*"; + inherits: false +} + +@property --tw-drop-shadow-alpha { + syntax: ""; + inherits: false; + initial-value: 100%; +} + +@property --tw-drop-shadow-size { + syntax: "*"; + inherits: false +} + +@property --tw-space-y-reverse { + syntax: "*"; + inherits: false; + initial-value: 0; +} + +@property --tw-translate-x { + syntax: "*"; + inherits: false; + initial-value: 0; +} + +@property --tw-translate-y { + syntax: "*"; + inherits: false; + initial-value: 0; +} + +@property --tw-translate-z { + syntax: "*"; + inherits: false; + initial-value: 0; +} + +@property --tw-outline-style { + syntax: "*"; + inherits: false; + initial-value: solid; +} + +@property --tw-duration { + syntax: "*"; + inherits: false +} + +@property --tw-ease { + syntax: "*"; + inherits: false +} + +@property --tw-leading { + syntax: "*"; + inherits: false +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} diff --git a/src/view/base/web/js/magewire.csp.esm.js b/src/view/base/web/js/magewire.csp.esm.js new file mode 100644 index 00000000..cd309988 --- /dev/null +++ b/src/view/base/web/js/magewire.csp.esm.js @@ -0,0 +1,11181 @@ +var __create = Object.create; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __getProtoOf = Object.getPrototypeOf; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __commonJS = (cb, mod) => function __require() { + return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports; +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, mod)); + +// ../alpine/packages/csp/dist/module.cjs.js +var require_module_cjs = __commonJS({ + "../alpine/packages/csp/dist/module.cjs.js"(exports, module) { + var __create2 = Object.create; + var __defProp2 = Object.defineProperty; + var __getOwnPropDesc2 = Object.getOwnPropertyDescriptor; + var __getOwnPropNames2 = Object.getOwnPropertyNames; + var __getProtoOf2 = Object.getPrototypeOf; + var __hasOwnProp2 = Object.prototype.hasOwnProperty; + var __commonJS2 = (cb, mod) => function __require() { + return mod || (0, cb[__getOwnPropNames2(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports; + }; + var __export = (target, all2) => { + for (var name in all2) + __defProp2(target, name, { get: all2[name], enumerable: true }); + }; + var __copyProps2 = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames2(from)) + if (!__hasOwnProp2.call(to, key) && key !== except) + __defProp2(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc2(from, key)) || desc.enumerable }); + } + return to; + }; + var __toESM2 = (mod, isNodeMode, target) => (target = mod != null ? __create2(__getProtoOf2(mod)) : {}, __copyProps2(isNodeMode || !mod || !mod.__esModule ? __defProp2(target, "default", { value: mod, enumerable: true }) : target, mod)); + var __toCommonJS = (mod) => __copyProps2(__defProp2({}, "__esModule", { value: true }), mod); + var require_shared_cjs = __commonJS2({ + "node_modules/@vue/shared/dist/shared.cjs.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + function makeMap(str, expectsLowerCase) { + const map = /* @__PURE__ */ Object.create(null); + const list = str.split(","); + for (let i = 0; i < list.length; i++) { + map[list[i]] = true; + } + return expectsLowerCase ? (val) => !!map[val.toLowerCase()] : (val) => !!map[val]; + } + var PatchFlagNames = { + [1]: `TEXT`, + [2]: `CLASS`, + [4]: `STYLE`, + [8]: `PROPS`, + [16]: `FULL_PROPS`, + [32]: `HYDRATE_EVENTS`, + [64]: `STABLE_FRAGMENT`, + [128]: `KEYED_FRAGMENT`, + [256]: `UNKEYED_FRAGMENT`, + [512]: `NEED_PATCH`, + [1024]: `DYNAMIC_SLOTS`, + [2048]: `DEV_ROOT_FRAGMENT`, + [-1]: `HOISTED`, + [-2]: `BAIL` + }; + var slotFlagsText = { + [1]: "STABLE", + [2]: "DYNAMIC", + [3]: "FORWARDED" + }; + var GLOBALS_WHITE_LISTED = "Infinity,undefined,NaN,isFinite,isNaN,parseFloat,parseInt,decodeURI,decodeURIComponent,encodeURI,encodeURIComponent,Math,Number,Date,Array,Object,Boolean,String,RegExp,Map,Set,JSON,Intl,BigInt"; + var isGloballyWhitelisted = /* @__PURE__ */ makeMap(GLOBALS_WHITE_LISTED); + var range = 2; + function generateCodeFrame(source, start22 = 0, end = source.length) { + let lines = source.split(/(\r?\n)/); + const newlineSequences = lines.filter((_, idx) => idx % 2 === 1); + lines = lines.filter((_, idx) => idx % 2 === 0); + let count = 0; + const res = []; + for (let i = 0; i < lines.length; i++) { + count += lines[i].length + (newlineSequences[i] && newlineSequences[i].length || 0); + if (count >= start22) { + for (let j = i - range; j <= i + range || end > count; j++) { + if (j < 0 || j >= lines.length) + continue; + const line = j + 1; + res.push(`${line}${" ".repeat(Math.max(3 - String(line).length, 0))}| ${lines[j]}`); + const lineLength = lines[j].length; + const newLineSeqLength = newlineSequences[j] && newlineSequences[j].length || 0; + if (j === i) { + const pad = start22 - (count - (lineLength + newLineSeqLength)); + const length = Math.max(1, end > count ? lineLength - pad : end - start22); + res.push(` | ` + " ".repeat(pad) + "^".repeat(length)); + } else if (j > i) { + if (end > count) { + const length = Math.max(Math.min(end - count, lineLength), 1); + res.push(` | ` + "^".repeat(length)); + } + count += lineLength + newLineSeqLength; + } + } + break; + } + } + return res.join("\n"); + } + var specialBooleanAttrs = `itemscope,allowfullscreen,formnovalidate,ismap,nomodule,novalidate,readonly`; + var isSpecialBooleanAttr = /* @__PURE__ */ makeMap(specialBooleanAttrs); + var isBooleanAttr2 = /* @__PURE__ */ makeMap(specialBooleanAttrs + `,async,autofocus,autoplay,controls,default,defer,disabled,hidden,loop,open,required,reversed,scoped,seamless,checked,muted,multiple,selected`); + var unsafeAttrCharRE = /[>/="'\u0009\u000a\u000c\u0020]/; + var attrValidationCache = {}; + function isSSRSafeAttrName(name) { + if (attrValidationCache.hasOwnProperty(name)) { + return attrValidationCache[name]; + } + const isUnsafe = unsafeAttrCharRE.test(name); + if (isUnsafe) { + console.error(`unsafe attribute name: ${name}`); + } + return attrValidationCache[name] = !isUnsafe; + } + var propsToAttrMap = { + acceptCharset: "accept-charset", + className: "class", + htmlFor: "for", + httpEquiv: "http-equiv" + }; + var isNoUnitNumericStyleProp = /* @__PURE__ */ makeMap(`animation-iteration-count,border-image-outset,border-image-slice,border-image-width,box-flex,box-flex-group,box-ordinal-group,column-count,columns,flex,flex-grow,flex-positive,flex-shrink,flex-negative,flex-order,grid-row,grid-row-end,grid-row-span,grid-row-start,grid-column,grid-column-end,grid-column-span,grid-column-start,font-weight,line-clamp,line-height,opacity,order,orphans,tab-size,widows,z-index,zoom,fill-opacity,flood-opacity,stop-opacity,stroke-dasharray,stroke-dashoffset,stroke-miterlimit,stroke-opacity,stroke-width`); + var isKnownAttr = /* @__PURE__ */ makeMap(`accept,accept-charset,accesskey,action,align,allow,alt,async,autocapitalize,autocomplete,autofocus,autoplay,background,bgcolor,border,buffered,capture,challenge,charset,checked,cite,class,code,codebase,color,cols,colspan,content,contenteditable,contextmenu,controls,coords,crossorigin,csp,data,datetime,decoding,default,defer,dir,dirname,disabled,download,draggable,dropzone,enctype,enterkeyhint,for,form,formaction,formenctype,formmethod,formnovalidate,formtarget,headers,height,hidden,high,href,hreflang,http-equiv,icon,id,importance,integrity,ismap,itemprop,keytype,kind,label,lang,language,loading,list,loop,low,manifest,max,maxlength,minlength,media,min,multiple,muted,name,novalidate,open,optimum,pattern,ping,placeholder,poster,preload,radiogroup,readonly,referrerpolicy,rel,required,reversed,rows,rowspan,sandbox,scope,scoped,selected,shape,size,sizes,slot,span,spellcheck,src,srcdoc,srclang,srcset,start,step,style,summary,tabindex,target,title,translate,type,usemap,value,width,wrap`); + function normalizeStyle(value) { + if (isArray2(value)) { + const res = {}; + for (let i = 0; i < value.length; i++) { + const item = value[i]; + const normalized = normalizeStyle(isString(item) ? parseStringStyle(item) : item); + if (normalized) { + for (const key in normalized) { + res[key] = normalized[key]; + } + } + } + return res; + } else if (isObject2(value)) { + return value; + } + } + var listDelimiterRE = /;(?![^(]*\))/g; + var propertyDelimiterRE = /:(.+)/; + function parseStringStyle(cssText) { + const ret = {}; + cssText.split(listDelimiterRE).forEach((item) => { + if (item) { + const tmp = item.split(propertyDelimiterRE); + tmp.length > 1 && (ret[tmp[0].trim()] = tmp[1].trim()); + } + }); + return ret; + } + function stringifyStyle(styles) { + let ret = ""; + if (!styles) { + return ret; + } + for (const key in styles) { + const value = styles[key]; + const normalizedKey = key.startsWith(`--`) ? key : hyphenate(key); + if (isString(value) || typeof value === "number" && isNoUnitNumericStyleProp(normalizedKey)) { + ret += `${normalizedKey}:${value};`; + } + } + return ret; + } + function normalizeClass(value) { + let res = ""; + if (isString(value)) { + res = value; + } else if (isArray2(value)) { + for (let i = 0; i < value.length; i++) { + const normalized = normalizeClass(value[i]); + if (normalized) { + res += normalized + " "; + } + } + } else if (isObject2(value)) { + for (const name in value) { + if (value[name]) { + res += name + " "; + } + } + } + return res.trim(); + } + var HTML_TAGS = "html,body,base,head,link,meta,style,title,address,article,aside,footer,header,h1,h2,h3,h4,h5,h6,hgroup,nav,section,div,dd,dl,dt,figcaption,figure,picture,hr,img,li,main,ol,p,pre,ul,a,b,abbr,bdi,bdo,br,cite,code,data,dfn,em,i,kbd,mark,q,rp,rt,rtc,ruby,s,samp,small,span,strong,sub,sup,time,u,var,wbr,area,audio,map,track,video,embed,object,param,source,canvas,script,noscript,del,ins,caption,col,colgroup,table,thead,tbody,td,th,tr,button,datalist,fieldset,form,input,label,legend,meter,optgroup,option,output,progress,select,textarea,details,dialog,menu,summary,template,blockquote,iframe,tfoot"; + var SVG_TAGS = "svg,animate,animateMotion,animateTransform,circle,clipPath,color-profile,defs,desc,discard,ellipse,feBlend,feColorMatrix,feComponentTransfer,feComposite,feConvolveMatrix,feDiffuseLighting,feDisplacementMap,feDistanceLight,feDropShadow,feFlood,feFuncA,feFuncB,feFuncG,feFuncR,feGaussianBlur,feImage,feMerge,feMergeNode,feMorphology,feOffset,fePointLight,feSpecularLighting,feSpotLight,feTile,feTurbulence,filter,foreignObject,g,hatch,hatchpath,image,line,linearGradient,marker,mask,mesh,meshgradient,meshpatch,meshrow,metadata,mpath,path,pattern,polygon,polyline,radialGradient,rect,set,solidcolor,stop,switch,symbol,text,textPath,title,tspan,unknown,use,view"; + var VOID_TAGS = "area,base,br,col,embed,hr,img,input,link,meta,param,source,track,wbr"; + var isHTMLTag = /* @__PURE__ */ makeMap(HTML_TAGS); + var isSVGTag = /* @__PURE__ */ makeMap(SVG_TAGS); + var isVoidTag = /* @__PURE__ */ makeMap(VOID_TAGS); + var escapeRE = /["'&<>]/; + function escapeHtml(string) { + const str = "" + string; + const match = escapeRE.exec(str); + if (!match) { + return str; + } + let html = ""; + let escaped; + let index; + let lastIndex = 0; + for (index = match.index; index < str.length; index++) { + switch (str.charCodeAt(index)) { + case 34: + escaped = """; + break; + case 38: + escaped = "&"; + break; + case 39: + escaped = "'"; + break; + case 60: + escaped = "<"; + break; + case 62: + escaped = ">"; + break; + default: + continue; + } + if (lastIndex !== index) { + html += str.substring(lastIndex, index); + } + lastIndex = index + 1; + html += escaped; + } + return lastIndex !== index ? html + str.substring(lastIndex, index) : html; + } + var commentStripRE = /^-?>||--!>| looseEqual(item, val)); + } + var toDisplayString = (val) => { + return val == null ? "" : isObject2(val) ? JSON.stringify(val, replacer, 2) : String(val); + }; + var replacer = (_key, val) => { + if (isMap(val)) { + return { + [`Map(${val.size})`]: [...val.entries()].reduce((entries, [key, val2]) => { + entries[`${key} =>`] = val2; + return entries; + }, {}) + }; + } else if (isSet(val)) { + return { + [`Set(${val.size})`]: [...val.values()] + }; + } else if (isObject2(val) && !isArray2(val) && !isPlainObject(val)) { + return String(val); + } + return val; + }; + var babelParserDefaultPlugins = [ + "bigInt", + "optionalChaining", + "nullishCoalescingOperator" + ]; + var EMPTY_OBJ = Object.freeze({}); + var EMPTY_ARR = Object.freeze([]); + var NOOP = () => { + }; + var NO = () => false; + var onRE = /^on[^a-z]/; + var isOn = (key) => onRE.test(key); + var isModelListener = (key) => key.startsWith("onUpdate:"); + var extend = Object.assign; + var remove = (arr, el) => { + const i = arr.indexOf(el); + if (i > -1) { + arr.splice(i, 1); + } + }; + var hasOwnProperty = Object.prototype.hasOwnProperty; + var hasOwn = (val, key) => hasOwnProperty.call(val, key); + var isArray2 = Array.isArray; + var isMap = (val) => toTypeString(val) === "[object Map]"; + var isSet = (val) => toTypeString(val) === "[object Set]"; + var isDate = (val) => val instanceof Date; + var isFunction2 = (val) => typeof val === "function"; + var isString = (val) => typeof val === "string"; + var isSymbol = (val) => typeof val === "symbol"; + var isObject2 = (val) => val !== null && typeof val === "object"; + var isPromise = (val) => { + return isObject2(val) && isFunction2(val.then) && isFunction2(val.catch); + }; + var objectToString = Object.prototype.toString; + var toTypeString = (value) => objectToString.call(value); + var toRawType = (value) => { + return toTypeString(value).slice(8, -1); + }; + var isPlainObject = (val) => toTypeString(val) === "[object Object]"; + var isIntegerKey = (key) => isString(key) && key !== "NaN" && key[0] !== "-" && "" + parseInt(key, 10) === key; + var isReservedProp = /* @__PURE__ */ makeMap(",key,ref,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted"); + var cacheStringFunction = (fn) => { + const cache = /* @__PURE__ */ Object.create(null); + return (str) => { + const hit = cache[str]; + return hit || (cache[str] = fn(str)); + }; + }; + var camelizeRE = /-(\w)/g; + var camelize = cacheStringFunction((str) => { + return str.replace(camelizeRE, (_, c) => c ? c.toUpperCase() : ""); + }); + var hyphenateRE = /\B([A-Z])/g; + var hyphenate = cacheStringFunction((str) => str.replace(hyphenateRE, "-$1").toLowerCase()); + var capitalize = cacheStringFunction((str) => str.charAt(0).toUpperCase() + str.slice(1)); + var toHandlerKey = cacheStringFunction((str) => str ? `on${capitalize(str)}` : ``); + var hasChanged = (value, oldValue) => value !== oldValue && (value === value || oldValue === oldValue); + var invokeArrayFns = (fns, arg) => { + for (let i = 0; i < fns.length; i++) { + fns[i](arg); + } + }; + var def = (obj, key, value) => { + Object.defineProperty(obj, key, { + configurable: true, + enumerable: false, + value + }); + }; + var toNumber = (val) => { + const n = parseFloat(val); + return isNaN(n) ? val : n; + }; + var _globalThis; + var getGlobalThis = () => { + return _globalThis || (_globalThis = typeof globalThis !== "undefined" ? globalThis : typeof self !== "undefined" ? self : typeof window !== "undefined" ? window : typeof global !== "undefined" ? global : {}); + }; + exports2.EMPTY_ARR = EMPTY_ARR; + exports2.EMPTY_OBJ = EMPTY_OBJ; + exports2.NO = NO; + exports2.NOOP = NOOP; + exports2.PatchFlagNames = PatchFlagNames; + exports2.babelParserDefaultPlugins = babelParserDefaultPlugins; + exports2.camelize = camelize; + exports2.capitalize = capitalize; + exports2.def = def; + exports2.escapeHtml = escapeHtml; + exports2.escapeHtmlComment = escapeHtmlComment; + exports2.extend = extend; + exports2.generateCodeFrame = generateCodeFrame; + exports2.getGlobalThis = getGlobalThis; + exports2.hasChanged = hasChanged; + exports2.hasOwn = hasOwn; + exports2.hyphenate = hyphenate; + exports2.invokeArrayFns = invokeArrayFns; + exports2.isArray = isArray2; + exports2.isBooleanAttr = isBooleanAttr2; + exports2.isDate = isDate; + exports2.isFunction = isFunction2; + exports2.isGloballyWhitelisted = isGloballyWhitelisted; + exports2.isHTMLTag = isHTMLTag; + exports2.isIntegerKey = isIntegerKey; + exports2.isKnownAttr = isKnownAttr; + exports2.isMap = isMap; + exports2.isModelListener = isModelListener; + exports2.isNoUnitNumericStyleProp = isNoUnitNumericStyleProp; + exports2.isObject = isObject2; + exports2.isOn = isOn; + exports2.isPlainObject = isPlainObject; + exports2.isPromise = isPromise; + exports2.isReservedProp = isReservedProp; + exports2.isSSRSafeAttrName = isSSRSafeAttrName; + exports2.isSVGTag = isSVGTag; + exports2.isSet = isSet; + exports2.isSpecialBooleanAttr = isSpecialBooleanAttr; + exports2.isString = isString; + exports2.isSymbol = isSymbol; + exports2.isVoidTag = isVoidTag; + exports2.looseEqual = looseEqual; + exports2.looseIndexOf = looseIndexOf; + exports2.makeMap = makeMap; + exports2.normalizeClass = normalizeClass; + exports2.normalizeStyle = normalizeStyle; + exports2.objectToString = objectToString; + exports2.parseStringStyle = parseStringStyle; + exports2.propsToAttrMap = propsToAttrMap; + exports2.remove = remove; + exports2.slotFlagsText = slotFlagsText; + exports2.stringifyStyle = stringifyStyle; + exports2.toDisplayString = toDisplayString; + exports2.toHandlerKey = toHandlerKey; + exports2.toNumber = toNumber; + exports2.toRawType = toRawType; + exports2.toTypeString = toTypeString; + } + }); + var require_shared = __commonJS2({ + "node_modules/@vue/shared/index.js"(exports2, module2) { + "use strict"; + if (false) { + module2.exports = null; + } else { + module2.exports = require_shared_cjs(); + } + } + }); + var require_reactivity_cjs = __commonJS2({ + "node_modules/@vue/reactivity/dist/reactivity.cjs.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + var shared = require_shared(); + var targetMap = /* @__PURE__ */ new WeakMap(); + var effectStack = []; + var activeEffect; + var ITERATE_KEY = Symbol("iterate"); + var MAP_KEY_ITERATE_KEY = Symbol("Map key iterate"); + function isEffect(fn) { + return fn && fn._isEffect === true; + } + function effect3(fn, options = shared.EMPTY_OBJ) { + if (isEffect(fn)) { + fn = fn.raw; + } + const effect4 = createReactiveEffect(fn, options); + if (!options.lazy) { + effect4(); + } + return effect4; + } + function stop2(effect4) { + if (effect4.active) { + cleanup(effect4); + if (effect4.options.onStop) { + effect4.options.onStop(); + } + effect4.active = false; + } + } + var uid = 0; + function createReactiveEffect(fn, options) { + const effect4 = function reactiveEffect() { + if (!effect4.active) { + return fn(); + } + if (!effectStack.includes(effect4)) { + cleanup(effect4); + try { + enableTracking(); + effectStack.push(effect4); + activeEffect = effect4; + return fn(); + } finally { + effectStack.pop(); + resetTracking(); + activeEffect = effectStack[effectStack.length - 1]; + } + } + }; + effect4.id = uid++; + effect4.allowRecurse = !!options.allowRecurse; + effect4._isEffect = true; + effect4.active = true; + effect4.raw = fn; + effect4.deps = []; + effect4.options = options; + return effect4; + } + function cleanup(effect4) { + const { deps } = effect4; + if (deps.length) { + for (let i = 0; i < deps.length; i++) { + deps[i].delete(effect4); + } + deps.length = 0; + } + } + var shouldTrack = true; + var trackStack = []; + function pauseTracking() { + trackStack.push(shouldTrack); + shouldTrack = false; + } + function enableTracking() { + trackStack.push(shouldTrack); + shouldTrack = true; + } + function resetTracking() { + const last = trackStack.pop(); + shouldTrack = last === void 0 ? true : last; + } + function track2(target, type, key) { + if (!shouldTrack || activeEffect === void 0) { + return; + } + let depsMap = targetMap.get(target); + if (!depsMap) { + targetMap.set(target, depsMap = /* @__PURE__ */ new Map()); + } + let dep = depsMap.get(key); + if (!dep) { + depsMap.set(key, dep = /* @__PURE__ */ new Set()); + } + if (!dep.has(activeEffect)) { + dep.add(activeEffect); + activeEffect.deps.push(dep); + if (activeEffect.options.onTrack) { + activeEffect.options.onTrack({ + effect: activeEffect, + target, + type, + key + }); + } + } + } + function trigger2(target, type, key, newValue, oldValue, oldTarget) { + const depsMap = targetMap.get(target); + if (!depsMap) { + return; + } + const effects = /* @__PURE__ */ new Set(); + const add2 = (effectsToAdd) => { + if (effectsToAdd) { + effectsToAdd.forEach((effect4) => { + if (effect4 !== activeEffect || effect4.allowRecurse) { + effects.add(effect4); + } + }); + } + }; + if (type === "clear") { + depsMap.forEach(add2); + } else if (key === "length" && shared.isArray(target)) { + depsMap.forEach((dep, key2) => { + if (key2 === "length" || key2 >= newValue) { + add2(dep); + } + }); + } else { + if (key !== void 0) { + add2(depsMap.get(key)); + } + switch (type) { + case "add": + if (!shared.isArray(target)) { + add2(depsMap.get(ITERATE_KEY)); + if (shared.isMap(target)) { + add2(depsMap.get(MAP_KEY_ITERATE_KEY)); + } + } else if (shared.isIntegerKey(key)) { + add2(depsMap.get("length")); + } + break; + case "delete": + if (!shared.isArray(target)) { + add2(depsMap.get(ITERATE_KEY)); + if (shared.isMap(target)) { + add2(depsMap.get(MAP_KEY_ITERATE_KEY)); + } + } + break; + case "set": + if (shared.isMap(target)) { + add2(depsMap.get(ITERATE_KEY)); + } + break; + } + } + const run = (effect4) => { + if (effect4.options.onTrigger) { + effect4.options.onTrigger({ + effect: effect4, + target, + key, + type, + newValue, + oldValue, + oldTarget + }); + } + if (effect4.options.scheduler) { + effect4.options.scheduler(effect4); + } else { + effect4(); + } + }; + effects.forEach(run); + } + var isNonTrackableKeys = /* @__PURE__ */ shared.makeMap(`__proto__,__v_isRef,__isVue`); + var builtInSymbols = new Set(Object.getOwnPropertyNames(Symbol).map((key) => Symbol[key]).filter(shared.isSymbol)); + var get2 = /* @__PURE__ */ createGetter(); + var shallowGet = /* @__PURE__ */ createGetter(false, true); + var readonlyGet = /* @__PURE__ */ createGetter(true); + var shallowReadonlyGet = /* @__PURE__ */ createGetter(true, true); + var arrayInstrumentations = /* @__PURE__ */ createArrayInstrumentations(); + function createArrayInstrumentations() { + const instrumentations = {}; + ["includes", "indexOf", "lastIndexOf"].forEach((key) => { + instrumentations[key] = function(...args) { + const arr = toRaw2(this); + for (let i = 0, l = this.length; i < l; i++) { + track2(arr, "get", i + ""); + } + const res = arr[key](...args); + if (res === -1 || res === false) { + return arr[key](...args.map(toRaw2)); + } else { + return res; + } + }; + }); + ["push", "pop", "shift", "unshift", "splice"].forEach((key) => { + instrumentations[key] = function(...args) { + pauseTracking(); + const res = toRaw2(this)[key].apply(this, args); + resetTracking(); + return res; + }; + }); + return instrumentations; + } + function createGetter(isReadonly2 = false, shallow = false) { + return function get3(target, key, receiver) { + if (key === "__v_isReactive") { + return !isReadonly2; + } else if (key === "__v_isReadonly") { + return isReadonly2; + } else if (key === "__v_raw" && receiver === (isReadonly2 ? shallow ? shallowReadonlyMap : readonlyMap : shallow ? shallowReactiveMap : reactiveMap).get(target)) { + return target; + } + const targetIsArray = shared.isArray(target); + if (!isReadonly2 && targetIsArray && shared.hasOwn(arrayInstrumentations, key)) { + return Reflect.get(arrayInstrumentations, key, receiver); + } + const res = Reflect.get(target, key, receiver); + if (shared.isSymbol(key) ? builtInSymbols.has(key) : isNonTrackableKeys(key)) { + return res; + } + if (!isReadonly2) { + track2(target, "get", key); + } + if (shallow) { + return res; + } + if (isRef(res)) { + const shouldUnwrap = !targetIsArray || !shared.isIntegerKey(key); + return shouldUnwrap ? res.value : res; + } + if (shared.isObject(res)) { + return isReadonly2 ? readonly(res) : reactive3(res); + } + return res; + }; + } + var set2 = /* @__PURE__ */ createSetter(); + var shallowSet = /* @__PURE__ */ createSetter(true); + function createSetter(shallow = false) { + return function set3(target, key, value, receiver) { + let oldValue = target[key]; + if (!shallow) { + value = toRaw2(value); + oldValue = toRaw2(oldValue); + if (!shared.isArray(target) && isRef(oldValue) && !isRef(value)) { + oldValue.value = value; + return true; + } + } + const hadKey = shared.isArray(target) && shared.isIntegerKey(key) ? Number(key) < target.length : shared.hasOwn(target, key); + const result = Reflect.set(target, key, value, receiver); + if (target === toRaw2(receiver)) { + if (!hadKey) { + trigger2(target, "add", key, value); + } else if (shared.hasChanged(value, oldValue)) { + trigger2(target, "set", key, value, oldValue); + } + } + return result; + }; + } + function deleteProperty(target, key) { + const hadKey = shared.hasOwn(target, key); + const oldValue = target[key]; + const result = Reflect.deleteProperty(target, key); + if (result && hadKey) { + trigger2(target, "delete", key, void 0, oldValue); + } + return result; + } + function has(target, key) { + const result = Reflect.has(target, key); + if (!shared.isSymbol(key) || !builtInSymbols.has(key)) { + track2(target, "has", key); + } + return result; + } + function ownKeys(target) { + track2(target, "iterate", shared.isArray(target) ? "length" : ITERATE_KEY); + return Reflect.ownKeys(target); + } + var mutableHandlers = { + get: get2, + set: set2, + deleteProperty, + has, + ownKeys + }; + var readonlyHandlers = { + get: readonlyGet, + set(target, key) { + { + console.warn(`Set operation on key "${String(key)}" failed: target is readonly.`, target); + } + return true; + }, + deleteProperty(target, key) { + { + console.warn(`Delete operation on key "${String(key)}" failed: target is readonly.`, target); + } + return true; + } + }; + var shallowReactiveHandlers = /* @__PURE__ */ shared.extend({}, mutableHandlers, { + get: shallowGet, + set: shallowSet + }); + var shallowReadonlyHandlers = /* @__PURE__ */ shared.extend({}, readonlyHandlers, { + get: shallowReadonlyGet + }); + var toReactive = (value) => shared.isObject(value) ? reactive3(value) : value; + var toReadonly = (value) => shared.isObject(value) ? readonly(value) : value; + var toShallow = (value) => value; + var getProto = (v) => Reflect.getPrototypeOf(v); + function get$1(target, key, isReadonly2 = false, isShallow = false) { + target = target["__v_raw"]; + const rawTarget = toRaw2(target); + const rawKey = toRaw2(key); + if (key !== rawKey) { + !isReadonly2 && track2(rawTarget, "get", key); + } + !isReadonly2 && track2(rawTarget, "get", rawKey); + const { has: has2 } = getProto(rawTarget); + const wrap = isShallow ? toShallow : isReadonly2 ? toReadonly : toReactive; + if (has2.call(rawTarget, key)) { + return wrap(target.get(key)); + } else if (has2.call(rawTarget, rawKey)) { + return wrap(target.get(rawKey)); + } else if (target !== rawTarget) { + target.get(key); + } + } + function has$1(key, isReadonly2 = false) { + const target = this["__v_raw"]; + const rawTarget = toRaw2(target); + const rawKey = toRaw2(key); + if (key !== rawKey) { + !isReadonly2 && track2(rawTarget, "has", key); + } + !isReadonly2 && track2(rawTarget, "has", rawKey); + return key === rawKey ? target.has(key) : target.has(key) || target.has(rawKey); + } + function size(target, isReadonly2 = false) { + target = target["__v_raw"]; + !isReadonly2 && track2(toRaw2(target), "iterate", ITERATE_KEY); + return Reflect.get(target, "size", target); + } + function add(value) { + value = toRaw2(value); + const target = toRaw2(this); + const proto = getProto(target); + const hadKey = proto.has.call(target, value); + if (!hadKey) { + target.add(value); + trigger2(target, "add", value, value); + } + return this; + } + function set$1(key, value) { + value = toRaw2(value); + const target = toRaw2(this); + const { has: has2, get: get3 } = getProto(target); + let hadKey = has2.call(target, key); + if (!hadKey) { + key = toRaw2(key); + hadKey = has2.call(target, key); + } else { + checkIdentityKeys(target, has2, key); + } + const oldValue = get3.call(target, key); + target.set(key, value); + if (!hadKey) { + trigger2(target, "add", key, value); + } else if (shared.hasChanged(value, oldValue)) { + trigger2(target, "set", key, value, oldValue); + } + return this; + } + function deleteEntry(key) { + const target = toRaw2(this); + const { has: has2, get: get3 } = getProto(target); + let hadKey = has2.call(target, key); + if (!hadKey) { + key = toRaw2(key); + hadKey = has2.call(target, key); + } else { + checkIdentityKeys(target, has2, key); + } + const oldValue = get3 ? get3.call(target, key) : void 0; + const result = target.delete(key); + if (hadKey) { + trigger2(target, "delete", key, void 0, oldValue); + } + return result; + } + function clear() { + const target = toRaw2(this); + const hadItems = target.size !== 0; + const oldTarget = shared.isMap(target) ? new Map(target) : new Set(target); + const result = target.clear(); + if (hadItems) { + trigger2(target, "clear", void 0, void 0, oldTarget); + } + return result; + } + function createForEach(isReadonly2, isShallow) { + return function forEach(callback, thisArg) { + const observed = this; + const target = observed["__v_raw"]; + const rawTarget = toRaw2(target); + const wrap = isShallow ? toShallow : isReadonly2 ? toReadonly : toReactive; + !isReadonly2 && track2(rawTarget, "iterate", ITERATE_KEY); + return target.forEach((value, key) => { + return callback.call(thisArg, wrap(value), wrap(key), observed); + }); + }; + } + function createIterableMethod(method, isReadonly2, isShallow) { + return function(...args) { + const target = this["__v_raw"]; + const rawTarget = toRaw2(target); + const targetIsMap = shared.isMap(rawTarget); + const isPair = method === "entries" || method === Symbol.iterator && targetIsMap; + const isKeyOnly = method === "keys" && targetIsMap; + const innerIterator = target[method](...args); + const wrap = isShallow ? toShallow : isReadonly2 ? toReadonly : toReactive; + !isReadonly2 && track2(rawTarget, "iterate", isKeyOnly ? MAP_KEY_ITERATE_KEY : ITERATE_KEY); + return { + next() { + const { value, done } = innerIterator.next(); + return done ? { value, done } : { + value: isPair ? [wrap(value[0]), wrap(value[1])] : wrap(value), + done + }; + }, + [Symbol.iterator]() { + return this; + } + }; + }; + } + function createReadonlyMethod(type) { + return function(...args) { + { + const key = args[0] ? `on key "${args[0]}" ` : ``; + console.warn(`${shared.capitalize(type)} operation ${key}failed: target is readonly.`, toRaw2(this)); + } + return type === "delete" ? false : this; + }; + } + function createInstrumentations() { + const mutableInstrumentations2 = { + get(key) { + return get$1(this, key); + }, + get size() { + return size(this); + }, + has: has$1, + add, + set: set$1, + delete: deleteEntry, + clear, + forEach: createForEach(false, false) + }; + const shallowInstrumentations2 = { + get(key) { + return get$1(this, key, false, true); + }, + get size() { + return size(this); + }, + has: has$1, + add, + set: set$1, + delete: deleteEntry, + clear, + forEach: createForEach(false, true) + }; + const readonlyInstrumentations2 = { + get(key) { + return get$1(this, key, true); + }, + get size() { + return size(this, true); + }, + has(key) { + return has$1.call(this, key, true); + }, + add: createReadonlyMethod("add"), + set: createReadonlyMethod("set"), + delete: createReadonlyMethod("delete"), + clear: createReadonlyMethod("clear"), + forEach: createForEach(true, false) + }; + const shallowReadonlyInstrumentations2 = { + get(key) { + return get$1(this, key, true, true); + }, + get size() { + return size(this, true); + }, + has(key) { + return has$1.call(this, key, true); + }, + add: createReadonlyMethod("add"), + set: createReadonlyMethod("set"), + delete: createReadonlyMethod("delete"), + clear: createReadonlyMethod("clear"), + forEach: createForEach(true, true) + }; + const iteratorMethods = ["keys", "values", "entries", Symbol.iterator]; + iteratorMethods.forEach((method) => { + mutableInstrumentations2[method] = createIterableMethod(method, false, false); + readonlyInstrumentations2[method] = createIterableMethod(method, true, false); + shallowInstrumentations2[method] = createIterableMethod(method, false, true); + shallowReadonlyInstrumentations2[method] = createIterableMethod(method, true, true); + }); + return [ + mutableInstrumentations2, + readonlyInstrumentations2, + shallowInstrumentations2, + shallowReadonlyInstrumentations2 + ]; + } + var [mutableInstrumentations, readonlyInstrumentations, shallowInstrumentations, shallowReadonlyInstrumentations] = /* @__PURE__ */ createInstrumentations(); + function createInstrumentationGetter(isReadonly2, shallow) { + const instrumentations = shallow ? isReadonly2 ? shallowReadonlyInstrumentations : shallowInstrumentations : isReadonly2 ? readonlyInstrumentations : mutableInstrumentations; + return (target, key, receiver) => { + if (key === "__v_isReactive") { + return !isReadonly2; + } else if (key === "__v_isReadonly") { + return isReadonly2; + } else if (key === "__v_raw") { + return target; + } + return Reflect.get(shared.hasOwn(instrumentations, key) && key in target ? instrumentations : target, key, receiver); + }; + } + var mutableCollectionHandlers = { + get: /* @__PURE__ */ createInstrumentationGetter(false, false) + }; + var shallowCollectionHandlers = { + get: /* @__PURE__ */ createInstrumentationGetter(false, true) + }; + var readonlyCollectionHandlers = { + get: /* @__PURE__ */ createInstrumentationGetter(true, false) + }; + var shallowReadonlyCollectionHandlers = { + get: /* @__PURE__ */ createInstrumentationGetter(true, true) + }; + function checkIdentityKeys(target, has2, key) { + const rawKey = toRaw2(key); + if (rawKey !== key && has2.call(target, rawKey)) { + const type = shared.toRawType(target); + console.warn(`Reactive ${type} contains both the raw and reactive versions of the same object${type === `Map` ? ` as keys` : ``}, which can lead to inconsistencies. Avoid differentiating between the raw and reactive versions of an object and only use the reactive version if possible.`); + } + } + var reactiveMap = /* @__PURE__ */ new WeakMap(); + var shallowReactiveMap = /* @__PURE__ */ new WeakMap(); + var readonlyMap = /* @__PURE__ */ new WeakMap(); + var shallowReadonlyMap = /* @__PURE__ */ new WeakMap(); + function targetTypeMap(rawType) { + switch (rawType) { + case "Object": + case "Array": + return 1; + case "Map": + case "Set": + case "WeakMap": + case "WeakSet": + return 2; + default: + return 0; + } + } + function getTargetType(value) { + return value["__v_skip"] || !Object.isExtensible(value) ? 0 : targetTypeMap(shared.toRawType(value)); + } + function reactive3(target) { + if (target && target["__v_isReadonly"]) { + return target; + } + return createReactiveObject(target, false, mutableHandlers, mutableCollectionHandlers, reactiveMap); + } + function shallowReactive(target) { + return createReactiveObject(target, false, shallowReactiveHandlers, shallowCollectionHandlers, shallowReactiveMap); + } + function readonly(target) { + return createReactiveObject(target, true, readonlyHandlers, readonlyCollectionHandlers, readonlyMap); + } + function shallowReadonly(target) { + return createReactiveObject(target, true, shallowReadonlyHandlers, shallowReadonlyCollectionHandlers, shallowReadonlyMap); + } + function createReactiveObject(target, isReadonly2, baseHandlers, collectionHandlers, proxyMap) { + if (!shared.isObject(target)) { + { + console.warn(`value cannot be made reactive: ${String(target)}`); + } + return target; + } + if (target["__v_raw"] && !(isReadonly2 && target["__v_isReactive"])) { + return target; + } + const existingProxy = proxyMap.get(target); + if (existingProxy) { + return existingProxy; + } + const targetType = getTargetType(target); + if (targetType === 0) { + return target; + } + const proxy = new Proxy(target, targetType === 2 ? collectionHandlers : baseHandlers); + proxyMap.set(target, proxy); + return proxy; + } + function isReactive2(value) { + if (isReadonly(value)) { + return isReactive2(value["__v_raw"]); + } + return !!(value && value["__v_isReactive"]); + } + function isReadonly(value) { + return !!(value && value["__v_isReadonly"]); + } + function isProxy(value) { + return isReactive2(value) || isReadonly(value); + } + function toRaw2(observed) { + return observed && toRaw2(observed["__v_raw"]) || observed; + } + function markRaw(value) { + shared.def(value, "__v_skip", true); + return value; + } + var convert = (val) => shared.isObject(val) ? reactive3(val) : val; + function isRef(r) { + return Boolean(r && r.__v_isRef === true); + } + function ref(value) { + return createRef(value); + } + function shallowRef(value) { + return createRef(value, true); + } + var RefImpl = class { + constructor(value, _shallow = false) { + this._shallow = _shallow; + this.__v_isRef = true; + this._rawValue = _shallow ? value : toRaw2(value); + this._value = _shallow ? value : convert(value); + } + get value() { + track2(toRaw2(this), "get", "value"); + return this._value; + } + set value(newVal) { + newVal = this._shallow ? newVal : toRaw2(newVal); + if (shared.hasChanged(newVal, this._rawValue)) { + this._rawValue = newVal; + this._value = this._shallow ? newVal : convert(newVal); + trigger2(toRaw2(this), "set", "value", newVal); + } + } + }; + function createRef(rawValue, shallow = false) { + if (isRef(rawValue)) { + return rawValue; + } + return new RefImpl(rawValue, shallow); + } + function triggerRef(ref2) { + trigger2(toRaw2(ref2), "set", "value", ref2.value); + } + function unref(ref2) { + return isRef(ref2) ? ref2.value : ref2; + } + var shallowUnwrapHandlers = { + get: (target, key, receiver) => unref(Reflect.get(target, key, receiver)), + set: (target, key, value, receiver) => { + const oldValue = target[key]; + if (isRef(oldValue) && !isRef(value)) { + oldValue.value = value; + return true; + } else { + return Reflect.set(target, key, value, receiver); + } + } + }; + function proxyRefs(objectWithRefs) { + return isReactive2(objectWithRefs) ? objectWithRefs : new Proxy(objectWithRefs, shallowUnwrapHandlers); + } + var CustomRefImpl = class { + constructor(factory) { + this.__v_isRef = true; + const { get: get3, set: set3 } = factory(() => track2(this, "get", "value"), () => trigger2(this, "set", "value")); + this._get = get3; + this._set = set3; + } + get value() { + return this._get(); + } + set value(newVal) { + this._set(newVal); + } + }; + function customRef(factory) { + return new CustomRefImpl(factory); + } + function toRefs(object) { + if (!isProxy(object)) { + console.warn(`toRefs() expects a reactive object but received a plain one.`); + } + const ret = shared.isArray(object) ? new Array(object.length) : {}; + for (const key in object) { + ret[key] = toRef(object, key); + } + return ret; + } + var ObjectRefImpl = class { + constructor(_object, _key) { + this._object = _object; + this._key = _key; + this.__v_isRef = true; + } + get value() { + return this._object[this._key]; + } + set value(newVal) { + this._object[this._key] = newVal; + } + }; + function toRef(object, key) { + return isRef(object[key]) ? object[key] : new ObjectRefImpl(object, key); + } + var ComputedRefImpl = class { + constructor(getter, _setter, isReadonly2) { + this._setter = _setter; + this._dirty = true; + this.__v_isRef = true; + this.effect = effect3(getter, { + lazy: true, + scheduler: () => { + if (!this._dirty) { + this._dirty = true; + trigger2(toRaw2(this), "set", "value"); + } + } + }); + this["__v_isReadonly"] = isReadonly2; + } + get value() { + const self2 = toRaw2(this); + if (self2._dirty) { + self2._value = this.effect(); + self2._dirty = false; + } + track2(self2, "get", "value"); + return self2._value; + } + set value(newValue) { + this._setter(newValue); + } + }; + function computed(getterOrOptions) { + let getter; + let setter; + if (shared.isFunction(getterOrOptions)) { + getter = getterOrOptions; + setter = () => { + console.warn("Write operation failed: computed value is readonly"); + }; + } else { + getter = getterOrOptions.get; + setter = getterOrOptions.set; + } + return new ComputedRefImpl(getter, setter, shared.isFunction(getterOrOptions) || !getterOrOptions.set); + } + exports2.ITERATE_KEY = ITERATE_KEY; + exports2.computed = computed; + exports2.customRef = customRef; + exports2.effect = effect3; + exports2.enableTracking = enableTracking; + exports2.isProxy = isProxy; + exports2.isReactive = isReactive2; + exports2.isReadonly = isReadonly; + exports2.isRef = isRef; + exports2.markRaw = markRaw; + exports2.pauseTracking = pauseTracking; + exports2.proxyRefs = proxyRefs; + exports2.reactive = reactive3; + exports2.readonly = readonly; + exports2.ref = ref; + exports2.resetTracking = resetTracking; + exports2.shallowReactive = shallowReactive; + exports2.shallowReadonly = shallowReadonly; + exports2.shallowRef = shallowRef; + exports2.stop = stop2; + exports2.toRaw = toRaw2; + exports2.toRef = toRef; + exports2.toRefs = toRefs; + exports2.track = track2; + exports2.trigger = trigger2; + exports2.triggerRef = triggerRef; + exports2.unref = unref; + } + }); + var require_reactivity = __commonJS2({ + "node_modules/@vue/reactivity/index.js"(exports2, module2) { + "use strict"; + if (false) { + module2.exports = null; + } else { + module2.exports = require_reactivity_cjs(); + } + } + }); + var module_exports = {}; + __export(module_exports, { + Alpine: () => src_default, + default: () => module_default + }); + module.exports = __toCommonJS(module_exports); + var flushPending = false; + var flushing = false; + var queue = []; + var lastFlushedIndex = -1; + function scheduler(callback) { + queueJob(callback); + } + function queueJob(job) { + if (!queue.includes(job)) + queue.push(job); + queueFlush(); + } + function dequeueJob(job) { + let index = queue.indexOf(job); + if (index !== -1 && index > lastFlushedIndex) + queue.splice(index, 1); + } + function queueFlush() { + if (!flushing && !flushPending) { + flushPending = true; + queueMicrotask(flushJobs); + } + } + function flushJobs() { + flushPending = false; + flushing = true; + for (let i = 0; i < queue.length; i++) { + queue[i](); + lastFlushedIndex = i; + } + queue.length = 0; + lastFlushedIndex = -1; + flushing = false; + } + var reactive; + var effect; + var release; + var raw; + var shouldSchedule = true; + function disableEffectScheduling(callback) { + shouldSchedule = false; + callback(); + shouldSchedule = true; + } + function setReactivityEngine(engine) { + reactive = engine.reactive; + release = engine.release; + effect = (callback) => engine.effect(callback, { scheduler: (task) => { + if (shouldSchedule) { + scheduler(task); + } else { + task(); + } + } }); + raw = engine.raw; + } + function overrideEffect(override) { + effect = override; + } + function elementBoundEffect(el) { + let cleanup = () => { + }; + let wrappedEffect = (callback) => { + let effectReference = effect(callback); + if (!el._x_effects) { + el._x_effects = /* @__PURE__ */ new Set(); + el._x_runEffects = () => { + el._x_effects.forEach((i) => i()); + }; + } + el._x_effects.add(effectReference); + cleanup = () => { + if (effectReference === void 0) + return; + el._x_effects.delete(effectReference); + release(effectReference); + }; + return effectReference; + }; + return [wrappedEffect, () => { + cleanup(); + }]; + } + function watch(getter, callback) { + let firstTime = true; + let oldValue; + let effectReference = effect(() => { + let value = getter(); + JSON.stringify(value); + if (!firstTime) { + queueMicrotask(() => { + callback(value, oldValue); + oldValue = value; + }); + } else { + oldValue = value; + } + firstTime = false; + }); + return () => release(effectReference); + } + var onAttributeAddeds = []; + var onElRemoveds = []; + var onElAddeds = []; + function onElAdded(callback) { + onElAddeds.push(callback); + } + function onElRemoved(el, callback) { + if (typeof callback === "function") { + if (!el._x_cleanups) + el._x_cleanups = []; + el._x_cleanups.push(callback); + } else { + callback = el; + onElRemoveds.push(callback); + } + } + function onAttributesAdded(callback) { + onAttributeAddeds.push(callback); + } + function onAttributeRemoved(el, name, callback) { + if (!el._x_attributeCleanups) + el._x_attributeCleanups = {}; + if (!el._x_attributeCleanups[name]) + el._x_attributeCleanups[name] = []; + el._x_attributeCleanups[name].push(callback); + } + function cleanupAttributes(el, names) { + if (!el._x_attributeCleanups) + return; + Object.entries(el._x_attributeCleanups).forEach(([name, value]) => { + if (names === void 0 || names.includes(name)) { + value.forEach((i) => i()); + delete el._x_attributeCleanups[name]; + } + }); + } + function cleanupElement(el) { + var _a, _b; + (_a = el._x_effects) == null ? void 0 : _a.forEach(dequeueJob); + while ((_b = el._x_cleanups) == null ? void 0 : _b.length) + el._x_cleanups.pop()(); + } + var observer = new MutationObserver(onMutate); + var currentlyObserving = false; + function startObservingMutations() { + observer.observe(document, { subtree: true, childList: true, attributes: true, attributeOldValue: true }); + currentlyObserving = true; + } + function stopObservingMutations() { + flushObserver(); + observer.disconnect(); + currentlyObserving = false; + } + var queuedMutations = []; + function flushObserver() { + let records = observer.takeRecords(); + queuedMutations.push(() => records.length > 0 && onMutate(records)); + let queueLengthWhenTriggered = queuedMutations.length; + queueMicrotask(() => { + if (queuedMutations.length === queueLengthWhenTriggered) { + while (queuedMutations.length > 0) + queuedMutations.shift()(); + } + }); + } + function mutateDom(callback) { + if (!currentlyObserving) + return callback(); + stopObservingMutations(); + let result = callback(); + startObservingMutations(); + return result; + } + var isCollecting = false; + var deferredMutations = []; + function deferMutations() { + isCollecting = true; + } + function flushAndStopDeferringMutations() { + isCollecting = false; + onMutate(deferredMutations); + deferredMutations = []; + } + function onMutate(mutations) { + if (isCollecting) { + deferredMutations = deferredMutations.concat(mutations); + return; + } + let addedNodes = []; + let removedNodes = /* @__PURE__ */ new Set(); + let addedAttributes = /* @__PURE__ */ new Map(); + let removedAttributes = /* @__PURE__ */ new Map(); + for (let i = 0; i < mutations.length; i++) { + if (mutations[i].target._x_ignoreMutationObserver) + continue; + if (mutations[i].type === "childList") { + mutations[i].removedNodes.forEach((node) => { + if (node.nodeType !== 1) + return; + if (!node._x_marker) + return; + removedNodes.add(node); + }); + mutations[i].addedNodes.forEach((node) => { + if (node.nodeType !== 1) + return; + if (removedNodes.has(node)) { + removedNodes.delete(node); + return; + } + if (node._x_marker) + return; + addedNodes.push(node); + }); + } + if (mutations[i].type === "attributes") { + let el = mutations[i].target; + let name = mutations[i].attributeName; + let oldValue = mutations[i].oldValue; + let add = () => { + if (!addedAttributes.has(el)) + addedAttributes.set(el, []); + addedAttributes.get(el).push({ name, value: el.getAttribute(name) }); + }; + let remove = () => { + if (!removedAttributes.has(el)) + removedAttributes.set(el, []); + removedAttributes.get(el).push(name); + }; + if (el.hasAttribute(name) && oldValue === null) { + add(); + } else if (el.hasAttribute(name)) { + remove(); + add(); + } else { + remove(); + } + } + } + removedAttributes.forEach((attrs, el) => { + cleanupAttributes(el, attrs); + }); + addedAttributes.forEach((attrs, el) => { + onAttributeAddeds.forEach((i) => i(el, attrs)); + }); + for (let node of removedNodes) { + if (addedNodes.some((i) => i.contains(node))) + continue; + onElRemoveds.forEach((i) => i(node)); + } + for (let node of addedNodes) { + if (!node.isConnected) + continue; + onElAddeds.forEach((i) => i(node)); + } + addedNodes = null; + removedNodes = null; + addedAttributes = null; + removedAttributes = null; + } + function scope(node) { + return mergeProxies(closestDataStack(node)); + } + function addScopeToNode(node, data2, referenceNode) { + node._x_dataStack = [data2, ...closestDataStack(referenceNode || node)]; + return () => { + node._x_dataStack = node._x_dataStack.filter((i) => i !== data2); + }; + } + function closestDataStack(node) { + if (node._x_dataStack) + return node._x_dataStack; + if (typeof ShadowRoot === "function" && node instanceof ShadowRoot) { + return closestDataStack(node.host); + } + if (!node.parentNode) { + return []; + } + return closestDataStack(node.parentNode); + } + function mergeProxies(objects) { + return new Proxy({ objects }, mergeProxyTrap); + } + var mergeProxyTrap = { + ownKeys({ objects }) { + return Array.from(new Set(objects.flatMap((i) => Object.keys(i)))); + }, + has({ objects }, name) { + if (name == Symbol.unscopables) + return false; + return objects.some((obj) => Object.prototype.hasOwnProperty.call(obj, name) || Reflect.has(obj, name)); + }, + get({ objects }, name, thisProxy) { + if (name == "toJSON") + return collapseProxies; + return Reflect.get(objects.find((obj) => Reflect.has(obj, name)) || {}, name, thisProxy); + }, + set({ objects }, name, value, thisProxy) { + const target = objects.find((obj) => Object.prototype.hasOwnProperty.call(obj, name)) || objects[objects.length - 1]; + const descriptor = Object.getOwnPropertyDescriptor(target, name); + if ((descriptor == null ? void 0 : descriptor.set) && (descriptor == null ? void 0 : descriptor.get)) + return descriptor.set.call(thisProxy, value) || true; + return Reflect.set(target, name, value); + } + }; + function collapseProxies() { + let keys = Reflect.ownKeys(this); + return keys.reduce((acc, key) => { + acc[key] = Reflect.get(this, key); + return acc; + }, {}); + } + function initInterceptors(data2) { + let isObject2 = (val) => typeof val === "object" && !Array.isArray(val) && val !== null; + let recurse = (obj, basePath = "") => { + Object.entries(Object.getOwnPropertyDescriptors(obj)).forEach(([key, { value, enumerable }]) => { + if (enumerable === false || value === void 0) + return; + if (typeof value === "object" && value !== null && value.__v_skip) + return; + let path = basePath === "" ? key : `${basePath}.${key}`; + if (typeof value === "object" && value !== null && value._x_interceptor) { + obj[key] = value.initialize(data2, path, key); + } else { + if (isObject2(value) && value !== obj && !(value instanceof Element)) { + recurse(value, path); + } + } + }); + }; + return recurse(data2); + } + function interceptor(callback, mutateObj = () => { + }) { + let obj = { + initialValue: void 0, + _x_interceptor: true, + initialize(data2, path, key) { + return callback(this.initialValue, () => get(data2, path), (value) => set(data2, path, value), path, key); + } + }; + mutateObj(obj); + return (initialValue) => { + if (typeof initialValue === "object" && initialValue !== null && initialValue._x_interceptor) { + let initialize = obj.initialize.bind(obj); + obj.initialize = (data2, path, key) => { + let innerValue = initialValue.initialize(data2, path, key); + obj.initialValue = innerValue; + return initialize(data2, path, key); + }; + } else { + obj.initialValue = initialValue; + } + return obj; + }; + } + function get(obj, path) { + return path.split(".").reduce((carry, segment) => carry[segment], obj); + } + function set(obj, path, value) { + if (typeof path === "string") + path = path.split("."); + if (path.length === 1) + obj[path[0]] = value; + else if (path.length === 0) + throw error; + else { + if (obj[path[0]]) + return set(obj[path[0]], path.slice(1), value); + else { + obj[path[0]] = {}; + return set(obj[path[0]], path.slice(1), value); + } + } + } + var magics = {}; + function magic(name, callback) { + magics[name] = callback; + } + function injectMagics(obj, el) { + let memoizedUtilities = getUtilities(el); + Object.entries(magics).forEach(([name, callback]) => { + Object.defineProperty(obj, `$${name}`, { + get() { + return callback(el, memoizedUtilities); + }, + enumerable: false + }); + }); + return obj; + } + function getUtilities(el) { + let [utilities, cleanup] = getElementBoundUtilities(el); + let utils = { interceptor, ...utilities }; + onElRemoved(el, cleanup); + return utils; + } + function tryCatch(el, expression, callback, ...args) { + try { + return callback(...args); + } catch (e) { + handleError(e, el, expression); + } + } + function handleError(error2, el, expression = void 0) { + error2 = Object.assign(error2 != null ? error2 : { message: "No error message given." }, { el, expression }); + console.warn(`Alpine Expression Error: ${error2.message} + +${expression ? 'Expression: "' + expression + '"\n\n' : ""}`, el); + setTimeout(() => { + throw error2; + }, 0); + } + var shouldAutoEvaluateFunctions = true; + function dontAutoEvaluateFunctions(callback) { + let cache = shouldAutoEvaluateFunctions; + shouldAutoEvaluateFunctions = false; + let result = callback(); + shouldAutoEvaluateFunctions = cache; + return result; + } + function evaluate(el, expression, extras = {}) { + let result; + evaluateLater(el, expression)((value) => result = value, extras); + return result; + } + function evaluateLater(...args) { + return theEvaluatorFunction(...args); + } + var theEvaluatorFunction = normalEvaluator; + function setEvaluator(newEvaluator) { + theEvaluatorFunction = newEvaluator; + } + function normalEvaluator(el, expression) { + let overriddenMagics = {}; + injectMagics(overriddenMagics, el); + let dataStack = [overriddenMagics, ...closestDataStack(el)]; + let evaluator = typeof expression === "function" ? generateEvaluatorFromFunction(dataStack, expression) : generateEvaluatorFromString(dataStack, expression, el); + return tryCatch.bind(null, el, expression, evaluator); + } + function generateEvaluatorFromFunction(dataStack, func) { + return (receiver = () => { + }, { scope: scope2 = {}, params = [] } = {}) => { + let result = func.apply(mergeProxies([scope2, ...dataStack]), params); + runIfTypeOfFunction(receiver, result); + }; + } + var evaluatorMemo = {}; + function generateFunctionFromString(expression, el) { + if (evaluatorMemo[expression]) { + return evaluatorMemo[expression]; + } + let AsyncFunction = Object.getPrototypeOf(async function() { + }).constructor; + let rightSideSafeExpression = /^[\n\s]*if.*\(.*\)/.test(expression.trim()) || /^(let|const)\s/.test(expression.trim()) ? `(async()=>{ ${expression} })()` : expression; + const safeAsyncFunction = () => { + try { + let func2 = new AsyncFunction(["__self", "scope"], `with (scope) { __self.result = ${rightSideSafeExpression} }; __self.finished = true; return __self.result;`); + Object.defineProperty(func2, "name", { + value: `[Alpine] ${expression}` + }); + return func2; + } catch (error2) { + handleError(error2, el, expression); + return Promise.resolve(); + } + }; + let func = safeAsyncFunction(); + evaluatorMemo[expression] = func; + return func; + } + function generateEvaluatorFromString(dataStack, expression, el) { + let func = generateFunctionFromString(expression, el); + return (receiver = () => { + }, { scope: scope2 = {}, params = [] } = {}) => { + func.result = void 0; + func.finished = false; + let completeScope = mergeProxies([scope2, ...dataStack]); + if (typeof func === "function") { + let promise = func(func, completeScope).catch((error2) => handleError(error2, el, expression)); + if (func.finished) { + runIfTypeOfFunction(receiver, func.result, completeScope, params, el); + func.result = void 0; + } else { + promise.then((result) => { + runIfTypeOfFunction(receiver, result, completeScope, params, el); + }).catch((error2) => handleError(error2, el, expression)).finally(() => func.result = void 0); + } + } + }; + } + function runIfTypeOfFunction(receiver, value, scope2, params, el) { + if (shouldAutoEvaluateFunctions && typeof value === "function") { + let result = value.apply(scope2, params); + if (result instanceof Promise) { + result.then((i) => runIfTypeOfFunction(receiver, i, scope2, params)).catch((error2) => handleError(error2, el, value)); + } else { + receiver(result); + } + } else if (typeof value === "object" && value instanceof Promise) { + value.then((i) => receiver(i)); + } else { + receiver(value); + } + } + var prefixAsString = "x-"; + function prefix(subject = "") { + return prefixAsString + subject; + } + function setPrefix(newPrefix) { + prefixAsString = newPrefix; + } + var directiveHandlers = {}; + function directive2(name, callback) { + directiveHandlers[name] = callback; + return { + before(directive22) { + if (!directiveHandlers[directive22]) { + console.warn(String.raw`Cannot find directive \`${directive22}\`. \`${name}\` will use the default order of execution`); + return; + } + const pos = directiveOrder.indexOf(directive22); + directiveOrder.splice(pos >= 0 ? pos : directiveOrder.indexOf("DEFAULT"), 0, name); + } + }; + } + function directiveExists(name) { + return Object.keys(directiveHandlers).includes(name); + } + function directives(el, attributes, originalAttributeOverride) { + attributes = Array.from(attributes); + if (el._x_virtualDirectives) { + let vAttributes = Object.entries(el._x_virtualDirectives).map(([name, value]) => ({ name, value })); + let staticAttributes = attributesOnly(vAttributes); + vAttributes = vAttributes.map((attribute) => { + if (staticAttributes.find((attr) => attr.name === attribute.name)) { + return { + name: `x-bind:${attribute.name}`, + value: `"${attribute.value}"` + }; + } + return attribute; + }); + attributes = attributes.concat(vAttributes); + } + let transformedAttributeMap = {}; + let directives2 = attributes.map(toTransformedAttributes((newName, oldName) => transformedAttributeMap[newName] = oldName)).filter(outNonAlpineAttributes).map(toParsedDirectives(transformedAttributeMap, originalAttributeOverride)).sort(byPriority); + return directives2.map((directive22) => { + return getDirectiveHandler(el, directive22); + }); + } + function attributesOnly(attributes) { + return Array.from(attributes).map(toTransformedAttributes()).filter((attr) => !outNonAlpineAttributes(attr)); + } + var isDeferringHandlers = false; + var directiveHandlerStacks = /* @__PURE__ */ new Map(); + var currentHandlerStackKey = Symbol(); + function deferHandlingDirectives(callback) { + isDeferringHandlers = true; + let key = Symbol(); + currentHandlerStackKey = key; + directiveHandlerStacks.set(key, []); + let flushHandlers = () => { + while (directiveHandlerStacks.get(key).length) + directiveHandlerStacks.get(key).shift()(); + directiveHandlerStacks.delete(key); + }; + let stopDeferring = () => { + isDeferringHandlers = false; + flushHandlers(); + }; + callback(flushHandlers); + stopDeferring(); + } + function getElementBoundUtilities(el) { + let cleanups2 = []; + let cleanup = (callback) => cleanups2.push(callback); + let [effect3, cleanupEffect] = elementBoundEffect(el); + cleanups2.push(cleanupEffect); + let utilities = { + Alpine: alpine_default, + effect: effect3, + cleanup, + evaluateLater: evaluateLater.bind(evaluateLater, el), + evaluate: evaluate.bind(evaluate, el) + }; + let doCleanup = () => cleanups2.forEach((i) => i()); + return [utilities, doCleanup]; + } + function getDirectiveHandler(el, directive22) { + let noop = () => { + }; + let handler4 = directiveHandlers[directive22.type] || noop; + let [utilities, cleanup] = getElementBoundUtilities(el); + onAttributeRemoved(el, directive22.original, cleanup); + let fullHandler = () => { + if (el._x_ignore || el._x_ignoreSelf) + return; + handler4.inline && handler4.inline(el, directive22, utilities); + handler4 = handler4.bind(handler4, el, directive22, utilities); + isDeferringHandlers ? directiveHandlerStacks.get(currentHandlerStackKey).push(handler4) : handler4(); + }; + fullHandler.runCleanups = cleanup; + return fullHandler; + } + var startingWith = (subject, replacement) => ({ name, value }) => { + if (name.startsWith(subject)) + name = name.replace(subject, replacement); + return { name, value }; + }; + var into = (i) => i; + function toTransformedAttributes(callback = () => { + }) { + return ({ name, value }) => { + let { name: newName, value: newValue } = attributeTransformers.reduce((carry, transform) => { + return transform(carry); + }, { name, value }); + if (newName !== name) + callback(newName, name); + return { name: newName, value: newValue }; + }; + } + var attributeTransformers = []; + function mapAttributes(callback) { + attributeTransformers.push(callback); + } + function outNonAlpineAttributes({ name }) { + return alpineAttributeRegex().test(name); + } + var alpineAttributeRegex = () => new RegExp(`^${prefixAsString}([^:^.]+)\\b`); + function toParsedDirectives(transformedAttributeMap, originalAttributeOverride) { + return ({ name, value }) => { + let typeMatch = name.match(alpineAttributeRegex()); + let valueMatch = name.match(/:([a-zA-Z0-9\-_:]+)/); + let modifiers = name.match(/\.[^.\]]+(?=[^\]]*$)/g) || []; + let original = originalAttributeOverride || transformedAttributeMap[name] || name; + return { + type: typeMatch ? typeMatch[1] : null, + value: valueMatch ? valueMatch[1] : null, + modifiers: modifiers.map((i) => i.replace(".", "")), + expression: value, + original + }; + }; + } + var DEFAULT = "DEFAULT"; + var directiveOrder = [ + "ignore", + "ref", + "data", + "id", + "anchor", + "bind", + "init", + "for", + "model", + "modelable", + "transition", + "show", + "if", + DEFAULT, + "teleport" + ]; + function byPriority(a, b) { + let typeA = directiveOrder.indexOf(a.type) === -1 ? DEFAULT : a.type; + let typeB = directiveOrder.indexOf(b.type) === -1 ? DEFAULT : b.type; + return directiveOrder.indexOf(typeA) - directiveOrder.indexOf(typeB); + } + function dispatch3(el, name, detail = {}) { + el.dispatchEvent(new CustomEvent(name, { + detail, + bubbles: true, + composed: true, + cancelable: true + })); + } + function walk(el, callback) { + if (typeof ShadowRoot === "function" && el instanceof ShadowRoot) { + Array.from(el.children).forEach((el2) => walk(el2, callback)); + return; + } + let skip = false; + callback(el, () => skip = true); + if (skip) + return; + let node = el.firstElementChild; + while (node) { + walk(node, callback, false); + node = node.nextElementSibling; + } + } + function warn(message, ...args) { + console.warn(`Alpine Warning: ${message}`, ...args); + } + var started = false; + function start2() { + if (started) + warn("Alpine has already been initialized on this page. Calling Alpine.start() more than once can cause problems."); + started = true; + if (!document.body) + warn("Unable to initialize. Trying to load Alpine before `` is available. Did you forget to add `defer` in Alpine's ` diff --git a/src/view/frontend/templates/html/loader.phtml b/src/view/frontend/templates/html/loader.phtml deleted file mode 100644 index d5b2cc92..00000000 --- a/src/view/frontend/templates/html/loader.phtml +++ /dev/null @@ -1,30 +0,0 @@ - diff --git a/src/view/frontend/templates/html/pagination/pager.phtml b/src/view/frontend/templates/html/pagination/pager.phtml deleted file mode 100644 index c2f1cfbd..00000000 --- a/src/view/frontend/templates/html/pagination/pager.phtml +++ /dev/null @@ -1,37 +0,0 @@ -getComponent(); -?> -
- hasPages()): ?> -
- onFirstPage()): ?> - « - - - - - hasMorePages()): ?> - - - » - -
- -
diff --git a/src/view/frontend/templates/js/alpinejs/components/magewire-script.phtml b/src/view/frontend/templates/js/alpinejs/components/magewire-script.phtml new file mode 100644 index 00000000..e3bff16e --- /dev/null +++ b/src/view/frontend/templates/js/alpinejs/components/magewire-script.phtml @@ -0,0 +1,41 @@ +getData('view_model'); +$magewireUtility = $magewireViewModel->utils()->magewire(); +$magewireFragment = $magewireViewModel->utils()->fragment(); +?> +make()->script()->start() ?> + +end() ?> diff --git a/src/view/frontend/templates/js/alpinejs/directives/.gitkeep b/src/view/frontend/templates/js/alpinejs/directives/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/view/frontend/templates/js/magewire/directives/.gitkeep b/src/view/frontend/templates/js/magewire/directives/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/view/frontend/templates/js/magewire/object-proxy.phtml b/src/view/frontend/templates/js/magewire/object-proxy.phtml new file mode 100644 index 00000000..552ec7e0 --- /dev/null +++ b/src/view/frontend/templates/js/magewire/object-proxy.phtml @@ -0,0 +1,144 @@ +getData('view_model'); +$magewireFragment = $magewireViewModel->utils()->fragment(); + +/** + * @internal Do not modify to ensure Magewire continues to function correctly. + * + * This template creates a JavaScript Proxy that acts as a temporary stand-in for + * window.Magewire before the real Magewire (Livewire v3) runtime has loaded. + * + * Problem it solves: + * Third-party scripts and theme code may call Magewire APIs (e.g. Magewire.onError()) + * in inline scripts that execute before the Magewire JS bundle has been parsed. + * Without this Proxy, those calls would throw "Magewire is not defined" or similar errors. + * + * How it works: + * 1. A JS Proxy is assigned to window.Magewire immediately (inline, blocking). + * 2. Any method call on the Proxy is captured and pushed onto a queue. + * 3. Once the real Magewire runtime fires the "magewire:init" event, the queued + * calls are replayed against known backwards-compatibility handlers. + * 4. After init, the real Magewire object replaces the Proxy — the queue is no + * longer needed and is discarded. + * + * This file also contains the V1 -> V2 onError bridge (magewireBcOnError), which + * translates the old Magewire.onError(callback) signature into a V2 request hook. + */ +?> +make()->script()->start() ?> + +end() ?> diff --git a/src/view/frontend/templates/magewire-features/.gitkeep b/src/view/frontend/templates/magewire-features/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/view/frontend/templates/magewire/ui-components/.gitkeep b/src/view/frontend/templates/magewire/ui-components/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/view/frontend/templates/magewire/utils/.gitkeep b/src/view/frontend/templates/magewire/utils/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/view/frontend/templates/page/js/magewire-initialize.phtml b/src/view/frontend/templates/page/js/magewire-initialize.phtml deleted file mode 100644 index 59402743..00000000 --- a/src/view/frontend/templates/page/js/magewire-initialize.phtml +++ /dev/null @@ -1,69 +0,0 @@ -require(Magewire::class); -?> - diff --git a/src/view/frontend/templates/page/js/magewire.phtml b/src/view/frontend/templates/page/js/magewire.phtml deleted file mode 100644 index eab4fd57..00000000 --- a/src/view/frontend/templates/page/js/magewire.phtml +++ /dev/null @@ -1,14 +0,0 @@ - - diff --git a/src/view/frontend/templates/page/js/magewire/plugin/intersect.phtml b/src/view/frontend/templates/page/js/magewire/plugin/intersect.phtml deleted file mode 100644 index 82df3b5c..00000000 --- a/src/view/frontend/templates/page/js/magewire/plugin/intersect.phtml +++ /dev/null @@ -1,20 +0,0 @@ - diff --git a/src/view/frontend/templates/tests/magewire/playwright/directives/area/escape.phtml b/src/view/frontend/templates/tests/magewire/playwright/directives/area/escape.phtml new file mode 100644 index 00000000..3f2b053c --- /dev/null +++ b/src/view/frontend/templates/tests/magewire/playwright/directives/area/escape.phtml @@ -0,0 +1,12 @@ + +
diff --git a/src/Model/WireableInterface.php b/src/view/frontend/templates/tests/magewire/playwright/directives/area/render.phtml similarity index 68% rename from src/Model/WireableInterface.php rename to src/view/frontend/templates/tests/magewire/playwright/directives/area/render.phtml index 4ebfd279..fa9e88ba 100644 --- a/src/Model/WireableInterface.php +++ b/src/view/frontend/templates/tests/magewire/playwright/directives/area/render.phtml @@ -1,4 +1,5 @@ +
+ Parent -} + @renderChild(alias: 'child') +
diff --git a/src/view/frontend/templates/tests/magewire/playwright/directives/area/render/child-child.phtml b/src/view/frontend/templates/tests/magewire/playwright/directives/area/render/child-child.phtml new file mode 100644 index 00000000..c4f3262f --- /dev/null +++ b/src/view/frontend/templates/tests/magewire/playwright/directives/area/render/child-child.phtml @@ -0,0 +1,14 @@ + +
+ Child's Child +
diff --git a/src/view/frontend/templates/tests/magewire/playwright/directives/area/render/child.phtml b/src/view/frontend/templates/tests/magewire/playwright/directives/area/render/child.phtml new file mode 100644 index 00000000..f63539d4 --- /dev/null +++ b/src/view/frontend/templates/tests/magewire/playwright/directives/area/render/child.phtml @@ -0,0 +1,16 @@ + +
+ Child + + @renderChild(alias: 'child-child') +
diff --git a/src/view/frontend/templates/tests/magewire/playwright/directives/base.phtml b/src/view/frontend/templates/tests/magewire/playwright/directives/base.phtml new file mode 100644 index 00000000..2017abc4 --- /dev/null +++ b/src/view/frontend/templates/tests/magewire/playwright/directives/base.phtml @@ -0,0 +1,43 @@ + +
+

+ Basics +

+ + + + + + + + + + + + + + + + + + + + + + + + + + +
DirectiveInputResultExpected
translatefoo@translate(value: 'foo', escape: false)foo
translate (escaped)bar@translate(value: 'bar')bar
+
diff --git a/src/view/frontend/templates/tests/magewire/playwright/directives/scope.phtml b/src/view/frontend/templates/tests/magewire/playwright/directives/scope.phtml new file mode 100644 index 00000000..536a7081 --- /dev/null +++ b/src/view/frontend/templates/tests/magewire/playwright/directives/scope.phtml @@ -0,0 +1,34 @@ + +
+

+ Scope +

+ + + + + + + + + + + @foreach(['a', 'b', 'c'] as $key => $value) + + + + + @endforeach + +
KeyValue
+
diff --git a/src/view/frontend/templates/tests/magewire/playwright/resolvers/aliased.phtml b/src/view/frontend/templates/tests/magewire/playwright/resolvers/aliased.phtml new file mode 100644 index 00000000..00449675 --- /dev/null +++ b/src/view/frontend/templates/tests/magewire/playwright/resolvers/aliased.phtml @@ -0,0 +1,14 @@ + +
+ Layout resolver (aliased) +
\ No newline at end of file diff --git a/src/view/frontend/templates/tests/magewire/playwright/resolvers/default.phtml b/src/view/frontend/templates/tests/magewire/playwright/resolvers/default.phtml new file mode 100644 index 00000000..0d0561ae --- /dev/null +++ b/src/view/frontend/templates/tests/magewire/playwright/resolvers/default.phtml @@ -0,0 +1,14 @@ + +
+ Layout resolver (default) +
diff --git a/src/view/frontend/templates/tests/magewire/playwright/resolvers/nested.phtml b/src/view/frontend/templates/tests/magewire/playwright/resolvers/nested.phtml new file mode 100644 index 00000000..a244c58e --- /dev/null +++ b/src/view/frontend/templates/tests/magewire/playwright/resolvers/nested.phtml @@ -0,0 +1,16 @@ + +
+ Layout resolver (nested parent) + + @renderChild(alias: 'child') +
\ No newline at end of file diff --git a/src/view/frontend/templates/tests/magewire/playwright/resolvers/nested/child.phtml b/src/view/frontend/templates/tests/magewire/playwright/resolvers/nested/child.phtml new file mode 100644 index 00000000..e8edcafb --- /dev/null +++ b/src/view/frontend/templates/tests/magewire/playwright/resolvers/nested/child.phtml @@ -0,0 +1,14 @@ + +
+ Layout resolver (nested child) +
\ No newline at end of file diff --git a/src/view/frontend/web/js/livewire.js b/src/view/frontend/web/js/livewire.js deleted file mode 100644 index 77cc6a14..00000000 --- a/src/view/frontend/web/js/livewire.js +++ /dev/null @@ -1,14 +0,0 @@ -!function(global,factory){"object"==typeof exports&&"undefined"!=typeof module?module.exports=factory():"function"==typeof define&&define.amd?define(factory):(global=global||self).Livewire=factory()}(this,(function(){"use strict";function _typeof(obj){return(_typeof="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(obj){return typeof obj}:function(obj){return obj&&"function"==typeof Symbol&&obj.constructor===Symbol&&obj!==Symbol.prototype?"symbol":typeof obj})(obj)}function _classCallCheck(instance,Constructor){if(!(instance instanceof Constructor))throw new TypeError("Cannot call a class as a function")}function _defineProperties(target,props){for(var i=0;iarr.length)&&(len=arr.length);for(var i=0,arr2=new Array(len);i0&&void 0!==arguments[0]?arguments[0]:"right";return this.modifiers.includes("up")?"up":this.modifiers.includes("down")?"down":this.modifiers.includes("left")?"left":this.modifiers.includes("right")?"right":fallback}},{key:"value",get:function(){return this.el.getAttribute(this.rawName)}},{key:"method",get:function(){return this.parseOutMethodAndParams(this.value).method}},{key:"params",get:function(){return this.parseOutMethodAndParams(this.value).params}}]),Directive}();function walk(root,callback){if(!1!==callback(root))for(var node=root.firstElementChild;node;)walk(node,callback),node=node.nextElementSibling}function dispatch(eventName){var event=document.createEvent("Events");return event.initEvent(eventName,!0,!0),document.dispatchEvent(event),event}function getCsrfToken(){var _window$livewire_toke,tokenTag=document.head.querySelector('meta[name="csrf-token"]');return tokenTag?tokenTag.content:null!==(_window$livewire_toke=window.livewire_token)&&void 0!==_window$livewire_toke?_window$livewire_toke:void 0}function kebabCase(subject){return subject.replace(/([a-z])([A-Z])/g,"$1-$2").replace(/[_\s]/,"-").toLowerCase()} - /*! - * isobject - * - * Copyright (c) 2014-2017, Jon Schlinkert. - * Released under the MIT License. - */var isobject=function(val){return null!=val&&"object"==typeof val&&!1===Array.isArray(val)},getValue=function(target,path,options){if(isobject(options)||(options={default:options}),!isValidObject(target))return void 0!==options.default?options.default:target;"number"==typeof path&&(path=String(path));const isArray=Array.isArray(path),isString="string"==typeof path,splitChar=options.separator||".",joinChar=options.joinChar||("string"==typeof splitChar?splitChar:".");if(!isString&&!isArray)return target;if(isString&&path in target)return isValid(path,target,options)?target[path]:options.default;let segs=isArray?path:split(path,splitChar,options),len=segs.length,idx=0;do{let prop=segs[idx];for("number"==typeof prop&&(prop=String(prop));prop&&"\\"===prop.slice(-1);)prop=join([prop.slice(0,-1),segs[++idx]||""],joinChar,options);if(prop in target){if(!isValid(prop,target,options))return options.default;target=target[prop]}else{let hasProp=!1,n=idx+1;for(;n - * - * Copyright (c) 2014-2018, Jon Schlinkert. - * Released under the MIT License. - */function join(segs,joinChar,options){return"function"==typeof options.join?options.join(segs):segs[0]+joinChar+segs[1]}function split(path,splitChar,options){return"function"==typeof options.split?options.split(path):path.split(splitChar)}function isValid(key,target,options){return"function"!=typeof options.isValid||options.isValid(key,target)}function isValidObject(val){return isobject(val)||Array.isArray(val)||"function"==typeof val}var _default=function(){function _default(el){var skipWatcher=arguments.length>1&&void 0!==arguments[1]&&arguments[1];_classCallCheck(this,_default),this.el=el,this.skipWatcher=skipWatcher,this.resolveCallback=function(){},this.rejectCallback=function(){}}return _createClass(_default,[{key:"toId",value:function(){return btoa(encodeURIComponent(this.el.outerHTML))}},{key:"onResolve",value:function(callback){this.resolveCallback=callback}},{key:"onReject",value:function(callback){this.rejectCallback=callback}},{key:"resolve",value:function(thing){this.resolveCallback(thing)}},{key:"reject",value:function(thing){this.rejectCallback(thing)}}]),_default}(),_default$1=function(_Action){_inherits(_default,_Action);var _super=_createSuper(_default);function _default(event,params,el){var _this;return _classCallCheck(this,_default),(_this=_super.call(this,el)).type="fireEvent",_this.payload={event:event,params:params},_this}return _createClass(_default,[{key:"toId",value:function(){return btoa(encodeURIComponent(this.type,this.payload.event,JSON.stringify(this.payload.params)))}}]),_default}(_default),MessageBus=function(){function MessageBus(){_classCallCheck(this,MessageBus),this.listeners={}}return _createClass(MessageBus,[{key:"register",value:function(name,callback){this.listeners[name]||(this.listeners[name]=[]),this.listeners[name].push(callback)}},{key:"call",value:function(name){for(var _len=arguments.length,params=new Array(_len>1?_len-1:0),_key=1;_key<_len;_key++)params[_key-1]=arguments[_key];(this.listeners[name]||[]).forEach((function(callback){callback.apply(void 0,params)}))}},{key:"has",value:function(name){return Object.keys(this.listeners).includes(name)}}]),MessageBus}(),HookManager={availableHooks:["component.initialized","element.initialized","element.updating","element.updated","element.removed","message.sent","message.failed","message.received","message.processed","interceptWireModelSetValue","interceptWireModelAttachListener","beforeReplaceState","beforePushState"],bus:new MessageBus,register:function(name,callback){if(!this.availableHooks.includes(name))throw"Livewire: Referencing unknown hook: [".concat(name,"]");this.bus.register(name,callback)},call:function(name){for(var _this$bus,_len=arguments.length,params=new Array(_len>1?_len-1:0),_key=1;_key<_len;_key++)params[_key-1]=arguments[_key];(_this$bus=this.bus).call.apply(_this$bus,[name].concat(params))}},DirectiveManager$1={directives:new MessageBus,register:function(name,callback){if(this.has(name))throw"Livewire: Directive already registered: [".concat(name,"]");this.directives.register(name,callback)},call:function(name,el,directive,component){this.directives.call(name,el,directive,component)},has:function(name){return this.directives.has(name)}},store={componentsById:{},listeners:new MessageBus,initialRenderIsFinished:!1,livewireIsInBackground:!1,livewireIsOffline:!1,sessionHasExpired:!1,directives:DirectiveManager$1,hooks:HookManager,onErrorCallback:function(){},components:function(){var _this=this;return Object.keys(this.componentsById).map((function(key){return _this.componentsById[key]}))},addComponent:function(component){return this.componentsById[component.id]=component},findComponent:function(id){return this.componentsById[id]},getComponentsByName:function(name){return this.components().filter((function(component){return component.name===name}))},hasComponent:function(id){return!!this.componentsById[id]},tearDownComponents:function(){var _this2=this;this.components().forEach((function(component){_this2.removeComponent(component)}))},on:function(event,callback){this.listeners.register(event,callback)},emit:function(event){for(var _this$listeners,_len=arguments.length,params=new Array(_len>1?_len-1:0),_key=1;_key<_len;_key++)params[_key-1]=arguments[_key];(_this$listeners=this.listeners).call.apply(_this$listeners,[event].concat(params)),this.componentsListeningForEvent(event).forEach((function(component){return component.addAction(new _default$1(event,params))}))},emitUp:function(el,event){for(var _len2=arguments.length,params=new Array(_len2>2?_len2-2:0),_key2=2;_key2<_len2;_key2++)params[_key2-2]=arguments[_key2];this.componentsListeningForEventThatAreTreeAncestors(el,event).forEach((function(component){return component.addAction(new _default$1(event,params))}))},emitSelf:function(componentId,event){var component=this.findComponent(componentId);if(component.listeners.includes(event)){for(var _len3=arguments.length,params=new Array(_len3>2?_len3-2:0),_key3=2;_key3<_len3;_key3++)params[_key3-2]=arguments[_key3];component.addAction(new _default$1(event,params))}},emitTo:function(componentName,event){for(var _len4=arguments.length,params=new Array(_len4>2?_len4-2:0),_key4=2;_key4<_len4;_key4++)params[_key4-2]=arguments[_key4];var components=this.getComponentsByName(componentName);components.forEach((function(component){component.listeners.includes(event)&&component.addAction(new _default$1(event,params))}))},componentsListeningForEventThatAreTreeAncestors:function(el,event){for(var parentIds=[],parent=el.parentElement.closest("[wire\\:id]");parent;)parentIds.push(parent.getAttribute("wire:id")),parent=parent.parentElement.closest("[wire\\:id]");return this.components().filter((function(component){return component.listeners.includes(event)&&parentIds.includes(component.id)}))},componentsListeningForEvent:function(event){return this.components().filter((function(component){return component.listeners.includes(event)}))},registerDirective:function(name,callback){this.directives.register(name,callback)},registerHook:function(name,callback){this.hooks.register(name,callback)},callHook:function(name){for(var _this$hooks,_len5=arguments.length,params=new Array(_len5>1?_len5-1:0),_key5=1;_key5<_len5;_key5++)params[_key5-1]=arguments[_key5];(_this$hooks=this.hooks).call.apply(_this$hooks,[name].concat(params))},changeComponentId:function(component,newId){var oldId=component.id;component.id=newId,component.fingerprint.id=newId,this.componentsById[newId]=component,delete this.componentsById[oldId],this.components().forEach((function(component){var children=component.serverMemo.children||{};Object.entries(children).forEach((function(_ref){var _ref2=_slicedToArray(_ref,2),key=_ref2[0],_ref2$=_ref2[1],id=_ref2$.id;_ref2$.tagName;id===oldId&&(children[key].id=newId)}))}))},removeComponent:function(component){component.tearDown(),delete this.componentsById[component.id]},onError:function(callback){this.onErrorCallback=callback},getClosestParentId:function(childId,subsetOfParentIds){var _this3=this,distancesByParentId={};subsetOfParentIds.forEach((function(parentId){var distance=_this3.getDistanceToChild(parentId,childId);distance&&(distancesByParentId[parentId]=distance)}));var closestParentId,smallestDistance=Math.min.apply(Math,_toConsumableArray(Object.values(distancesByParentId)));return Object.entries(distancesByParentId).forEach((function(_ref3){var _ref4=_slicedToArray(_ref3,2),parentId=_ref4[0];_ref4[1]===smallestDistance&&(closestParentId=parentId)})),closestParentId},getDistanceToChild:function(parentId,childId){var distanceMemo=arguments.length>2&&void 0!==arguments[2]?arguments[2]:1,parentComponent=this.findComponent(parentId);if(parentComponent){var childIds=parentComponent.childIds;if(childIds.includes(childId))return distanceMemo;for(var i=0;i0&&void 0!==arguments[0]?arguments[0]:null;null===node&&(node=document);var allEls=Array.from(node.querySelectorAll("[wire\\:initial-data]")),onlyChildEls=Array.from(node.querySelectorAll("[wire\\:initial-data] [wire\\:initial-data]"));return allEls.filter((function(el){return!onlyChildEls.includes(el)}))},allModelElementsInside:function(root){return Array.from(root.querySelectorAll("[wire\\:model]"))},getByAttributeAndValue:function(attribute,value){return document.querySelector("[wire\\:".concat(attribute,'="').concat(value,'"]'))},nextFrame:function(fn){var _this=this;requestAnimationFrame((function(){requestAnimationFrame(fn.bind(_this))}))},closestRoot:function(el){return this.closestByAttribute(el,"id")},closestByAttribute:function(el,attribute){var closestEl=el.closest("[wire\\:".concat(attribute,"]"));if(!closestEl)throw"\nLivewire Error:\n\nCannot find parent element in DOM tree containing attribute: [wire:".concat(attribute,"].\n\nUsually this is caused by Livewire's DOM-differ not being able to properly track changes.\n\nReference the following guide for common causes: https://laravel-livewire.com/docs/troubleshooting \n\nReferenced element:\n\n").concat(el.outerHTML,"\n");return closestEl},isComponentRootEl:function(el){return this.hasAttribute(el,"id")},hasAttribute:function(el,attribute){return el.hasAttribute("wire:".concat(attribute))},getAttribute:function(el,attribute){return el.getAttribute("wire:".concat(attribute))},removeAttribute:function(el,attribute){return el.removeAttribute("wire:".concat(attribute))},setAttribute:function(el,attribute,value){return el.setAttribute("wire:".concat(attribute),value)},hasFocus:function(el){return el===document.activeElement},isInput:function(el){return["INPUT","TEXTAREA","SELECT"].includes(el.tagName.toUpperCase())},isTextInput:function(el){return["INPUT","TEXTAREA"].includes(el.tagName.toUpperCase())&&!["checkbox","radio"].includes(el.type)},valueFromInput:function(el,component){if("checkbox"===el.type){var modelName=wireDirectives(el).get("model").value,modelValue=component.deferredActions[modelName]?component.deferredActions[modelName].payload.value:getValue(component.data,modelName);return Array.isArray(modelValue)?this.mergeCheckboxValueIntoArray(el,modelValue):!!el.checked&&(el.getAttribute("value")||!0)}return"SELECT"===el.tagName&&el.multiple?this.getSelectValues(el):el.value},mergeCheckboxValueIntoArray:function(el,arrayValue){return el.checked?arrayValue.includes(el.value)?arrayValue:arrayValue.concat(el.value):arrayValue.filter((function(item){return item!==el.value}))},setInputValueFromModel:function(el,component){var modelString=wireDirectives(el).get("model").value,modelValue=getValue(component.data,modelString);"input"===el.tagName.toLowerCase()&&"file"===el.type||this.setInputValue(el,modelValue)},setInputValue:function(el,value){if(store.callHook("interceptWireModelSetValue",value,el),"radio"===el.type)el.checked=el.value==value;else if("checkbox"===el.type)if(Array.isArray(value)){var valueFound=!1;value.forEach((function(val){val==el.value&&(valueFound=!0)})),el.checked=valueFound}else el.checked=!!value;else"SELECT"===el.tagName?this.updateSelect(el,value):(value=void 0===value?"":value,el.value=value)},getSelectValues:function(el){return Array.from(el.options).filter((function(option){return option.selected})).map((function(option){return option.value||option.text}))},updateSelect:function(el,value){var arrayWrappedValue=[].concat(value).map((function(value){return value+""}));Array.from(el.options).forEach((function(option){option.selected=arrayWrappedValue.includes(option.value)}))}},ceil=Math.ceil,floor=Math.floor,toInteger=function(argument){return isNaN(argument=+argument)?0:(argument>0?floor:ceil)(argument)},requireObjectCoercible=function(it){if(null==it)throw TypeError("Can't call method on "+it);return it},createMethod=function(CONVERT_TO_STRING){return function($this,pos){var first,second,S=String(requireObjectCoercible($this)),position=toInteger(pos),size=S.length;return position<0||position>=size?CONVERT_TO_STRING?"":void 0:(first=S.charCodeAt(position))<55296||first>56319||position+1===size||(second=S.charCodeAt(position+1))<56320||second>57343?CONVERT_TO_STRING?S.charAt(position):first:CONVERT_TO_STRING?S.slice(position,position+2):second-56320+(first-55296<<10)+65536}},stringMultibyte={codeAt:createMethod(!1),charAt:createMethod(!0)},commonjsGlobal="undefined"!=typeof globalThis?globalThis:"undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:{};function createCommonjsModule(fn,basedir,module){return fn(module={path:basedir,exports:{},require:function(path,base){return commonjsRequire(path,null==base?module.path:base)}},module.exports),module.exports}function commonjsRequire(){throw new Error("Dynamic requires are not currently supported by @rollup/plugin-commonjs")}var check=function(it){return it&&it.Math==Math&&it},global_1=check("object"==typeof globalThis&&globalThis)||check("object"==typeof window&&window)||check("object"==typeof self&&self)||check("object"==typeof commonjsGlobal&&commonjsGlobal)||Function("return this")(),fails=function(exec){try{return!!exec()}catch(error){return!0}},descriptors=!fails((function(){return 7!=Object.defineProperty({},1,{get:function(){return 7}})[1]})),isObject=function(it){return"object"==typeof it?null!==it:"function"==typeof it},document$1=global_1.document,EXISTS=isObject(document$1)&&isObject(document$1.createElement),documentCreateElement=function(it){return EXISTS?document$1.createElement(it):{}},ie8DomDefine=!descriptors&&!fails((function(){return 7!=Object.defineProperty(documentCreateElement("div"),"a",{get:function(){return 7}}).a})),anObject=function(it){if(!isObject(it))throw TypeError(String(it)+" is not an object");return it},toPrimitive=function(input,PREFERRED_STRING){if(!isObject(input))return input;var fn,val;if(PREFERRED_STRING&&"function"==typeof(fn=input.toString)&&!isObject(val=fn.call(input)))return val;if("function"==typeof(fn=input.valueOf)&&!isObject(val=fn.call(input)))return val;if(!PREFERRED_STRING&&"function"==typeof(fn=input.toString)&&!isObject(val=fn.call(input)))return val;throw TypeError("Can't convert object to primitive value")},nativeDefineProperty=Object.defineProperty,f=descriptors?nativeDefineProperty:function(O,P,Attributes){if(anObject(O),P=toPrimitive(P,!0),anObject(Attributes),ie8DomDefine)try{return nativeDefineProperty(O,P,Attributes)}catch(error){}if("get"in Attributes||"set"in Attributes)throw TypeError("Accessors not supported");return"value"in Attributes&&(O[P]=Attributes.value),O},objectDefineProperty={f:f},createPropertyDescriptor=function(bitmap,value){return{enumerable:!(1&bitmap),configurable:!(2&bitmap),writable:!(4&bitmap),value:value}},createNonEnumerableProperty=descriptors?function(object,key,value){return objectDefineProperty.f(object,key,createPropertyDescriptor(1,value))}:function(object,key,value){return object[key]=value,object},setGlobal=function(key,value){try{createNonEnumerableProperty(global_1,key,value)}catch(error){global_1[key]=value}return value},SHARED="__core-js_shared__",store$1=global_1[SHARED]||setGlobal(SHARED,{}),sharedStore=store$1,functionToString=Function.toString;"function"!=typeof sharedStore.inspectSource&&(sharedStore.inspectSource=function(it){return functionToString.call(it)});var inspectSource=sharedStore.inspectSource,WeakMap=global_1.WeakMap,nativeWeakMap="function"==typeof WeakMap&&/native code/.test(inspectSource(WeakMap)),hasOwnProperty={}.hasOwnProperty,has=function(it,key){return hasOwnProperty.call(it,key)},shared=createCommonjsModule((function(module){(module.exports=function(key,value){return sharedStore[key]||(sharedStore[key]=void 0!==value?value:{})})("versions",[]).push({version:"3.6.5",mode:"global",copyright:"© 2020 Denis Pushkarev (zloirock.ru)"})})),id=0,postfix=Math.random(),uid=function(key){return"Symbol("+String(void 0===key?"":key)+")_"+(++id+postfix).toString(36)},keys=shared("keys"),sharedKey=function(key){return keys[key]||(keys[key]=uid(key))},hiddenKeys={},WeakMap$1=global_1.WeakMap,set,get,has$1,enforce=function(it){return has$1(it)?get(it):set(it,{})},getterFor=function(TYPE){return function(it){var state;if(!isObject(it)||(state=get(it)).type!==TYPE)throw TypeError("Incompatible receiver, "+TYPE+" required");return state}};if(nativeWeakMap){var store$2=new WeakMap$1,wmget=store$2.get,wmhas=store$2.has,wmset=store$2.set;set=function(it,metadata){return wmset.call(store$2,it,metadata),metadata},get=function(it){return wmget.call(store$2,it)||{}},has$1=function(it){return wmhas.call(store$2,it)}}else{var STATE=sharedKey("state");hiddenKeys[STATE]=!0,set=function(it,metadata){return createNonEnumerableProperty(it,STATE,metadata),metadata},get=function(it){return has(it,STATE)?it[STATE]:{}},has$1=function(it){return has(it,STATE)}}var internalState={set:set,get:get,has:has$1,enforce:enforce,getterFor:getterFor},nativePropertyIsEnumerable={}.propertyIsEnumerable,getOwnPropertyDescriptor=Object.getOwnPropertyDescriptor,NASHORN_BUG=getOwnPropertyDescriptor&&!nativePropertyIsEnumerable.call({1:2},1),f$1=NASHORN_BUG?function(V){var descriptor=getOwnPropertyDescriptor(this,V);return!!descriptor&&descriptor.enumerable}:nativePropertyIsEnumerable,objectPropertyIsEnumerable={f:f$1},toString={}.toString,classofRaw=function(it){return toString.call(it).slice(8,-1)},split$1="".split,indexedObject=fails((function(){return!Object("z").propertyIsEnumerable(0)}))?function(it){return"String"==classofRaw(it)?split$1.call(it,""):Object(it)}:Object,toIndexedObject=function(it){return indexedObject(requireObjectCoercible(it))},nativeGetOwnPropertyDescriptor=Object.getOwnPropertyDescriptor,f$2=descriptors?nativeGetOwnPropertyDescriptor:function(O,P){if(O=toIndexedObject(O),P=toPrimitive(P,!0),ie8DomDefine)try{return nativeGetOwnPropertyDescriptor(O,P)}catch(error){}if(has(O,P))return createPropertyDescriptor(!objectPropertyIsEnumerable.f.call(O,P),O[P])},objectGetOwnPropertyDescriptor={f:f$2},redefine=createCommonjsModule((function(module){var getInternalState=internalState.get,enforceInternalState=internalState.enforce,TEMPLATE=String(String).split("String");(module.exports=function(O,key,value,options){var unsafe=!!options&&!!options.unsafe,simple=!!options&&!!options.enumerable,noTargetGet=!!options&&!!options.noTargetGet;"function"==typeof value&&("string"!=typeof key||has(value,"name")||createNonEnumerableProperty(value,"name",key),enforceInternalState(value).source=TEMPLATE.join("string"==typeof key?key:"")),O!==global_1?(unsafe?!noTargetGet&&O[key]&&(simple=!0):delete O[key],simple?O[key]=value:createNonEnumerableProperty(O,key,value)):simple?O[key]=value:setGlobal(key,value)})(Function.prototype,"toString",(function(){return"function"==typeof this&&getInternalState(this).source||inspectSource(this)}))})),path=global_1,aFunction=function(variable){return"function"==typeof variable?variable:void 0},getBuiltIn=function(namespace,method){return arguments.length<2?aFunction(path[namespace])||aFunction(global_1[namespace]):path[namespace]&&path[namespace][method]||global_1[namespace]&&global_1[namespace][method]},min=Math.min,toLength=function(argument){return argument>0?min(toInteger(argument),9007199254740991):0},max=Math.max,min$1=Math.min,toAbsoluteIndex=function(index,length){var integer=toInteger(index);return integer<0?max(integer+length,0):min$1(integer,length)},createMethod$1=function(IS_INCLUDES){return function($this,el,fromIndex){var value,O=toIndexedObject($this),length=toLength(O.length),index=toAbsoluteIndex(fromIndex,length);if(IS_INCLUDES&&el!=el){for(;length>index;)if((value=O[index++])!=value)return!0}else for(;length>index;index++)if((IS_INCLUDES||index in O)&&O[index]===el)return IS_INCLUDES||index||0;return!IS_INCLUDES&&-1}},arrayIncludes={includes:createMethod$1(!0),indexOf:createMethod$1(!1)},indexOf=arrayIncludes.indexOf,objectKeysInternal=function(object,names){var key,O=toIndexedObject(object),i=0,result=[];for(key in O)!has(hiddenKeys,key)&&has(O,key)&&result.push(key);for(;names.length>i;)has(O,key=names[i++])&&(~indexOf(result,key)||result.push(key));return result},enumBugKeys=["constructor","hasOwnProperty","isPrototypeOf","propertyIsEnumerable","toLocaleString","toString","valueOf"],hiddenKeys$1=enumBugKeys.concat("length","prototype"),f$3=Object.getOwnPropertyNames||function(O){return objectKeysInternal(O,hiddenKeys$1)},objectGetOwnPropertyNames={f:f$3},f$4=Object.getOwnPropertySymbols,objectGetOwnPropertySymbols={f:f$4},ownKeys$1=getBuiltIn("Reflect","ownKeys")||function(it){var keys=objectGetOwnPropertyNames.f(anObject(it)),getOwnPropertySymbols=objectGetOwnPropertySymbols.f;return getOwnPropertySymbols?keys.concat(getOwnPropertySymbols(it)):keys},copyConstructorProperties=function(target,source){for(var keys=ownKeys$1(source),defineProperty=objectDefineProperty.f,getOwnPropertyDescriptor=objectGetOwnPropertyDescriptor.f,i=0;iindex;)objectDefineProperty.f(O,key=keys[index++],Properties[key]);return O},html=getBuiltIn("document","documentElement"),GT=">",LT="<",PROTOTYPE="prototype",SCRIPT="script",IE_PROTO$1=sharedKey("IE_PROTO"),EmptyConstructor=function(){},scriptTag=function(content){return LT+SCRIPT+GT+content+LT+"/"+SCRIPT+GT},NullProtoObjectViaActiveX=function(activeXDocument){activeXDocument.write(scriptTag("")),activeXDocument.close();var temp=activeXDocument.parentWindow.Object;return activeXDocument=null,temp},NullProtoObjectViaIFrame=function(){var iframeDocument,iframe=documentCreateElement("iframe"),JS="java"+SCRIPT+":";return iframe.style.display="none",html.appendChild(iframe),iframe.src=String(JS),(iframeDocument=iframe.contentWindow.document).open(),iframeDocument.write(scriptTag("document.F=Object")),iframeDocument.close(),iframeDocument.F},activeXDocument,NullProtoObject=function(){try{activeXDocument=document.domain&&new ActiveXObject("htmlfile")}catch(error){}NullProtoObject=activeXDocument?NullProtoObjectViaActiveX(activeXDocument):NullProtoObjectViaIFrame();for(var length=enumBugKeys.length;length--;)delete NullProtoObject[PROTOTYPE][enumBugKeys[length]];return NullProtoObject()};hiddenKeys[IE_PROTO$1]=!0;var objectCreate=Object.create||function(O,Properties){var result;return null!==O?(EmptyConstructor[PROTOTYPE]=anObject(O),result=new EmptyConstructor,EmptyConstructor[PROTOTYPE]=null,result[IE_PROTO$1]=O):result=NullProtoObject(),void 0===Properties?result:objectDefineProperties(result,Properties)},defineProperty=objectDefineProperty.f,TO_STRING_TAG=wellKnownSymbol("toStringTag"),setToStringTag=function(it,TAG,STATIC){it&&!has(it=STATIC?it:it.prototype,TO_STRING_TAG)&&defineProperty(it,TO_STRING_TAG,{configurable:!0,value:TAG})},iterators={},IteratorPrototype$1=iteratorsCore.IteratorPrototype,returnThis$1=function(){return this},createIteratorConstructor=function(IteratorConstructor,NAME,next){var TO_STRING_TAG=NAME+" Iterator";return IteratorConstructor.prototype=objectCreate(IteratorPrototype$1,{next:createPropertyDescriptor(1,next)}),setToStringTag(IteratorConstructor,TO_STRING_TAG,!1),iterators[TO_STRING_TAG]=returnThis$1,IteratorConstructor},aPossiblePrototype=function(it){if(!isObject(it)&&null!==it)throw TypeError("Can't set "+String(it)+" as a prototype");return it},objectSetPrototypeOf=Object.setPrototypeOf||("__proto__"in{}?function(){var setter,CORRECT_SETTER=!1,test={};try{(setter=Object.getOwnPropertyDescriptor(Object.prototype,"__proto__").set).call(test,[]),CORRECT_SETTER=test instanceof Array}catch(error){}return function(O,proto){return anObject(O),aPossiblePrototype(proto),CORRECT_SETTER?setter.call(O,proto):O.__proto__=proto,O}}():void 0),IteratorPrototype$2=iteratorsCore.IteratorPrototype,BUGGY_SAFARI_ITERATORS$1=iteratorsCore.BUGGY_SAFARI_ITERATORS,ITERATOR$1=wellKnownSymbol("iterator"),KEYS="keys",VALUES="values",ENTRIES="entries",returnThis$2=function(){return this},defineIterator=function(Iterable,NAME,IteratorConstructor,next,DEFAULT,IS_SET,FORCED){createIteratorConstructor(IteratorConstructor,NAME,next);var CurrentIteratorPrototype,methods,KEY,getIterationMethod=function(KIND){if(KIND===DEFAULT&&defaultIterator)return defaultIterator;if(!BUGGY_SAFARI_ITERATORS$1&&KIND in IterablePrototype)return IterablePrototype[KIND];switch(KIND){case KEYS:case VALUES:case ENTRIES:return function(){return new IteratorConstructor(this,KIND)}}return function(){return new IteratorConstructor(this)}},TO_STRING_TAG=NAME+" Iterator",INCORRECT_VALUES_NAME=!1,IterablePrototype=Iterable.prototype,nativeIterator=IterablePrototype[ITERATOR$1]||IterablePrototype["@@iterator"]||DEFAULT&&IterablePrototype[DEFAULT],defaultIterator=!BUGGY_SAFARI_ITERATORS$1&&nativeIterator||getIterationMethod(DEFAULT),anyNativeIterator="Array"==NAME&&IterablePrototype.entries||nativeIterator;if(anyNativeIterator&&(CurrentIteratorPrototype=objectGetPrototypeOf(anyNativeIterator.call(new Iterable)),IteratorPrototype$2!==Object.prototype&&CurrentIteratorPrototype.next&&(objectGetPrototypeOf(CurrentIteratorPrototype)!==IteratorPrototype$2&&(objectSetPrototypeOf?objectSetPrototypeOf(CurrentIteratorPrototype,IteratorPrototype$2):"function"!=typeof CurrentIteratorPrototype[ITERATOR$1]&&createNonEnumerableProperty(CurrentIteratorPrototype,ITERATOR$1,returnThis$2)),setToStringTag(CurrentIteratorPrototype,TO_STRING_TAG,!0))),DEFAULT==VALUES&&nativeIterator&&nativeIterator.name!==VALUES&&(INCORRECT_VALUES_NAME=!0,defaultIterator=function(){return nativeIterator.call(this)}),IterablePrototype[ITERATOR$1]!==defaultIterator&&createNonEnumerableProperty(IterablePrototype,ITERATOR$1,defaultIterator),iterators[NAME]=defaultIterator,DEFAULT)if(methods={values:getIterationMethod(VALUES),keys:IS_SET?defaultIterator:getIterationMethod(KEYS),entries:getIterationMethod(ENTRIES)},FORCED)for(KEY in methods)(BUGGY_SAFARI_ITERATORS$1||INCORRECT_VALUES_NAME||!(KEY in IterablePrototype))&&redefine(IterablePrototype,KEY,methods[KEY]);else _export({target:NAME,proto:!0,forced:BUGGY_SAFARI_ITERATORS$1||INCORRECT_VALUES_NAME},methods);return methods},charAt=stringMultibyte.charAt,STRING_ITERATOR="String Iterator",setInternalState=internalState.set,getInternalState=internalState.getterFor(STRING_ITERATOR);defineIterator(String,"String",(function(iterated){setInternalState(this,{type:STRING_ITERATOR,string:String(iterated),index:0})}),(function(){var point,state=getInternalState(this),string=state.string,index=state.index;return index>=string.length?{value:void 0,done:!0}:(point=charAt(string,index),state.index+=point.length,{value:point,done:!1})}));var aFunction$1=function(it){if("function"!=typeof it)throw TypeError(String(it)+" is not a function");return it},functionBindContext=function(fn,that,length){if(aFunction$1(fn),void 0===that)return fn;switch(length){case 0:return function(){return fn.call(that)};case 1:return function(a){return fn.call(that,a)};case 2:return function(a,b){return fn.call(that,a,b)};case 3:return function(a,b,c){return fn.call(that,a,b,c)}}return function(){return fn.apply(that,arguments)}},callWithSafeIterationClosing=function(iterator,fn,value,ENTRIES){try{return ENTRIES?fn(anObject(value)[0],value[1]):fn(value)}catch(error){var returnMethod=iterator.return;throw void 0!==returnMethod&&anObject(returnMethod.call(iterator)),error}},ITERATOR$2=wellKnownSymbol("iterator"),ArrayPrototype=Array.prototype,isArrayIteratorMethod=function(it){return void 0!==it&&(iterators.Array===it||ArrayPrototype[ITERATOR$2]===it)},createProperty=function(object,key,value){var propertyKey=toPrimitive(key);propertyKey in object?objectDefineProperty.f(object,propertyKey,createPropertyDescriptor(0,value)):object[propertyKey]=value},TO_STRING_TAG$1=wellKnownSymbol("toStringTag"),test={};test[TO_STRING_TAG$1]="z";var toStringTagSupport="[object z]"===String(test),TO_STRING_TAG$2=wellKnownSymbol("toStringTag"),CORRECT_ARGUMENTS="Arguments"==classofRaw(function(){return arguments}()),tryGet=function(it,key){try{return it[key]}catch(error){}},classof=toStringTagSupport?classofRaw:function(it){var O,tag,result;return void 0===it?"Undefined":null===it?"Null":"string"==typeof(tag=tryGet(O=Object(it),TO_STRING_TAG$2))?tag:CORRECT_ARGUMENTS?classofRaw(O):"Object"==(result=classofRaw(O))&&"function"==typeof O.callee?"Arguments":result},ITERATOR$3=wellKnownSymbol("iterator"),getIteratorMethod=function(it){if(null!=it)return it[ITERATOR$3]||it["@@iterator"]||iterators[classof(it)]},arrayFrom=function(arrayLike){var length,result,step,iterator,next,value,O=toObject(arrayLike),C="function"==typeof this?this:Array,argumentsLength=arguments.length,mapfn=argumentsLength>1?arguments[1]:void 0,mapping=void 0!==mapfn,iteratorMethod=getIteratorMethod(O),index=0;if(mapping&&(mapfn=functionBindContext(mapfn,argumentsLength>2?arguments[2]:void 0,2)),null==iteratorMethod||C==Array&&isArrayIteratorMethod(iteratorMethod))for(result=new C(length=toLength(O.length));length>index;index++)value=mapping?mapfn(O[index],index):O[index],createProperty(result,index,value);else for(next=(iterator=iteratorMethod.call(O)).next,result=new C;!(step=next.call(iterator)).done;index++)value=mapping?callWithSafeIterationClosing(iterator,mapfn,[step.value,index],!0):step.value,createProperty(result,index,value);return result.length=index,result},ITERATOR$4=wellKnownSymbol("iterator"),SAFE_CLOSING=!1;try{var called=0,iteratorWithReturn={next:function(){return{done:!!called++}},return:function(){SAFE_CLOSING=!0}};iteratorWithReturn[ITERATOR$4]=function(){return this},Array.from(iteratorWithReturn,(function(){throw 2}))}catch(error){}var checkCorrectnessOfIteration=function(exec,SKIP_CLOSING){if(!SKIP_CLOSING&&!SAFE_CLOSING)return!1;var ITERATION_SUPPORT=!1;try{var object={};object[ITERATOR$4]=function(){return{next:function(){return{done:ITERATION_SUPPORT=!0}}}},exec(object)}catch(error){}return ITERATION_SUPPORT},INCORRECT_ITERATION=!checkCorrectnessOfIteration((function(iterable){Array.from(iterable)}));_export({target:"Array",stat:!0,forced:INCORRECT_ITERATION},{from:arrayFrom});var from_1=path.Array.from,UNSCOPABLES=wellKnownSymbol("unscopables"),ArrayPrototype$1=Array.prototype;null==ArrayPrototype$1[UNSCOPABLES]&&objectDefineProperty.f(ArrayPrototype$1,UNSCOPABLES,{configurable:!0,value:objectCreate(null)});var addToUnscopables=function(key){ArrayPrototype$1[UNSCOPABLES][key]=!0},defineProperty$1=Object.defineProperty,cache={},thrower=function(it){throw it},arrayMethodUsesToLength=function(METHOD_NAME,options){if(has(cache,METHOD_NAME))return cache[METHOD_NAME];options||(options={});var method=[][METHOD_NAME],ACCESSORS=!!has(options,"ACCESSORS")&&options.ACCESSORS,argument0=has(options,0)?options[0]:thrower,argument1=has(options,1)?options[1]:void 0;return cache[METHOD_NAME]=!!method&&!fails((function(){if(ACCESSORS&&!descriptors)return!0;var O={length:-1};ACCESSORS?defineProperty$1(O,1,{enumerable:!0,get:thrower}):O[1]=1,method.call(O,argument0,argument1)}))},$includes=arrayIncludes.includes,USES_TO_LENGTH=arrayMethodUsesToLength("indexOf",{ACCESSORS:!0,1:0});_export({target:"Array",proto:!0,forced:!USES_TO_LENGTH},{includes:function(el){return $includes(this,el,arguments.length>1?arguments[1]:void 0)}}),addToUnscopables("includes");var call=Function.call,entryUnbind=function(CONSTRUCTOR,METHOD,length){return functionBindContext(call,global_1[CONSTRUCTOR].prototype[METHOD],length)},includes=entryUnbind("Array","includes"),isArray=Array.isArray||function(arg){return"Array"==classofRaw(arg)},flattenIntoArray=function(target,original,source,sourceLen,start,depth,mapper,thisArg){for(var element,targetIndex=start,sourceIndex=0,mapFn=!!mapper&&functionBindContext(mapper,thisArg,3);sourceIndex0&&isArray(element))targetIndex=flattenIntoArray(target,original,element,toLength(element.length),targetIndex,depth-1)-1;else{if(targetIndex>=9007199254740991)throw TypeError("Exceed the acceptable array length");target[targetIndex]=element}targetIndex++}sourceIndex++}return targetIndex},flattenIntoArray_1=flattenIntoArray,SPECIES=wellKnownSymbol("species"),arraySpeciesCreate=function(originalArray,length){var C;return isArray(originalArray)&&("function"!=typeof(C=originalArray.constructor)||C!==Array&&!isArray(C.prototype)?isObject(C)&&null===(C=C[SPECIES])&&(C=void 0):C=void 0),new(void 0===C?Array:C)(0===length?0:length)};_export({target:"Array",proto:!0},{flat:function(){var depthArg=arguments.length?arguments[0]:void 0,O=toObject(this),sourceLen=toLength(O.length),A=arraySpeciesCreate(O,0);return A.length=flattenIntoArray_1(A,O,O,sourceLen,0,void 0===depthArg?1:toInteger(depthArg)),A}}),addToUnscopables("flat");var flat=entryUnbind("Array","flat"),push=[].push,createMethod$2=function(TYPE){var IS_MAP=1==TYPE,IS_FILTER=2==TYPE,IS_SOME=3==TYPE,IS_EVERY=4==TYPE,IS_FIND_INDEX=6==TYPE,NO_HOLES=5==TYPE||IS_FIND_INDEX;return function($this,callbackfn,that,specificCreate){for(var value,result,O=toObject($this),self=indexedObject(O),boundFunction=functionBindContext(callbackfn,that,3),length=toLength(self.length),index=0,create=specificCreate||arraySpeciesCreate,target=IS_MAP?create($this,length):IS_FILTER?create($this,0):void 0;length>index;index++)if((NO_HOLES||index in self)&&(result=boundFunction(value=self[index],index,O),TYPE))if(IS_MAP)target[index]=result;else if(result)switch(TYPE){case 3:return!0;case 5:return value;case 6:return index;case 2:push.call(target,value)}else if(IS_EVERY)return!1;return IS_FIND_INDEX?-1:IS_SOME||IS_EVERY?IS_EVERY:target}},arrayIteration={forEach:createMethod$2(0),map:createMethod$2(1),filter:createMethod$2(2),some:createMethod$2(3),every:createMethod$2(4),find:createMethod$2(5),findIndex:createMethod$2(6)},$find=arrayIteration.find,FIND="find",SKIPS_HOLES=!0,USES_TO_LENGTH$1=arrayMethodUsesToLength(FIND);FIND in[]&&Array(1)[FIND]((function(){SKIPS_HOLES=!1})),_export({target:"Array",proto:!0,forced:SKIPS_HOLES||!USES_TO_LENGTH$1},{find:function(callbackfn){return $find(this,callbackfn,arguments.length>1?arguments[1]:void 0)}}),addToUnscopables(FIND);var find=entryUnbind("Array","find"),nativeAssign=Object.assign,defineProperty$2=Object.defineProperty,objectAssign=!nativeAssign||fails((function(){if(descriptors&&1!==nativeAssign({b:1},nativeAssign(defineProperty$2({},"a",{enumerable:!0,get:function(){defineProperty$2(this,"b",{value:3,enumerable:!1})}}),{b:2})).b)return!0;var A={},B={},symbol=Symbol();return A[symbol]=7,"abcdefghijklmnopqrst".split("").forEach((function(chr){B[chr]=chr})),7!=nativeAssign({},A)[symbol]||"abcdefghijklmnopqrst"!=objectKeys(nativeAssign({},B)).join("")}))?function(target,source){for(var T=toObject(target),argumentsLength=arguments.length,index=1,getOwnPropertySymbols=objectGetOwnPropertySymbols.f,propertyIsEnumerable=objectPropertyIsEnumerable.f;argumentsLength>index;)for(var key,S=indexedObject(arguments[index++]),keys=getOwnPropertySymbols?objectKeys(S).concat(getOwnPropertySymbols(S)):objectKeys(S),length=keys.length,j=0;length>j;)key=keys[j++],descriptors&&!propertyIsEnumerable.call(S,key)||(T[key]=S[key]);return T}:nativeAssign;_export({target:"Object",stat:!0,forced:Object.assign!==objectAssign},{assign:objectAssign});var assign=path.Object.assign,propertyIsEnumerable=objectPropertyIsEnumerable.f,createMethod$3=function(TO_ENTRIES){return function(it){for(var key,O=toIndexedObject(it),keys=objectKeys(O),length=keys.length,i=0,result=[];length>i;)key=keys[i++],descriptors&&!propertyIsEnumerable.call(O,key)||result.push(TO_ENTRIES?[key,O[key]]:O[key]);return result}},objectToArray={entries:createMethod$3(!0),values:createMethod$3(!1)},$entries=objectToArray.entries;_export({target:"Object",stat:!0},{entries:function(O){return $entries(O)}});var entries=path.Object.entries,$values=objectToArray.values;_export({target:"Object",stat:!0},{values:function(O){return $values(O)}});var values=path.Object.values,objectToString=toStringTagSupport?{}.toString:function(){return"[object "+classof(this)+"]"};toStringTagSupport||redefine(Object.prototype,"toString",objectToString,{unsafe:!0});var domIterables={CSSRuleList:0,CSSStyleDeclaration:0,CSSValueList:0,ClientRectList:0,DOMRectList:0,DOMStringList:0,DOMTokenList:1,DataTransferItemList:0,FileList:0,HTMLAllCollection:0,HTMLCollection:0,HTMLFormElement:0,HTMLSelectElement:0,MediaList:0,MimeTypeArray:0,NamedNodeMap:0,NodeList:1,PaintRequestList:0,Plugin:0,PluginArray:0,SVGLengthList:0,SVGNumberList:0,SVGPathSegList:0,SVGPointList:0,SVGStringList:0,SVGTransformList:0,SourceBufferList:0,StyleSheetList:0,TextTrackCueList:0,TextTrackList:0,TouchList:0},ARRAY_ITERATOR="Array Iterator",setInternalState$1=internalState.set,getInternalState$1=internalState.getterFor(ARRAY_ITERATOR),es_array_iterator=defineIterator(Array,"Array",(function(iterated,kind){setInternalState$1(this,{type:ARRAY_ITERATOR,target:toIndexedObject(iterated),index:0,kind:kind})}),(function(){var state=getInternalState$1(this),target=state.target,kind=state.kind,index=state.index++;return!target||index>=target.length?(state.target=void 0,{value:void 0,done:!0}):"keys"==kind?{value:index,done:!1}:"values"==kind?{value:target[index],done:!1}:{value:[index,target[index]],done:!1}}),"values");iterators.Arguments=iterators.Array,addToUnscopables("keys"),addToUnscopables("values"),addToUnscopables("entries");var ITERATOR$5=wellKnownSymbol("iterator"),TO_STRING_TAG$3=wellKnownSymbol("toStringTag"),ArrayValues=es_array_iterator.values;for(var COLLECTION_NAME in domIterables){var Collection=global_1[COLLECTION_NAME],CollectionPrototype=Collection&&Collection.prototype;if(CollectionPrototype){if(CollectionPrototype[ITERATOR$5]!==ArrayValues)try{createNonEnumerableProperty(CollectionPrototype,ITERATOR$5,ArrayValues)}catch(error){CollectionPrototype[ITERATOR$5]=ArrayValues}if(CollectionPrototype[TO_STRING_TAG$3]||createNonEnumerableProperty(CollectionPrototype,TO_STRING_TAG$3,COLLECTION_NAME),domIterables[COLLECTION_NAME])for(var METHOD_NAME in es_array_iterator)if(CollectionPrototype[METHOD_NAME]!==es_array_iterator[METHOD_NAME])try{createNonEnumerableProperty(CollectionPrototype,METHOD_NAME,es_array_iterator[METHOD_NAME])}catch(error){CollectionPrototype[METHOD_NAME]=es_array_iterator[METHOD_NAME]}}}var nativePromiseConstructor=global_1.Promise,redefineAll=function(target,src,options){for(var key in src)redefine(target,key,src[key],options);return target},SPECIES$1=wellKnownSymbol("species"),setSpecies=function(CONSTRUCTOR_NAME){var Constructor=getBuiltIn(CONSTRUCTOR_NAME),defineProperty=objectDefineProperty.f;descriptors&&Constructor&&!Constructor[SPECIES$1]&&defineProperty(Constructor,SPECIES$1,{configurable:!0,get:function(){return this}})},anInstance=function(it,Constructor,name){if(!(it instanceof Constructor))throw TypeError("Incorrect "+(name?name+" ":"")+"invocation");return it},iterate_1=createCommonjsModule((function(module){var Result=function(stopped,result){this.stopped=stopped,this.result=result};(module.exports=function(iterable,fn,that,AS_ENTRIES,IS_ITERATOR){var iterator,iterFn,index,length,result,next,step,boundFunction=functionBindContext(fn,that,AS_ENTRIES?2:1);if(IS_ITERATOR)iterator=iterable;else{if("function"!=typeof(iterFn=getIteratorMethod(iterable)))throw TypeError("Target is not iterable");if(isArrayIteratorMethod(iterFn)){for(index=0,length=toLength(iterable.length);length>index;index++)if((result=AS_ENTRIES?boundFunction(anObject(step=iterable[index])[0],step[1]):boundFunction(iterable[index]))&&result instanceof Result)return result;return new Result(!1)}iterator=iterFn.call(iterable)}for(next=iterator.next;!(step=next.call(iterator)).done;)if("object"==typeof(result=callWithSafeIterationClosing(iterator,boundFunction,step.value,AS_ENTRIES))&&result&&result instanceof Result)return result;return new Result(!1)}).stop=function(result){return new Result(!0,result)}})),SPECIES$2=wellKnownSymbol("species"),speciesConstructor=function(O,defaultConstructor){var S,C=anObject(O).constructor;return void 0===C||null==(S=anObject(C)[SPECIES$2])?defaultConstructor:aFunction$1(S)},engineUserAgent=getBuiltIn("navigator","userAgent")||"",engineIsIos=/(iphone|ipod|ipad).*applewebkit/i.test(engineUserAgent),location=global_1.location,set$1=global_1.setImmediate,clear=global_1.clearImmediate,process=global_1.process,MessageChannel=global_1.MessageChannel,Dispatch=global_1.Dispatch,counter=0,queue={},ONREADYSTATECHANGE="onreadystatechange",defer,channel,port,run=function(id){if(queue.hasOwnProperty(id)){var fn=queue[id];delete queue[id],fn()}},runner=function(id){return function(){run(id)}},listener=function(event){run(event.data)},post=function(id){global_1.postMessage(id+"",location.protocol+"//"+location.host)};set$1&&clear||(set$1=function(fn){for(var args=[],i=1;arguments.length>i;)args.push(arguments[i++]);return queue[++counter]=function(){("function"==typeof fn?fn:Function(fn)).apply(void 0,args)},defer(counter),counter},clear=function(id){delete queue[id]},"process"==classofRaw(process)?defer=function(id){process.nextTick(runner(id))}:Dispatch&&Dispatch.now?defer=function(id){Dispatch.now(runner(id))}:MessageChannel&&!engineIsIos?(channel=new MessageChannel,port=channel.port2,channel.port1.onmessage=listener,defer=functionBindContext(port.postMessage,port,1)):!global_1.addEventListener||"function"!=typeof postMessage||global_1.importScripts||fails(post)||"file:"===location.protocol?defer=ONREADYSTATECHANGE in documentCreateElement("script")?function(id){html.appendChild(documentCreateElement("script"))[ONREADYSTATECHANGE]=function(){html.removeChild(this),run(id)}}:function(id){setTimeout(runner(id),0)}:(defer=post,global_1.addEventListener("message",listener,!1)));var task={set:set$1,clear:clear},getOwnPropertyDescriptor$2=objectGetOwnPropertyDescriptor.f,macrotask=task.set,MutationObserver=global_1.MutationObserver||global_1.WebKitMutationObserver,process$1=global_1.process,Promise$1=global_1.Promise,IS_NODE="process"==classofRaw(process$1),queueMicrotaskDescriptor=getOwnPropertyDescriptor$2(global_1,"queueMicrotask"),queueMicrotask=queueMicrotaskDescriptor&&queueMicrotaskDescriptor.value,flush,head,last,notify,toggle,node,promise,then;queueMicrotask||(flush=function(){var parent,fn;for(IS_NODE&&(parent=process$1.domain)&&parent.exit();head;){fn=head.fn,head=head.next;try{fn()}catch(error){throw head?notify():last=void 0,error}}last=void 0,parent&&parent.enter()},IS_NODE?notify=function(){process$1.nextTick(flush)}:MutationObserver&&!engineIsIos?(toggle=!0,node=document.createTextNode(""),new MutationObserver(flush).observe(node,{characterData:!0}),notify=function(){node.data=toggle=!toggle}):Promise$1&&Promise$1.resolve?(promise=Promise$1.resolve(void 0),then=promise.then,notify=function(){then.call(promise,flush)}):notify=function(){macrotask.call(global_1,flush)});var microtask=queueMicrotask||function(fn){var task={fn:fn,next:void 0};last&&(last.next=task),head||(head=task,notify()),last=task},PromiseCapability=function(C){var resolve,reject;this.promise=new C((function($$resolve,$$reject){if(void 0!==resolve||void 0!==reject)throw TypeError("Bad Promise constructor");resolve=$$resolve,reject=$$reject})),this.resolve=aFunction$1(resolve),this.reject=aFunction$1(reject)},f$5=function(C){return new PromiseCapability(C)},newPromiseCapability={f:f$5},promiseResolve=function(C,x){if(anObject(C),isObject(x)&&x.constructor===C)return x;var promiseCapability=newPromiseCapability.f(C);return(0,promiseCapability.resolve)(x),promiseCapability.promise},hostReportErrors=function(a,b){var console=global_1.console;console&&console.error&&(1===arguments.length?console.error(a):console.error(a,b))},perform=function(exec){try{return{error:!1,value:exec()}}catch(error){return{error:!0,value:error}}},process$2=global_1.process,versions=process$2&&process$2.versions,v8=versions&&versions.v8,match,version;v8?(match=v8.split("."),version=match[0]+match[1]):engineUserAgent&&(match=engineUserAgent.match(/Edge\/(\d+)/),(!match||match[1]>=74)&&(match=engineUserAgent.match(/Chrome\/(\d+)/),match&&(version=match[1])));var engineV8Version=version&&+version,task$1=task.set,SPECIES$3=wellKnownSymbol("species"),PROMISE="Promise",getInternalState$2=internalState.get,setInternalState$2=internalState.set,getInternalPromiseState=internalState.getterFor(PROMISE),PromiseConstructor=nativePromiseConstructor,TypeError$1=global_1.TypeError,document$2=global_1.document,process$3=global_1.process,$fetch=getBuiltIn("fetch"),newPromiseCapability$1=newPromiseCapability.f,newGenericPromiseCapability=newPromiseCapability$1,IS_NODE$1="process"==classofRaw(process$3),DISPATCH_EVENT=!!(document$2&&document$2.createEvent&&global_1.dispatchEvent),UNHANDLED_REJECTION="unhandledrejection",REJECTION_HANDLED="rejectionhandled",PENDING=0,FULFILLED=1,REJECTED=2,HANDLED=1,UNHANDLED=2,Internal,OwnPromiseCapability,PromiseWrapper,nativeThen,FORCED=isForced_1(PROMISE,(function(){if(!(inspectSource(PromiseConstructor)!==String(PromiseConstructor))){if(66===engineV8Version)return!0;if(!IS_NODE$1&&"function"!=typeof PromiseRejectionEvent)return!0}if(engineV8Version>=51&&/native code/.test(PromiseConstructor))return!1;var promise=PromiseConstructor.resolve(1),FakePromise=function(exec){exec((function(){}),(function(){}))};return(promise.constructor={})[SPECIES$3]=FakePromise,!(promise.then((function(){}))instanceof FakePromise)})),INCORRECT_ITERATION$1=FORCED||!checkCorrectnessOfIteration((function(iterable){PromiseConstructor.all(iterable).catch((function(){}))})),isThenable=function(it){var then;return!(!isObject(it)||"function"!=typeof(then=it.then))&&then},notify$1=function(promise,state,isReject){if(!state.notified){state.notified=!0;var chain=state.reactions;microtask((function(){for(var value=state.value,ok=state.state==FULFILLED,index=0;chain.length>index;){var result,then,exited,reaction=chain[index++],handler=ok?reaction.ok:reaction.fail,resolve=reaction.resolve,reject=reaction.reject,domain=reaction.domain;try{handler?(ok||(state.rejection===UNHANDLED&&onHandleUnhandled(promise,state),state.rejection=HANDLED),!0===handler?result=value:(domain&&domain.enter(),result=handler(value),domain&&(domain.exit(),exited=!0)),result===reaction.promise?reject(TypeError$1("Promise-chain cycle")):(then=isThenable(result))?then.call(result,resolve,reject):resolve(result)):reject(value)}catch(error){domain&&!exited&&domain.exit(),reject(error)}}state.reactions=[],state.notified=!1,isReject&&!state.rejection&&onUnhandled(promise,state)}))}},dispatchEvent=function(name,promise,reason){var event,handler;DISPATCH_EVENT?((event=document$2.createEvent("Event")).promise=promise,event.reason=reason,event.initEvent(name,!1,!0),global_1.dispatchEvent(event)):event={promise:promise,reason:reason},(handler=global_1["on"+name])?handler(event):name===UNHANDLED_REJECTION&&hostReportErrors("Unhandled promise rejection",reason)},onUnhandled=function(promise,state){task$1.call(global_1,(function(){var result,value=state.value;if(isUnhandled(state)&&(result=perform((function(){IS_NODE$1?process$3.emit("unhandledRejection",value,promise):dispatchEvent(UNHANDLED_REJECTION,promise,value)})),state.rejection=IS_NODE$1||isUnhandled(state)?UNHANDLED:HANDLED,result.error))throw result.value}))},isUnhandled=function(state){return state.rejection!==HANDLED&&!state.parent},onHandleUnhandled=function(promise,state){task$1.call(global_1,(function(){IS_NODE$1?process$3.emit("rejectionHandled",promise):dispatchEvent(REJECTION_HANDLED,promise,state.value)}))},bind=function(fn,promise,state,unwrap){return function(value){fn(promise,state,value,unwrap)}},internalReject=function(promise,state,value,unwrap){state.done||(state.done=!0,unwrap&&(state=unwrap),state.value=value,state.state=REJECTED,notify$1(promise,state,!0))},internalResolve=function(promise,state,value,unwrap){if(!state.done){state.done=!0,unwrap&&(state=unwrap);try{if(promise===value)throw TypeError$1("Promise can't be resolved itself");var then=isThenable(value);then?microtask((function(){var wrapper={done:!1};try{then.call(value,bind(internalResolve,promise,wrapper,state),bind(internalReject,promise,wrapper,state))}catch(error){internalReject(promise,wrapper,error,state)}})):(state.value=value,state.state=FULFILLED,notify$1(promise,state,!1))}catch(error){internalReject(promise,{done:!1},error,state)}}};FORCED&&(PromiseConstructor=function(executor){anInstance(this,PromiseConstructor,PROMISE),aFunction$1(executor),Internal.call(this);var state=getInternalState$2(this);try{executor(bind(internalResolve,this,state),bind(internalReject,this,state))}catch(error){internalReject(this,state,error)}},Internal=function(executor){setInternalState$2(this,{type:PROMISE,done:!1,notified:!1,parent:!1,reactions:[],rejection:!1,state:PENDING,value:void 0})},Internal.prototype=redefineAll(PromiseConstructor.prototype,{then:function(onFulfilled,onRejected){var state=getInternalPromiseState(this),reaction=newPromiseCapability$1(speciesConstructor(this,PromiseConstructor));return reaction.ok="function"!=typeof onFulfilled||onFulfilled,reaction.fail="function"==typeof onRejected&&onRejected,reaction.domain=IS_NODE$1?process$3.domain:void 0,state.parent=!0,state.reactions.push(reaction),state.state!=PENDING&¬ify$1(this,state,!1),reaction.promise},catch:function(onRejected){return this.then(void 0,onRejected)}}),OwnPromiseCapability=function(){var promise=new Internal,state=getInternalState$2(promise);this.promise=promise,this.resolve=bind(internalResolve,promise,state),this.reject=bind(internalReject,promise,state)},newPromiseCapability.f=newPromiseCapability$1=function(C){return C===PromiseConstructor||C===PromiseWrapper?new OwnPromiseCapability(C):newGenericPromiseCapability(C)},"function"==typeof nativePromiseConstructor&&(nativeThen=nativePromiseConstructor.prototype.then,redefine(nativePromiseConstructor.prototype,"then",(function(onFulfilled,onRejected){var that=this;return new PromiseConstructor((function(resolve,reject){nativeThen.call(that,resolve,reject)})).then(onFulfilled,onRejected)}),{unsafe:!0}),"function"==typeof $fetch&&_export({global:!0,enumerable:!0,forced:!0},{fetch:function(input){return promiseResolve(PromiseConstructor,$fetch.apply(global_1,arguments))}}))),_export({global:!0,wrap:!0,forced:FORCED},{Promise:PromiseConstructor}),setToStringTag(PromiseConstructor,PROMISE,!1),setSpecies(PROMISE),PromiseWrapper=getBuiltIn(PROMISE),_export({target:PROMISE,stat:!0,forced:FORCED},{reject:function(r){var capability=newPromiseCapability$1(this);return capability.reject.call(void 0,r),capability.promise}}),_export({target:PROMISE,stat:!0,forced:FORCED},{resolve:function(x){return promiseResolve(this,x)}}),_export({target:PROMISE,stat:!0,forced:INCORRECT_ITERATION$1},{all:function(iterable){var C=this,capability=newPromiseCapability$1(C),resolve=capability.resolve,reject=capability.reject,result=perform((function(){var $promiseResolve=aFunction$1(C.resolve),values=[],counter=0,remaining=1;iterate_1(iterable,(function(promise){var index=counter++,alreadyCalled=!1;values.push(void 0),remaining++,$promiseResolve.call(C,promise).then((function(value){alreadyCalled||(alreadyCalled=!0,values[index]=value,--remaining||resolve(values))}),reject)})),--remaining||resolve(values)}));return result.error&&reject(result.value),capability.promise},race:function(iterable){var C=this,capability=newPromiseCapability$1(C),reject=capability.reject,result=perform((function(){var $promiseResolve=aFunction$1(C.resolve);iterate_1(iterable,(function(promise){$promiseResolve.call(C,promise).then(capability.resolve,reject)}))}));return result.error&&reject(result.value),capability.promise}}),_export({target:"Promise",stat:!0},{allSettled:function(iterable){var C=this,capability=newPromiseCapability.f(C),resolve=capability.resolve,reject=capability.reject,result=perform((function(){var promiseResolve=aFunction$1(C.resolve),values=[],counter=0,remaining=1;iterate_1(iterable,(function(promise){var index=counter++,alreadyCalled=!1;values.push(void 0),remaining++,promiseResolve.call(C,promise).then((function(value){alreadyCalled||(alreadyCalled=!0,values[index]={status:"fulfilled",value:value},--remaining||resolve(values))}),(function(e){alreadyCalled||(alreadyCalled=!0,values[index]={status:"rejected",reason:e},--remaining||resolve(values))}))})),--remaining||resolve(values)}));return result.error&&reject(result.value),capability.promise}});var NON_GENERIC=!!nativePromiseConstructor&&fails((function(){nativePromiseConstructor.prototype.finally.call({then:function(){}},(function(){}))}));_export({target:"Promise",proto:!0,real:!0,forced:NON_GENERIC},{finally:function(onFinally){var C=speciesConstructor(this,getBuiltIn("Promise")),isFunction="function"==typeof onFinally;return this.then(isFunction?function(x){return promiseResolve(C,onFinally()).then((function(){return x}))}:onFinally,isFunction?function(e){return promiseResolve(C,onFinally()).then((function(){throw e}))}:onFinally)}}),"function"!=typeof nativePromiseConstructor||nativePromiseConstructor.prototype.finally||redefine(nativePromiseConstructor.prototype,"finally",getBuiltIn("Promise").prototype.finally);var promise$1=path.Promise,setInternalState$3=internalState.set,getInternalAggregateErrorState=internalState.getterFor("AggregateError"),$AggregateError=function(errors,message){var that=this;if(!(that instanceof $AggregateError))return new $AggregateError(errors,message);objectSetPrototypeOf&&(that=objectSetPrototypeOf(new Error(message),objectGetPrototypeOf(that)));var errorsArray=[];return iterate_1(errors,errorsArray.push,errorsArray),descriptors?setInternalState$3(that,{errors:errorsArray,type:"AggregateError"}):that.errors=errorsArray,void 0!==message&&createNonEnumerableProperty(that,"message",String(message)),that};$AggregateError.prototype=objectCreate(Error.prototype,{constructor:createPropertyDescriptor(5,$AggregateError),message:createPropertyDescriptor(5,""),name:createPropertyDescriptor(5,"AggregateError")}),descriptors&&objectDefineProperty.f($AggregateError.prototype,"errors",{get:function(){return getInternalAggregateErrorState(this).errors},configurable:!0}),_export({global:!0},{AggregateError:$AggregateError}),_export({target:"Promise",stat:!0},{try:function(callbackfn){var promiseCapability=newPromiseCapability.f(this),result=perform(callbackfn);return(result.error?promiseCapability.reject:promiseCapability.resolve)(result.value),promiseCapability.promise}});var PROMISE_ANY_ERROR="No one promise resolved";_export({target:"Promise",stat:!0},{any:function(iterable){var C=this,capability=newPromiseCapability.f(C),resolve=capability.resolve,reject=capability.reject,result=perform((function(){var promiseResolve=aFunction$1(C.resolve),errors=[],counter=0,remaining=1,alreadyResolved=!1;iterate_1(iterable,(function(promise){var index=counter++,alreadyRejected=!1;errors.push(void 0),remaining++,promiseResolve.call(C,promise).then((function(value){alreadyRejected||alreadyResolved||(alreadyResolved=!0,resolve(value))}),(function(e){alreadyRejected||alreadyResolved||(alreadyRejected=!0,errors[index]=e,--remaining||reject(new(getBuiltIn("AggregateError"))(errors,PROMISE_ANY_ERROR)))}))})),--remaining||reject(new(getBuiltIn("AggregateError"))(errors,PROMISE_ANY_ERROR))}));return result.error&&reject(result.value),capability.promise}});var MATCH=wellKnownSymbol("match"),isRegexp=function(it){var isRegExp;return isObject(it)&&(void 0!==(isRegExp=it[MATCH])?!!isRegExp:"RegExp"==classofRaw(it))},notARegexp=function(it){if(isRegexp(it))throw TypeError("The method doesn't accept regular expressions");return it},MATCH$1=wellKnownSymbol("match"),correctIsRegexpLogic=function(METHOD_NAME){var regexp=/./;try{"/./"[METHOD_NAME](regexp)}catch(e){try{return regexp[MATCH$1]=!1,"/./"[METHOD_NAME](regexp)}catch(f){}}return!1},getOwnPropertyDescriptor$3=objectGetOwnPropertyDescriptor.f,nativeStartsWith="".startsWith,min$2=Math.min,CORRECT_IS_REGEXP_LOGIC=correctIsRegexpLogic("startsWith"),MDN_POLYFILL_BUG=!(CORRECT_IS_REGEXP_LOGIC||(descriptor=getOwnPropertyDescriptor$3(String.prototype,"startsWith"),!descriptor||descriptor.writable)),descriptor;_export({target:"String",proto:!0,forced:!MDN_POLYFILL_BUG&&!CORRECT_IS_REGEXP_LOGIC},{startsWith:function(searchString){var that=String(requireObjectCoercible(this));notARegexp(searchString);var index=toLength(min$2(arguments.length>1?arguments[1]:void 0,that.length)),search=String(searchString);return nativeStartsWith?nativeStartsWith.call(that,search,index):that.slice(index,index+search.length)===search}});var startsWith=entryUnbind("String","startsWith"),global$1="undefined"!=typeof globalThis&&globalThis||"undefined"!=typeof self&&self||void 0!==global$1&&global$1,support={searchParams:"URLSearchParams"in global$1,iterable:"Symbol"in global$1&&"iterator"in Symbol,blob:"FileReader"in global$1&&"Blob"in global$1&&function(){try{return new Blob,!0}catch(e){return!1}}(),formData:"FormData"in global$1,arrayBuffer:"ArrayBuffer"in global$1};function isDataView(obj){return obj&&DataView.prototype.isPrototypeOf(obj)}if(support.arrayBuffer)var viewClasses=["[object Int8Array]","[object Uint8Array]","[object Uint8ClampedArray]","[object Int16Array]","[object Uint16Array]","[object Int32Array]","[object Uint32Array]","[object Float32Array]","[object Float64Array]"],isArrayBufferView=ArrayBuffer.isView||function(obj){return obj&&viewClasses.indexOf(Object.prototype.toString.call(obj))>-1};function normalizeName(name){if("string"!=typeof name&&(name=String(name)),/[^a-z0-9\-#$%&'*+.^_`|~!]/i.test(name)||""===name)throw new TypeError("Invalid character in header field name");return name.toLowerCase()}function normalizeValue(value){return"string"!=typeof value&&(value=String(value)),value}function iteratorFor(items){var iterator={next:function(){var value=items.shift();return{done:void 0===value,value:value}}};return support.iterable&&(iterator[Symbol.iterator]=function(){return iterator}),iterator}function Headers(headers){this.map={},headers instanceof Headers?headers.forEach((function(value,name){this.append(name,value)}),this):Array.isArray(headers)?headers.forEach((function(header){this.append(header[0],header[1])}),this):headers&&Object.getOwnPropertyNames(headers).forEach((function(name){this.append(name,headers[name])}),this)}function consumed(body){if(body.bodyUsed)return Promise.reject(new TypeError("Already read"));body.bodyUsed=!0}function fileReaderReady(reader){return new Promise((function(resolve,reject){reader.onload=function(){resolve(reader.result)},reader.onerror=function(){reject(reader.error)}}))}function readBlobAsArrayBuffer(blob){var reader=new FileReader,promise=fileReaderReady(reader);return reader.readAsArrayBuffer(blob),promise}function readBlobAsText(blob){var reader=new FileReader,promise=fileReaderReady(reader);return reader.readAsText(blob),promise}function readArrayBufferAsText(buf){for(var view=new Uint8Array(buf),chars=new Array(view.length),i=0;i-1?upcased:method}function Request(input,options){if(!(this instanceof Request))throw new TypeError('Please use the "new" operator, this DOM object constructor cannot be called as a function.');var body=(options=options||{}).body;if(input instanceof Request){if(input.bodyUsed)throw new TypeError("Already read");this.url=input.url,this.credentials=input.credentials,options.headers||(this.headers=new Headers(input.headers)),this.method=input.method,this.mode=input.mode,this.signal=input.signal,body||null==input._bodyInit||(body=input._bodyInit,input.bodyUsed=!0)}else this.url=String(input);if(this.credentials=options.credentials||this.credentials||"same-origin",!options.headers&&this.headers||(this.headers=new Headers(options.headers)),this.method=normalizeMethod(options.method||this.method||"GET"),this.mode=options.mode||this.mode||null,this.signal=options.signal||this.signal,this.referrer=null,("GET"===this.method||"HEAD"===this.method)&&body)throw new TypeError("Body not allowed for GET or HEAD requests");if(this._initBody(body),!("GET"!==this.method&&"HEAD"!==this.method||"no-store"!==options.cache&&"no-cache"!==options.cache)){var reParamSearch=/([?&])_=[^&]*/;if(reParamSearch.test(this.url))this.url=this.url.replace(reParamSearch,"$1_="+(new Date).getTime());else{this.url+=(/\?/.test(this.url)?"&":"?")+"_="+(new Date).getTime()}}}function decode(body){var form=new FormData;return body.trim().split("&").forEach((function(bytes){if(bytes){var split=bytes.split("="),name=split.shift().replace(/\+/g," "),value=split.join("=").replace(/\+/g," ");form.append(decodeURIComponent(name),decodeURIComponent(value))}})),form}function parseHeaders(rawHeaders){var headers=new Headers;return rawHeaders.replace(/\r?\n[\t ]+/g," ").split(/\r?\n/).forEach((function(line){var parts=line.split(":"),key=parts.shift().trim();if(key){var value=parts.join(":").trim();headers.append(key,value)}})),headers}function Response(bodyInit,options){if(!(this instanceof Response))throw new TypeError('Please use the "new" operator, this DOM object constructor cannot be called as a function.');options||(options={}),this.type="default",this.status=void 0===options.status?200:options.status,this.ok=this.status>=200&&this.status<300,this.statusText="statusText"in options?options.statusText:"",this.headers=new Headers(options.headers),this.url=options.url||"",this._initBody(bodyInit)}Request.prototype.clone=function(){return new Request(this,{body:this._bodyInit})},Body.call(Request.prototype),Body.call(Response.prototype),Response.prototype.clone=function(){return new Response(this._bodyInit,{status:this.status,statusText:this.statusText,headers:new Headers(this.headers),url:this.url})},Response.error=function(){var response=new Response(null,{status:0,statusText:""});return response.type="error",response};var redirectStatuses=[301,302,303,307,308];Response.redirect=function(url,status){if(-1===redirectStatuses.indexOf(status))throw new RangeError("Invalid status code");return new Response(null,{status:status,headers:{location:url}})};var DOMException=global$1.DOMException;try{new DOMException}catch(err){DOMException=function(message,name){this.message=message,this.name=name;var error=Error(message);this.stack=error.stack},DOMException.prototype=Object.create(Error.prototype),DOMException.prototype.constructor=DOMException}function fetch$1(input,init){return new Promise((function(resolve,reject){var request=new Request(input,init);if(request.signal&&request.signal.aborted)return reject(new DOMException("Aborted","AbortError"));var xhr=new XMLHttpRequest;function abortXhr(){xhr.abort()}xhr.onload=function(){var options={status:xhr.status,statusText:xhr.statusText,headers:parseHeaders(xhr.getAllResponseHeaders()||"")};options.url="responseURL"in xhr?xhr.responseURL:options.headers.get("X-Request-URL");var body="response"in xhr?xhr.response:xhr.responseText;setTimeout((function(){resolve(new Response(body,options))}),0)},xhr.onerror=function(){setTimeout((function(){reject(new TypeError("Network request failed"))}),0)},xhr.ontimeout=function(){setTimeout((function(){reject(new TypeError("Network request failed"))}),0)},xhr.onabort=function(){setTimeout((function(){reject(new DOMException("Aborted","AbortError"))}),0)},xhr.open(request.method,function(url){try{return""===url&&global$1.location.href?global$1.location.href:url}catch(e){return url}}(request.url),!0),"include"===request.credentials?xhr.withCredentials=!0:"omit"===request.credentials&&(xhr.withCredentials=!1),"responseType"in xhr&&(support.blob?xhr.responseType="blob":support.arrayBuffer&&request.headers.get("Content-Type")&&-1!==request.headers.get("Content-Type").indexOf("application/octet-stream")&&(xhr.responseType="arraybuffer")),!init||"object"!=typeof init.headers||init.headers instanceof Headers?request.headers.forEach((function(value,name){xhr.setRequestHeader(name,value)})):Object.getOwnPropertyNames(init.headers).forEach((function(name){xhr.setRequestHeader(name,normalizeValue(init.headers[name]))})),request.signal&&(request.signal.addEventListener("abort",abortXhr),xhr.onreadystatechange=function(){4===xhr.readyState&&request.signal.removeEventListener("abort",abortXhr)}),xhr.send(void 0===request._bodyInit?null:request._bodyInit)}))}fetch$1.polyfill=!0,global$1.fetch||(global$1.fetch=fetch$1,global$1.Headers=Headers,global$1.Request=Request,global$1.Response=Response),null==Element.prototype.getAttributeNames&&(Element.prototype.getAttributeNames=function(){for(var attributes=this.attributes,length=attributes.length,result=new Array(length),i=0;i=0&&matches.item(i)!==this;);return i>-1}),Element.prototype.matches||(Element.prototype.matches=Element.prototype.msMatchesSelector||Element.prototype.webkitMatchesSelector),Element.prototype.closest||(Element.prototype.closest=function(s){var el=this;do{if(el.matches(s))return el;el=el.parentElement||el.parentNode}while(null!==el&&1===el.nodeType);return null});var Connection=function(){function Connection(){_classCallCheck(this,Connection)}return _createClass(Connection,[{key:"onMessage",value:function(message,payload){message.component.receiveMessage(message,payload)}},{key:"onError",value:function(message,status){return message.component.messageSendFailed(),store.onErrorCallback(status)}},{key:"showExpiredMessage",value:function(){confirm("This page has expired due to inactivity.\nWould you like to refresh the page?")&&window.location.reload()}},{key:"sendMessage",value:function(message){var _this=this,payload=message.payload(),csrfToken=getCsrfToken(),socketId=this.getSocketId();if(window.__testing_request_interceptor)return window.__testing_request_interceptor(payload,this);fetch("".concat(window.livewire_app_url,"/livewire/message/").concat(payload.fingerprint.name),{method:"POST",body:JSON.stringify(payload),credentials:"same-origin",headers:_objectSpread2(_objectSpread2({"Content-Type":"application/json",Accept:"text/html, application/xhtml+xml","X-Livewire":!0,Referer:window.location.href},csrfToken&&{"X-CSRF-TOKEN":csrfToken}),socketId&&{"X-Socket-ID":socketId})}).then((function(response){if(response.ok)response.text().then((function(response){_this.isOutputFromDump(response)?(_this.onError(message),_this.showHtmlModal(response)):_this.onMessage(message,JSON.parse(response))}));else{if(!1===_this.onError(message,response.status))return;if(419===response.status){if(store.sessionHasExpired)return;store.sessionHasExpired=!0,_this.showExpiredMessage()}else response.text().then((function(response){_this.showHtmlModal(response)}))}})).catch((function(){_this.onError(message)}))}},{key:"isOutputFromDump",value:function(output){return!!output.match(/ +end() ?> diff --git a/themes/Hyva/view/frontend/layout/default_hyva.xml b/themes/Hyva/view/frontend/layout/default_hyva.xml new file mode 100644 index 00000000..7f7c2783 --- /dev/null +++ b/themes/Hyva/view/frontend/layout/default_hyva.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/themes/Hyva/view/frontend/layout/hyva_checkout_index_index.xml b/themes/Hyva/view/frontend/layout/hyva_checkout_index_index.xml new file mode 100644 index 00000000..3d0f522a --- /dev/null +++ b/themes/Hyva/view/frontend/layout/hyva_checkout_index_index.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + diff --git a/themes/Hyva/view/frontend/tailwind/module.css b/themes/Hyva/view/frontend/tailwind/module.css new file mode 100644 index 00000000..743a3abc --- /dev/null +++ b/themes/Hyva/view/frontend/tailwind/module.css @@ -0,0 +1,3 @@ +@source "../../../../../src/view"; + +@import "./ui-components/notifier.css"; diff --git a/themes/Hyva/view/frontend/tailwind/tailwind-source.css b/themes/Hyva/view/frontend/tailwind/tailwind-source.css new file mode 100644 index 00000000..b62cb32c --- /dev/null +++ b/themes/Hyva/view/frontend/tailwind/tailwind-source.css @@ -0,0 +1 @@ +@import "./ui-components/notifier.css"; diff --git a/themes/Hyva/view/frontend/tailwind/tailwind.config.js b/themes/Hyva/view/frontend/tailwind/tailwind.config.js new file mode 100644 index 00000000..b9c194d0 --- /dev/null +++ b/themes/Hyva/view/frontend/tailwind/tailwind.config.js @@ -0,0 +1,5 @@ +module.exports = { + content: [ + '../../../../themes/Hyva/view/frontend/templates/**/*.phtml' + ] +}; diff --git a/themes/Hyva/view/frontend/tailwind/ui-components/notifier.css b/themes/Hyva/view/frontend/tailwind/ui-components/notifier.css new file mode 100644 index 00000000..dea524f3 --- /dev/null +++ b/themes/Hyva/view/frontend/tailwind/ui-components/notifier.css @@ -0,0 +1,38 @@ +.magewire-notifier { + --offset: 0.5rem; + --index: 40; + --gap: 0.25rem; + z-index: var(--index); + position: fixed; + inset-inline: 0; + inset-block-end: 0; + max-width: 100%; + margin: var(--offset); + display: inline-flex; + flex-direction: column; + gap: var(--gap); + + /* Desktop */ + @media(min-width: 768px) { + --gap: 0.5rem; + --offset: 1.5rem; + inset-inline-end: auto; + } + + & .message { + @apply shadow-xl; + + margin-bottom: 0; + padding-inline: 1rem; + padding-block: 0.875rem; + max-width: 70ch; + justify-content: flex-start; + overflow-wrap: anywhere; + cursor: pointer; + gap: 0.5rem; + } +} + +.magewire-notifier-after { + margin-inline-start: auto; +} diff --git a/themes/Hyva/view/frontend/templates/js/magewire/internal/backwards-compatibility-emits.phtml b/themes/Hyva/view/frontend/templates/js/magewire/internal/backwards-compatibility-emits.phtml new file mode 100644 index 00000000..717f2f01 --- /dev/null +++ b/themes/Hyva/view/frontend/templates/js/magewire/internal/backwards-compatibility-emits.phtml @@ -0,0 +1,45 @@ +getData('view_model'); +$magewireFragment = $magewireViewModel->utils()->fragment(); +?> +make()->script()->start() ?> + +end() ?> diff --git a/themes/Hyva/view/frontend/templates/magewire-features/support-hyva-checkout-backwards-compatibility/magewire-attributes.phtml b/themes/Hyva/view/frontend/templates/magewire-features/support-hyva-checkout-backwards-compatibility/magewire-attributes.phtml new file mode 100644 index 00000000..18d5727c --- /dev/null +++ b/themes/Hyva/view/frontend/templates/magewire-features/support-hyva-checkout-backwards-compatibility/magewire-attributes.phtml @@ -0,0 +1,66 @@ +getData('view_model'); +$magewireFragment = $magewireViewModel->utils()->fragment(); + +/** @internal Do not modify to ensure Magewire continues to function correctly. */ +?> +make()->script()->start() ?> + +end() ?> diff --git a/themes/Hyva/view/frontend/templates/magewire-features/support-hyva-checkout-backwards-compatibility/magewire-components.phtml b/themes/Hyva/view/frontend/templates/magewire-features/support-hyva-checkout-backwards-compatibility/magewire-components.phtml new file mode 100644 index 00000000..e559a32e --- /dev/null +++ b/themes/Hyva/view/frontend/templates/magewire-features/support-hyva-checkout-backwards-compatibility/magewire-components.phtml @@ -0,0 +1,107 @@ +getData('view_model'); +$magewireFragment = $magewireViewModel->utils()->fragment(); + +/** @internal Do not modify to ensure Magewire continues to function correctly. */ +?> +make()->script()->start() ?> + +end() ?> diff --git a/themes/Hyva/view/frontend/templates/magewire-features/support-hyva-checkout-backwards-compatibility/magewire-events.phtml b/themes/Hyva/view/frontend/templates/magewire-features/support-hyva-checkout-backwards-compatibility/magewire-events.phtml new file mode 100644 index 00000000..e7cddd20 --- /dev/null +++ b/themes/Hyva/view/frontend/templates/magewire-features/support-hyva-checkout-backwards-compatibility/magewire-events.phtml @@ -0,0 +1,178 @@ +getData('view_model'); +$magewireFragment = $magewireViewModel->utils()->fragment(); + +/** @internal Do not modify to ensure Magewire continues to function correctly. */ +?> +make()->script()->start() ?> + +end() ?> diff --git a/themes/Hyva/view/frontend/templates/magewire-features/support-hyva-checkout-backwards-compatibility/magewire-hooks.phtml b/themes/Hyva/view/frontend/templates/magewire-features/support-hyva-checkout-backwards-compatibility/magewire-hooks.phtml new file mode 100644 index 00000000..7f31118e --- /dev/null +++ b/themes/Hyva/view/frontend/templates/magewire-features/support-hyva-checkout-backwards-compatibility/magewire-hooks.phtml @@ -0,0 +1,75 @@ +getData('view_model'); +$magewireFragment = $magewireViewModel->utils()->fragment(); + +/** @internal Do not modify to ensure Magewire continues to function correctly. */ +?> +make()->script()->start() ?> + +end() ?> diff --git a/themes/Hyva/view/frontend/templates/magewire-features/support-magento-flash-messages/support-magento-flash-messages.phtml b/themes/Hyva/view/frontend/templates/magewire-features/support-magento-flash-messages/support-magento-flash-messages.phtml new file mode 100644 index 00000000..be52f17b --- /dev/null +++ b/themes/Hyva/view/frontend/templates/magewire-features/support-magento-flash-messages/support-magento-flash-messages.phtml @@ -0,0 +1,20 @@ +getData('view_model'); +$magewireFragment = $magewireViewModel->utils()->fragment(); +?> +make()->script()->start() ?> + +end() ?> diff --git a/themes/Hyva/view/frontend/templates/overwrite/Hyva_Theme/page/js/alpinejs.phtml b/themes/Hyva/view/frontend/templates/overwrite/Hyva_Theme/page/js/alpinejs.phtml new file mode 100644 index 00000000..826ad8b0 --- /dev/null +++ b/themes/Hyva/view/frontend/templates/overwrite/Hyva_Theme/page/js/alpinejs.phtml @@ -0,0 +1,18 @@ + +getChildHtml() ?> diff --git a/themes/Hyva/view/frontend/templates/script.phtml b/themes/Hyva/view/frontend/templates/script.phtml new file mode 100644 index 00000000..28d9f7f5 --- /dev/null +++ b/themes/Hyva/view/frontend/templates/script.phtml @@ -0,0 +1,48 @@ +getData('view_model'); + +$magewireUtility = $magewireViewModel->utils()->magewire(); + +// The Hyvä theme requires AlpineJS to function. Magewire ships with its own bundled version of AlpineJS, +// so when Magewire is present and active on the page, it acts as the AlpineJS provider for Hyvä as well. +// +// The condition below determines which AlpineJS source should be used: +// +// - TRUE (canRequireMagewire): Magewire is responsible for bootstrapping Alpine. The Magewire script +// tag is rendered directly, which includes Alpine out of the box. Hyva piggybacks on this. +// +// - FALSE (else): Magewire is not driving the page, so Alpine must come from elsewhere. The child +// block output is rendered instead, which is expected to contain the standalone Alpine script +// configured by the Hyvä theme or another module. +// +// @todo Make Alpine.js loading configurable with three options: +// 1. Load the Magewire-bundled Alpine.js version. +// 2. Load standalone Alpine.js only (coming with the Hyvä theme). +// 3. Load the Alpine.js version that ships with Magewire, without loading Magewire itself. +?> +canRequireMagewireJsLibrary()): ?> + + + getChildHtml() ?> + diff --git a/themes/Luma/etc/module.xml b/themes/Luma/etc/module.xml new file mode 100644 index 00000000..140bb374 --- /dev/null +++ b/themes/Luma/etc/module.xml @@ -0,0 +1,10 @@ + + + + + + + + diff --git a/themes/Luma/registration.php b/themes/Luma/registration.php new file mode 100644 index 00000000..bfc9bb1d --- /dev/null +++ b/themes/Luma/registration.php @@ -0,0 +1,14 @@ +