From 9de1b407b7b608bad10eb22a42738392dca55435 Mon Sep 17 00:00:00 2001 From: gouater <139002105+dancycodes@users.noreply.github.com> Date: Sun, 9 Nov 2025 18:12:15 +0100 Subject: [PATCH 1/4] Add Datastar RC6 file format support and metadata access Introduces support for Datastar RC6 file signal format ({name, contents, mime}) alongside legacy RC5 format in HyperFileStorage, HyperSignal, and HyperBase64Validator. Adds methods to extract original filename, MIME type, and format detection. Updates tests to cover RC6 and legacy formats, ensuring backward compatibility and proper metadata handling. Documents new API and migration patterns in ACTIONS_API.md. --- ACTIONS_API.md | 585 +++++++++++++++++++++++ src/Http/HyperSignal.php | 52 ++ src/Services/HyperFileStorage.php | 147 +++++- src/Validation/HyperBase64Validator.php | 36 +- tests/Feature/FileUploadWorkflowTest.php | 174 +++++-- 5 files changed, 947 insertions(+), 47 deletions(-) create mode 100644 ACTIONS_API.md diff --git a/ACTIONS_API.md b/ACTIONS_API.md new file mode 100644 index 0000000..0cbb4ba --- /dev/null +++ b/ACTIONS_API.md @@ -0,0 +1,585 @@ +# Action Methods API Reference + +Simplified action methods for Datastar HTTP actions with smart event detection. These methods reduce verbosity while maintaining full access to Datastar's capabilities. + +## Table of Contents + +- [Quick Start](#quick-start) +- [Smart Event Detection](#smart-event-detection) +- [CSRF-Protected Actions](#csrf-protected-actions) +- [Standard Datastar Actions](#standard-datastar-actions) +- [Navigate Action](#navigate-action) +- [Dispatch Action](#dispatch-action) +- [Event Modifiers](#event-modifiers) +- [Options Reference](#options-reference) +- [Common Patterns](#common-patterns) + +## Quick Start + +```php +use Dancycodes\Hyper\Html\Html; + +// Before: verbose dataOn() syntax +Html::button()->dataOn('click', '@postx("/save")'); + +// After: simplified action method with smart event detection +Html::button()->postx('/save'); + +// Forms automatically use 'submit__prevent' +Html::form()->postx('/submit'); // Uses submit__prevent event + +// Inputs automatically use 'input' event +Html::input()->get('/search'); // Uses input event +``` + +## Smart Event Detection + +Action methods automatically select the appropriate event based on element type and attributes: + +| Element Type | Default Event | Reasoning | +|-------------|---------------|-----------| +| `form` | `submit__prevent` | Prevents default form submission | +| `button`, `a` | `click` | Standard click interaction | +| `input[type=checkbox]` | `change` | Fires when checked/unchecked | +| `input[type=radio]` | `change` | Fires when selection changes | +| `input[type=file]` | `change` | Fires when file selected | +| `input[type=color]` | `change` | Fires when color selected | +| `input` (text-like) | `input` | Fires on every keystroke/change | +| `select` | `change` | Fires when option selected | +| `textarea` | `input` | Fires on every keystroke | +| All others | `click` | Safe default for interactions | + +### Input Type Detection + +For `` elements, the smart detection checks the `type` attribute: + +**Change Event Types** (state-based interactions): +- `checkbox` - Toggle checked state +- `radio` - Select from radio group +- `file` - Select file from file system +- `color` - Pick color from color picker + +**Input Event Types** (continuous feedback): +- `text`, `email`, `password` - Text entry +- `number`, `date`, `time` - Numeric/date entry +- `search`, `url`, `tel` - Specialized text entry +- `range` - Slider movement +- All other input types default to `input` + +### Smart Detection Examples + +```php +// Text input - automatically uses 'input' event +Html::input()->type('text')->get('/autosave'); +// Generates: data-on:input="@get('/autosave')" + +// Checkbox - automatically uses 'change' event +Html::input()->type('checkbox')->patchx('/toggle-active'); +// Generates: data-on:change="@patchx('/toggle-active')" + +// Radio button - automatically uses 'change' event +Html::input()->type('radio')->patchx('/select-option'); +// Generates: data-on:change="@patchx('/select-option')" + +// File input - automatically uses 'change' event +Html::input()->type('file')->postx('/upload'); +// Generates: data-on:change="@postx('/upload')" + +// Select dropdown - automatically uses 'change' event +Html::select()->get('/filter'); +// Generates: data-on:change="@get('/filter')" + +// Form - automatically uses 'submit__prevent' event +Html::form()->postx('/submit'); +// Generates: data-on:submit__prevent="@postx('/submit')" +``` + +### Override Smart Defaults + +Explicitly pass an event to override the smart default: + +```php +// Override form's default submit__prevent +Html::form()->postx('/save', 'change'); + +// Override checkbox's default change event +Html::input()->type('checkbox')->patchx('/toggle', 'click'); + +// Add event modifiers to text input +Html::input()->type('text')->get('/search', 'input__debounce.300ms'); + +// Use window event modifier +Html::div()->get('/refresh', 'click__window'); +``` + +## CSRF-Protected Actions + +Laravel Hyper extends Datastar with CSRF-protected variants of mutating actions. **Always use these for Laravel applications.** + +### postx() + +POST request with automatic CSRF token inclusion. + +```php +public function postx(string $url, ?string $event = null, array $options = []): static +``` + +**Usage:** + +```php +// Simple POST (uses smart event: 'click' for button) +Html::button()->postx('/create'); + +// POST on form (uses smart event: 'submit__prevent') +Html::form()->postx('/submit'); + +// Explicit event with modifiers +Html::button()->postx('/save', 'dblclick__once'); + +// With options +Html::button()->postx('/save', null, [ + 'headers' => ['X-Custom' => 'value'], + 'retry' => 3 +]); +``` + +### putx() + +PUT request with automatic CSRF token inclusion. + +```php +public function putx(string $url, ?string $event = null, array $options = []): static +``` + +**Usage:** + +```php +// Full resource update +Html::button()->putx('/users/123'); + +// With explicit event +Html::form()->putx('/update', 'submit__prevent'); +``` + +### patchx() + +PATCH request with automatic CSRF token inclusion. + +```php +public function patchx(string $url, ?string $event = null, array $options = []): static +``` + +**Usage:** + +```php +// Partial resource update +Html::button()->patchx('/users/123'); + +// Toggle status on checkbox change +Html::input() + ->type('checkbox') + ->patchx('/toggle-status', 'change'); +``` + +### deletex() + +DELETE request with automatic CSRF token inclusion. + +```php +public function deletex(string $url, ?string $event = null, array $options = []): static +``` + +**Usage:** + +```php +// Simple delete +Html::button()->deletex('/items/123'); + +// With confirmation (use window modifier for global event) +Html::button()->deletex('/items/123', 'click__window'); +``` + +## Standard Datastar Actions + +These are the standard Datastar actions **without CSRF protection**. Use these only when: +- Interfacing with external APIs +- Working with read-only GET operations +- You've handled CSRF another way + +### get() + +GET request to fetch data or HTML. + +```php +public function get(string $url, ?string $event = null, array $options = []): static +``` + +**Usage:** + +```php +// Simple GET +Html::button()->get('/fetch-data'); + +// Search on input with debounce +Html::input()->get('/search', 'input__debounce.300ms'); + +// Infinite scroll on intersection +Html::div()->get('/load-more', 'intersect__once'); + +// With options +Html::button()->get('/api/data', null, [ + 'headers' => ['Accept' => 'application/json'] +]); +``` + +### post(), put(), patch(), delete() + +Standard Datastar actions **without CSRF protection**. + +```php +public function post(string $url, ?string $event = null, array $options = []): static +public function put(string $url, ?string $event = null, array $options = []): static +public function patch(string $url, ?string $event = null, array $options = []): static +public function delete(string $url, ?string $event = null, array $options = []): static +``` + +**⚠️ Warning:** Use the `*x` variants (postx, putx, patchx, deletex) for Laravel applications instead. + +## Navigate Action + +Client-side navigation with optional targeted updates. Updates browser URL and fetches new content without full page reload. + +```php +public function navigate( + string $url, + ?string $event = null, + ?string $key = null, + array $options = [] +): static +``` + +**Parameters:** +- `$url`: Target URL to navigate to +- `$event`: Event trigger (null = smart default) +- `$key`: Optional navigation key for targeted updates +- `$options`: Datastar fetch options + +**Usage:** + +```php +// Simple navigation +Html::a() + ->href('/dashboard') + ->navigate('/dashboard'); + +// Targeted navigation (only updates specific fragment) +Html::a()->navigate('/sidebar', null, 'sidebar-content'); + +// With explicit event +Html::div()->navigate('/page', 'dblclick'); + +// With options +Html::a()->navigate('/page', null, 'main', [ + 'headers' => ['X-Partial' => 'true'] +]); + +// Navigation with event modifiers +Html::a()->navigate('/content', 'click__prevent', 'main'); +``` + +## Dispatch Action + +Dispatches custom JavaScript events with optional detail data. Useful for component communication and decoupled architectures. + +```php +public function dispatch( + string $eventName, + ?string $event = null, + array $detail = [] +): static +``` + +**Parameters:** +- `$eventName`: Custom event name to dispatch +- `$event`: DOM event that triggers dispatch (null = smart default) +- `$detail`: Optional event detail data (sent as `event.detail`) + +**Usage:** + +```php +// Simple event dispatch +Html::button()->dispatch('modal-open'); + +// With event detail +Html::button()->dispatch('user-selected', null, [ + 'userId' => 123, + 'username' => 'john' +]); + +// Explicit trigger event +Html::div()->dispatch('item-clicked', 'dblclick'); + +// Complex detail data +Html::button()->dispatch('form-ready', null, [ + 'formId' => 'user-form', + 'fields' => ['name', 'email'], + 'timestamp' => time() +]); +``` + +## Event Modifiers + +Event modifiers control event behavior. Use Datastar's double underscore (`__`) syntax: + +| Modifier | Description | Example | +|---------|-------------|---------| +| `prevent` | Calls `preventDefault()` | `click__prevent` | +| `stop` | Calls `stopPropagation()` | `click__stop` | +| `once` | Fire only once | `click__once` | +| `passive` | Passive event listener | `scroll__passive` | +| `capture` | Use capture phase | `click__capture` | +| `debounce.{time}ms` | Debounce execution | `input__debounce.300ms` | +| `throttle.{time}ms` | Throttle execution | `scroll__throttle.250ms` | +| `delay.{time}ms` | Simple delay | `click__delay.500ms` | +| `window` | Attach to window | `resize__window` | +| `outside` | Trigger when click outside | `click__outside` | +| `document` | Attach to document | `keydown__document` | +| `viewtransition` | Use View Transition API | `click__viewtransition` | + +### Modifier Examples + +```php +// Prevent default form submission +Html::form()->postx('/save', 'submit__prevent'); + +// Debounced search input +Html::input()->get('/search', 'input__debounce.300ms'); + +// Fire only once +Html::button()->postx('/init', 'click__once'); + +// Window resize event +Html::div()->get('/layout', 'resize__window'); + +// Combine multiple modifiers +Html::button()->postx('/save', 'click__prevent__stop__once'); + +// Throttle scroll event +Html::div()->get('/load-items', 'scroll__throttle.250ms'); +``` + +## Options Reference + +All action methods accept an optional `$options` array for Datastar fetch configuration: + +```php +Html::button()->postx('/save', null, [ + // Custom headers + 'headers' => [ + 'X-Custom-Header' => 'value', + 'Accept' => 'application/json' + ], + + // Retry configuration + 'retry' => 3, // Number of retries + 'retryDelay' => 1000, // Delay between retries (ms) + + // Credentials handling + 'credentials' => 'include', // 'omit', 'same-origin', 'include' + + // Filter signals sent to server + 'filterSignals' => ['userId', 'formData'], + + // Content-Type override + 'contentType' => 'application/json', + + // Timeout + 'timeout' => 5000, // Request timeout (ms) +]); +``` + +### Common Options Patterns + +```php +// API endpoint with JSON +Html::button()->postx('/api/save', null, [ + 'headers' => ['Accept' => 'application/json'], + 'contentType' => 'application/json' +]); + +// Retry on failure +Html::button()->postx('/unreliable-endpoint', null, [ + 'retry' => 5, + 'retryDelay' => 2000 +]); + +// Filter sensitive signals +Html::form()->postx('/submit', null, [ + 'filterSignals' => ['email', 'formData'] +]); +``` + +## Common Patterns + +### Search with Debounce + +```php +Html::input() + ->type('search') + ->name('q') + ->placeholder('Search...') + ->dataBind('searchQuery') + ->get('/search', 'input__debounce.300ms') + ->dataIndicator('searching'); +``` + +### Save Button with Loading State + +```php +Html::button() + ->type('submit') + ->class('btn btn-primary') + ->dataIndicator('saving') + ->postx('/save') + ->dataAttr('disabled', '$saving') + ->content( + Html::span()->dataShow('!$saving')->text('Save'), + Html::span()->dataShow('$saving')->text('Saving...') + ); +``` + +### Infinite Scroll + +```php +Html::div() + ->id('scroll-trigger') + ->get('/load-more', 'intersect__once') + ->dataIndicator('loading') + ->content( + Html::div()->dataShow('$loading')->text('Loading...') + ); +``` + +### Delete with Confirmation + +```php +Html::button() + ->class('btn btn-danger') + ->dataIndicator('deleting') + ->deletex('/items/123', 'click__window') // window modifier for global confirmation + ->dataAttr('disabled', '$deleting') + ->text('Delete'); +``` + +### Form with Multiple Submit Actions + +```php +Html::form() + ->dataSignals(['status' => 'draft']) + ->dataIndicator('submitting') + ->content( + Html::input()->type('text')->name('title')->dataBind('title'), + + // Save as draft + Html::button() + ->type('submit') + ->postx('/save?status=draft') + ->dataAttr('disabled', '$submitting') + ->text('Save Draft'), + + // Publish + Html::button() + ->type('submit') + ->postx('/save?status=published') + ->dataAttr('disabled', '$submitting') + ->text('Publish') + ); +``` + +### Navigation with Targeted Updates + +```php +// Main navigation link +Html::a() + ->href('/dashboard') + ->class('nav-link') + ->navigate('/dashboard', null, 'main-content', [ + 'headers' => ['X-Partial' => 'main-content'] + ]) + ->text('Dashboard'); + +// Sidebar navigation (updates sidebar only) +Html::a() + ->href('/sidebar/users') + ->navigate('/sidebar/users', null, 'sidebar', [ + 'headers' => ['X-Partial' => 'sidebar'] + ]) + ->text('Users'); +``` + +### Auto-save on Input + +```php +Html::input() + ->type('text') + ->name('title') + ->dataBind('title') + ->patchx('/autosave', 'input__debounce.500ms') + ->dataIndicator('autoSaving') + ->dataAttr('title', "$autoSaving ? 'Saving...' : 'Auto-save enabled'"); +``` + +### Toggle on Checkbox + +```php +// Smart detection automatically uses 'change' event +Html::input() + ->type('checkbox') + ->name('active') + ->dataBind('isActive') + ->patchx('/toggle-active') // No need to specify 'change'! + ->dataIndicator('toggling') + ->dataAttr('disabled', '$toggling'); +``` + +### File Upload Preview + +```php +// Smart detection automatically uses 'change' event for file inputs +Html::input() + ->type('file') + ->name('avatar') + ->dataBind('avatarFile') + ->postx('/upload') // No need to specify 'change'! + ->dataIndicator('uploading'); +``` + +## Migration Guide + +Migrating from verbose `dataOn()` syntax to simplified action methods: + +```php +// Before +Html::button()->dataOn('click', '@postx("/save")'); + +// After +Html::button()->postx('/save'); + +// Before (with event modifiers) +Html::input()->dataOn('input__debounce.300ms', '@get("/search")'); + +// After +Html::input()->get('/search', 'input__debounce.300ms'); + +// Before (with options) +Html::button()->dataOn('click', '@postx("/save", {retry: 3})'); + +// After +Html::button()->postx('/save', null, ['retry' => 3]); +``` + +## See Also + +- [Loading States Documentation](./LOADING_STATES.md) +- [Datastar Backend Actions](https://data-star.dev/reference/plugins/backend) +- [Datastar Event Modifiers](https://data-star.dev/reference/plugins/backend#event-modifiers) diff --git a/src/Http/HyperSignal.php b/src/Http/HyperSignal.php index da5fe0a..8a24675 100644 --- a/src/Http/HyperSignal.php +++ b/src/Http/HyperSignal.php @@ -576,6 +576,58 @@ public function storeMultiple(array $mapping, string $disk = 'public'): array return $this->getFileStorage()->storeMultiple($mapping, $disk); } + /** + * Get original filename from Datastar RC6 signal format + * + * Extracts the original filename from RC6 format file signals. Returns null for + * legacy format or when signal doesn't contain file data. Useful for preserving + * original filenames or displaying file names in UI. + * + * RC6 Format: [{name: 'photo.jpg', contents: 'base64...', mime: 'image/jpeg'}] + * + * @param string $signalKey Signal key containing file data + * + * @return string|null Original filename if available, null otherwise + */ + public function getFilename(string $signalKey): ?string + { + return $this->getFileStorage()->getFilename($signalKey); + } + + /** + * Get MIME type from Datastar RC6 signal format + * + * Extracts the MIME type from RC6 format file signals without needing to decode + * the base64 data. Returns null for legacy format or when signal doesn't contain + * file data. Useful for validation and content-type determination. + * + * RC6 Format: [{name: 'photo.jpg', contents: 'base64...', mime: 'image/jpeg'}] + * + * @param string $signalKey Signal key containing file data + * + * @return string|null MIME type if available, null otherwise + */ + public function getMimeType(string $signalKey): ?string + { + return $this->getFileStorage()->getMimeType($signalKey); + } + + /** + * Check if signal contains RC6 format file data + * + * Determines whether the signal value uses Datastar RC6 format (object with + * name, contents, and mime properties) or legacy format. Useful for conditional + * processing or feature detection. + * + * @param string $signalKey Signal key to check + * + * @return bool True if RC6 format, false otherwise + */ + public function isRC6Format(string $signalKey): bool + { + return $this->getFileStorage()->isRC6Format($signalKey); + } + /** * Static helper for quick access * diff --git a/src/Services/HyperFileStorage.php b/src/Services/HyperFileStorage.php index 75df2e0..33d1f18 100644 --- a/src/Services/HyperFileStorage.php +++ b/src/Services/HyperFileStorage.php @@ -12,14 +12,21 @@ * Provides methods for storing base64 data to Laravel filesystem disks with automatic MIME * type detection, extension resolution, and unique filename generation. * - * Supports Datastar signal format where file inputs are transmitted as base64 strings or - * arrays containing base64 data. Handles data URL prefixes by stripping metadata headers - * before decoding binary content for storage. + * Supports both Datastar RC6 format (objects with {name, contents, mime}) and legacy RC5 + * format (plain base64 strings). File inputs are transmitted as single-element arrays, + * automatically extracting contents from RC6 objects or using plain strings from legacy + * format. Handles data URL prefixes by stripping metadata headers before decoding binary + * content for storage. + * + * RC6 Format: [{name: 'photo.jpg', contents: 'base64...', mime: 'image/jpeg'}] + * Legacy Format: ['base64...'] or 'data:image/png;base64,base64...' * * Files are stored using Laravel Storage facade without creating temporary files on disk, * enabling efficient memory-based processing for uploaded content. MIME type detection * uses PHP's getimagesizefromstring for images and finfo extension for other file types. * + * Public methods available for RC6 metadata access: getFilename(), getMimeType(), isRC6Format() + * * @see \Dancycodes\Hyper\Http\HyperSignal * @see \Dancycodes\Hyper\Validation\HyperBase64Validator */ @@ -32,16 +39,20 @@ class HyperFileStorage * binary content, detects MIME type and extension, generates unique filename if not * provided, and stores file to specified disk and directory path. * + * For RC6 format files, automatically uses the original filename if no custom filename + * is provided, preserving the user's original file name. Set $filename to false to + * force auto-generation even for RC6 format. + * * @param string $signalKey Signal key containing base64 file data * @param string $directory Target directory path within disk (optional) * @param string $disk Laravel filesystem disk name (default: public) - * @param string|null $filename Custom filename with extension (auto-generated if null) + * @param string|null|false $filename Custom filename with extension (null: use RC6 name if available, false: force auto-generate) * * @throws \InvalidArgumentException When signal key contains no base64 data * * @return string Stored file path relative to disk root */ - public function store(string $signalKey, string $directory = '', string $disk = 'public', ?string $filename = null): string + public function store(string $signalKey, string $directory = '', string $disk = 'public', string|null|false $filename = null): string { $base64Data = $this->extractBase64FromSignals($signalKey); @@ -49,6 +60,16 @@ public function store(string $signalKey, string $directory = '', string $disk = throw new InvalidArgumentException("No base64 data found for signal: {$signalKey}"); } + // Use RC6 filename if no custom filename provided and RC6 format detected + if ($filename === null && $this->isRC6Format($signalKey)) { + $filename = $this->getFilename($signalKey); + } + + // Convert false to null for auto-generation + if ($filename === false) { + $filename = null; + } + return $this->storeBase64Data($base64Data, $directory, $disk, $filename); } @@ -108,9 +129,15 @@ public function storeMultiple(array $mapping, string $disk = 'public'): array /** * Extract base64 data from signal value handling Datastar format * - * Retrieves signal value using signals() helper, handles array format where Datastar - * transmits file inputs as single-element arrays, strips data URL metadata prefix if - * present, and returns clean base64 string ready for decoding. + * Supports both Datastar RC6 format (objects with {name, contents, mime}) and legacy + * RC5 format (plain base64 strings). Retrieves signal value using signals() helper, + * handles array format where Datastar transmits file inputs as single-element arrays, + * extracts contents from RC6 objects or uses plain strings from legacy format, strips + * data URL metadata prefix if present for backward compatibility, and returns clean + * base64 string ready for decoding. + * + * RC6 Format: [{name: 'photo.jpg', contents: 'base64...', mime: 'image/jpeg'}] + * Legacy Format: ['base64...'] or 'data:image/png;base64,base64...' * * @param string $key Signal key containing file data * @@ -126,13 +153,23 @@ private function extractBase64FromSignals(string $key): string if (empty($value)) { return ''; } - $value = $value[0]; + + $firstItem = $value[0]; + + // RC6 format: object with 'contents' key + if (is_array($firstItem) && isset($firstItem['contents'])) { + $value = $firstItem['contents']; + } else { + // Legacy RC5 format: plain base64 string + $value = $firstItem; + } } if (!is_string($value)) { return ''; } + // Strip data URI prefix if present (legacy format support) if (strpos($value, ';base64,') !== false) { [, $value] = explode(',', $value, 2); } @@ -140,6 +177,98 @@ private function extractBase64FromSignals(string $key): string return trim($value); } + /** + * Extract original filename from Datastar RC6 signal format + * + * Retrieves signal value and extracts the original filename if present in RC6 format. + * Returns null for legacy format or when filename is not available. Useful for + * preserving original filenames during storage operations. + * + * RC6 Format: [{name: 'photo.jpg', contents: 'base64...', mime: 'image/jpeg'}] + * + * @param string $signalKey Signal key containing file data + * + * @return string|null Original filename if available, null otherwise + */ + public function getFilename(string $signalKey): ?string + { + /** @var \Dancycodes\Hyper\Http\HyperSignal $hyperSignal */ + $hyperSignal = signals(); + $value = $hyperSignal->get($signalKey); + + if (!is_array($value) || empty($value)) { + return null; + } + + $firstItem = $value[0]; + + // RC6 format: object with 'name' key + if (is_array($firstItem) && isset($firstItem['name']) && is_string($firstItem['name'])) { + return $firstItem['name']; + } + + return null; + } + + /** + * Extract MIME type from Datastar RC6 signal format + * + * Retrieves signal value and extracts the MIME type if present in RC6 format. + * Returns null for legacy format or when MIME type is not available. Useful for + * validation and content-type determination without decoding base64 data. + * + * RC6 Format: [{name: 'photo.jpg', contents: 'base64...', mime: 'image/jpeg'}] + * + * @param string $signalKey Signal key containing file data + * + * @return string|null MIME type if available, null otherwise + */ + public function getMimeType(string $signalKey): ?string + { + /** @var \Dancycodes\Hyper\Http\HyperSignal $hyperSignal */ + $hyperSignal = signals(); + $value = $hyperSignal->get($signalKey); + + if (!is_array($value) || empty($value)) { + return null; + } + + $firstItem = $value[0]; + + // RC6 format: object with 'mime' key + if (is_array($firstItem) && isset($firstItem['mime']) && is_string($firstItem['mime'])) { + return $firstItem['mime']; + } + + return null; + } + + /** + * Check if signal contains RC6 format file data + * + * Determines whether the signal value uses Datastar RC6 format (object with + * name, contents, and mime properties) or legacy format. Useful for conditional + * processing based on format version. + * + * @param string $signalKey Signal key to check + * + * @return bool True if RC6 format, false otherwise + */ + public function isRC6Format(string $signalKey): bool + { + /** @var \Dancycodes\Hyper\Http\HyperSignal $hyperSignal */ + $hyperSignal = signals(); + $value = $hyperSignal->get($signalKey); + + if (!is_array($value) || empty($value)) { + return false; + } + + $firstItem = $value[0]; + + return is_array($firstItem) && isset($firstItem['contents']); + } + /** * Decode and store base64 data to Laravel filesystem disk * diff --git a/src/Validation/HyperBase64Validator.php b/src/Validation/HyperBase64Validator.php index dff7fc7..ba048d0 100644 --- a/src/Validation/HyperBase64Validator.php +++ b/src/Validation/HyperBase64Validator.php @@ -11,9 +11,14 @@ * reactive signals. Implements validation methods for image type verification, file size * constraints, image dimension requirements, and MIME type checking. * - * Handles Datastar signal format where file inputs are transmitted as single-element - * arrays, automatically extracting and stripping data URL prefixes before validation. - * All validators delegate empty value handling to required rule for consistent behavior. + * Handles both Datastar RC6 format (objects with {name, contents, mime}) and legacy + * RC5 format (plain base64 strings). File inputs are transmitted as single-element arrays, + * automatically extracting contents from RC6 objects or using plain strings from legacy + * format, and stripping data URL prefixes before validation. All validators delegate empty + * value handling to required rule for consistent behavior. + * + * RC6 Format: [{name: 'photo.jpg', contents: 'base64...', mime: 'image/jpeg'}] + * Legacy Format: ['base64...'] or 'data:image/png;base64,base64...' * * Validation rules perform memory-based operations without temporary files, using PHP's * getimagesizefromstring for image validation and finfo extension for MIME type detection. @@ -269,11 +274,16 @@ public function validateB64mimes(string $attribute, mixed $value, array $paramet /** * Extract base64 value from Datastar signal format or plain string * - * Handles array format where Datastar transmits file inputs as single-element arrays, - * extracts first element if array provided, returns empty string for non-string values, - * strips data URL metadata prefix if present, and returns trimmed base64 string. + * Supports both Datastar RC6 format (array of objects with {name, contents, mime}) + * and legacy RC5 format (array of plain base64 strings). Extracts first element if + * array provided, handles RC6 object structure by accessing 'contents' property, + * strips data URL metadata prefix if present for backward compatibility, and returns + * trimmed base64 string. * - * @param mixed $value Base64 string or Datastar array format + * RC6 Format: [{name: 'photo.jpg', contents: 'base64...', mime: 'image/jpeg'}] + * Legacy Format: ['base64...'] or 'data:image/png;base64,base64...' + * + * @param mixed $value Base64 string or Datastar array format (RC5/RC6) * * @return string Clean base64 string without data URL prefix */ @@ -283,13 +293,23 @@ private function extractBase64Value(mixed $value): string if (empty($value)) { return ''; } - $value = $value[0]; + + $firstItem = $value[0]; + + // RC6 format: object with 'contents' key + if (is_array($firstItem) && isset($firstItem['contents'])) { + $value = $firstItem['contents']; + } else { + // Legacy RC5 format: plain base64 string + $value = $firstItem; + } } if (!is_string($value)) { return ''; } + // Strip data URI prefix if present (legacy format support) if (strpos($value, ';base64,') !== false) { [, $value] = explode(',', $value, 2); } diff --git a/tests/Feature/FileUploadWorkflowTest.php b/tests/Feature/FileUploadWorkflowTest.php index 8550ea3..b85febd 100644 --- a/tests/Feature/FileUploadWorkflowTest.php +++ b/tests/Feature/FileUploadWorkflowTest.php @@ -9,8 +9,11 @@ /** * Test File Upload Workflows with Base64 * + * Tests both Datastar RC6 format ({name, contents, mime}) and legacy RC5 format + * (plain base64 strings) to ensure backward compatibility and proper RC6 support. + * * @see TESTING.md - File 52: FileUploadWorkflow Tests - * Status: 🔄 IN PROGRESS - 22 test methods + * Status: ✅ UPDATED - 25 test methods (22 updated for RC6 + 3 new RC6/legacy tests) */ class FileUploadWorkflowTest extends TestCase { @@ -25,6 +28,36 @@ protected function setUp(): void Storage::fake('public'); } + /** + * Create RC6 format file data (Datastar RC6 standard) + * + * @param string $contents Base64-encoded file contents + * @param string $name Original filename + * @param string $mime MIME type + * + * @return array RC6 format: [{name, contents, mime}] + */ + protected function createRC6File(string $contents, string $name = 'test-file.png', string $mime = 'image/png'): array + { + return [[ + 'name' => $name, + 'contents' => $contents, + 'mime' => $mime, + ]]; + } + + /** + * Create legacy format file data (Datastar RC5 - for backward compatibility tests) + * + * @param string $contents Base64-encoded file contents + * + * @return array Legacy format: ['base64...'] + */ + protected function createLegacyFile(string $contents): array + { + return [$contents]; + } + /** @test */ public function test_base64_image_upload_and_storage() { @@ -33,7 +66,7 @@ public function test_base64_image_upload_and_storage() return hyper()->signals(['uploaded' => true, 'path' => $path]); }); - $signals = json_encode(['avatar' => $this->validPngBase64]); + $signals = json_encode(['avatar' => $this->createRC6File($this->validPngBase64, 'avatar.png')]); $response = $this->call('POST', '/upload-image', [ 'datastar' => $signals, ], [], [], ['HTTP_DATASTAR_REQUEST' => 'true']); @@ -49,7 +82,7 @@ public function test_base64_pdf_upload_and_storage() return hyper()->signals(['uploaded' => true]); }); - $signals = json_encode(['document' => $pdfBase64]); + $signals = json_encode(['document' => $this->createRC6File($pdfBase64, 'document.pdf', 'application/pdf')]); $response = $this->call('POST', '/upload-pdf', [ 'datastar' => $signals, ], [], [], ['HTTP_DATASTAR_REQUEST' => 'true']); @@ -68,7 +101,7 @@ public function test_file_validation_with_b64image_rule() return hyper()->signals(['errors' => ['avatar' => ['Invalid image']]]); } }); - $signals = json_encode(['avatar' => $this->validPngBase64]); + $signals = json_encode(['avatar' => $this->createRC6File($this->validPngBase64, 'avatar.png')]); $response = $this->call('POST', '/validate-image', [ 'datastar' => $signals, ], [], [], ['HTTP_DATASTAR_REQUEST' => 'true']); @@ -83,7 +116,7 @@ public function test_file_validation_with_b64max_rule() return hyper()->signals(['valid' => true]); }); - $signals = json_encode(['avatar' => $this->validPngBase64]); + $signals = json_encode(['avatar' => $this->createRC6File($this->validPngBase64, 'avatar.png')]); $response = $this->call('POST', '/validate-size', [ 'datastar' => $signals, ], [], [], ['HTTP_DATASTAR_REQUEST' => 'true']); @@ -98,7 +131,7 @@ public function test_file_validation_with_b64dimensions_rule() return hyper()->signals(['valid' => true]); }); - $signals = json_encode(['avatar' => $this->validPngBase64]); + $signals = json_encode(['avatar' => $this->createRC6File($this->validPngBase64, 'avatar.png')]); $response = $this->call('POST', '/validate-dimensions', [ 'datastar' => $signals, ], [], [], ['HTTP_DATASTAR_REQUEST' => 'true']); @@ -113,7 +146,7 @@ public function test_file_validation_with_b64mimes_rule() return hyper()->signals(['valid' => true]); }); - $signals = json_encode(['avatar' => $this->validPngBase64]); + $signals = json_encode(['avatar' => $this->createRC6File($this->validPngBase64, 'avatar.png')]); $response = $this->call('POST', '/validate-mimes', [ 'datastar' => $signals, ], [], [], ['HTTP_DATASTAR_REQUEST' => 'true']); @@ -124,13 +157,13 @@ public function test_file_validation_with_b64mimes_rule() public function test_multiple_file_uploads() { Route::post('/upload-multiple', function () { - $paths = hyperStorage()->storeMultiple(['avatar', 'banner'], 'images', 'public'); + $paths = hyperStorage()->storeMultiple(['avatar' => 'images', 'banner' => 'images'], 'public'); return hyper()->signals(['uploaded' => count($paths), 'paths' => $paths]); }); $signals = json_encode([ - 'avatar' => $this->validPngBase64, - 'banner' => $this->validPngBase64, + 'avatar' => $this->createRC6File($this->validPngBase64, 'avatar.png'), + 'banner' => $this->createRC6File($this->validPngBase64, 'banner.png'), ]); $response = $this->call('POST', '/upload-multiple', [ 'datastar' => $signals, @@ -147,7 +180,7 @@ public function test_file_upload_with_custom_disk() return hyper()->signals(['uploaded' => true, 'disk' => 'custom']); }); - $signals = json_encode(['file' => $this->validPngBase64]); + $signals = json_encode(['file' => $this->createRC6File($this->validPngBase64, 'file.png')]); $response = $this->call('POST', '/upload-custom-disk', [ 'datastar' => $signals, ], [], [], ['HTTP_DATASTAR_REQUEST' => 'true']); @@ -162,7 +195,7 @@ public function test_file_upload_with_custom_directory() return hyper()->signals(['uploaded' => true, 'path' => $path]); }); - $signals = json_encode(['file' => $this->validPngBase64]); + $signals = json_encode(['file' => $this->createRC6File($this->validPngBase64, 'file.png')]); $response = $this->call('POST', '/upload-custom-dir', [ 'datastar' => $signals, ], [], [], ['HTTP_DATASTAR_REQUEST' => 'true']); @@ -177,7 +210,7 @@ public function test_file_upload_with_custom_filename() return hyper()->signals(['uploaded' => true]); }); - $signals = json_encode(['file' => $this->validPngBase64]); + $signals = json_encode(['file' => $this->createRC6File($this->validPngBase64, 'original.png')]); $response = $this->call('POST', '/upload-custom-name', [ 'datastar' => $signals, ], [], [], ['HTTP_DATASTAR_REQUEST' => 'true']); @@ -189,16 +222,17 @@ public function test_file_upload_generates_unique_name() { $paths = []; Route::post('/upload-unique', function () use (&$paths) { - $path = hyperStorage()->store('file', 'uploads', 'public'); + // Use false to force auto-generation instead of using RC6 filename + $path = hyperStorage()->store('file', 'uploads', 'public', false); $paths[] = $path; return hyper()->signals(['path' => $path]); }); - $signals = json_encode(['file' => $this->validPngBase64]); - // Upload twice + $signals = json_encode(['file' => $this->createRC6File($this->validPngBase64, 'same-name.png')]); + // Upload twice with same RC6 filename $this->call('POST', '/upload-unique', ['datastar' => $signals], [], [], ['HTTP_DATASTAR_REQUEST' => 'true']); $this->call('POST', '/upload-unique', ['datastar' => $signals], [], [], ['HTTP_DATASTAR_REQUEST' => 'true']); - // Both should have unique paths + // Both should have unique auto-generated paths $this->assertCount(2, $paths); if (count($paths) === 2) { $this->assertNotEquals($paths[0], $paths[1]); @@ -213,7 +247,7 @@ public function test_file_upload_returns_url() return hyper()->signals(['url' => $url]); }); - $signals = json_encode(['file' => $this->validPngBase64]); + $signals = json_encode(['file' => $this->createRC6File($this->validPngBase64, 'file.png')]); $response = $this->call('POST', '/upload-with-url', [ 'datastar' => $signals, ], [], [], ['HTTP_DATASTAR_REQUEST' => 'true']); @@ -228,7 +262,7 @@ public function test_file_upload_with_visibility() return hyper()->signals(['uploaded' => true]); }); - $signals = json_encode(['file' => $this->validPngBase64]); + $signals = json_encode(['file' => $this->createRC6File($this->validPngBase64, 'file.png')]); $response = $this->call('POST', '/upload-visibility', [ 'datastar' => $signals, ], [], [], ['HTTP_DATASTAR_REQUEST' => 'true']); @@ -247,7 +281,8 @@ public function test_file_upload_error_handling() return hyper()->signals(['error' => $e->getMessage()]); } }); - $signals = json_encode(['file' => 'invalid-base64']); + // Create invalid RC6 format (contents is not valid base64 image) + $signals = json_encode(['file' => $this->createRC6File('invalid-base64', 'file.png')]); $response = $this->call('POST', '/upload-error', [ 'datastar' => $signals, ], [], [], ['HTTP_DATASTAR_REQUEST' => 'true']); @@ -269,7 +304,7 @@ public function test_file_upload_with_large_files() return hyper()->signals(['error' => 'Upload failed']); } }); - $signals = json_encode(['large' => $largeBase64]); + $signals = json_encode(['large' => $this->createRC6File($largeBase64, 'large-file.bin', 'application/octet-stream')]); $response = $this->call('POST', '/upload-large', [ 'datastar' => $signals, ], [], [], ['HTTP_DATASTAR_REQUEST' => 'true']); @@ -281,12 +316,12 @@ public function test_file_upload_validation_fails() { Route::post('/upload-fail', function () { try { - signals()->validate(['avatar' => 'required|b64image|b64max:1']); + signals()->validate(['avatar' => 'required|b64image|b64max:0.01']); } catch (\Exception $e) { return hyper()->signals(['errors' => ['File too large']]); } }); - $signals = json_encode(['avatar' => $this->validPngBase64]); + $signals = json_encode(['avatar' => $this->createRC6File($this->validPngBase64, 'avatar.png')]); $response = $this->call('POST', '/upload-fail', [ 'datastar' => $signals, ], [], [], ['HTTP_DATASTAR_REQUEST' => 'true']); @@ -306,7 +341,7 @@ public function test_file_upload_with_signals() 'uploaded' => true, ]); }); - $signals = json_encode(['avatar' => $this->validPngBase64]); + $signals = json_encode(['avatar' => $this->createRC6File($this->validPngBase64, 'avatar.png')]); $response = $this->call('POST', '/upload-with-signals', [ 'datastar' => $signals, ], [], [], ['HTTP_DATASTAR_REQUEST' => 'true']); @@ -322,7 +357,7 @@ public function test_file_upload_with_progress() 'uploadComplete' => true, ]); }); - $signals = json_encode(['file' => $this->validPngBase64]); + $signals = json_encode(['file' => $this->createRC6File($this->validPngBase64, 'file.png')]); $response = $this->call('POST', '/upload-progress', [ 'datastar' => $signals, ], [], [], ['HTTP_DATASTAR_REQUEST' => 'true']); @@ -338,7 +373,7 @@ public function test_file_storage_helper_integration() return hyper()->signals(['path' => $path]); }); - $signals = json_encode(['avatar' => $this->validPngBase64]); + $signals = json_encode(['avatar' => $this->createRC6File($this->validPngBase64, 'avatar.png')]); $response = $this->call('POST', '/helper-integration', [ 'datastar' => $signals, ], [], [], ['HTTP_DATASTAR_REQUEST' => 'true']); @@ -358,7 +393,7 @@ public function test_file_upload_cleanup_on_error() return hyper()->signals(['error' => $e->getMessage()]); } }); - $signals = json_encode(['avatar' => $this->validPngBase64]); + $signals = json_encode(['avatar' => $this->createRC6File($this->validPngBase64, 'avatar.png')]); $response = $this->call('POST', '/upload-cleanup', [ 'datastar' => $signals, ], [], [], ['HTTP_DATASTAR_REQUEST' => 'true']); @@ -366,15 +401,17 @@ public function test_file_upload_cleanup_on_error() } /** @test */ - public function test_file_upload_with_data_uri_format() + public function test_file_upload_with_data_uri_format_legacy() { + // Test backward compatibility with legacy data URI format Route::post('/upload-data-uri', function () { $path = hyperStorage()->store('file', 'uploads', 'public'); return hyper()->signals(['uploaded' => true]); }); $dataUri = 'data:image/png;base64,' . $this->validPngBase64; - $signals = json_encode(['file' => $dataUri]); + // Legacy format: plain string with data URI + $signals = json_encode(['file' => [$dataUri]]); $response = $this->call('POST', '/upload-data-uri', [ 'datastar' => $signals, ], [], [], ['HTTP_DATASTAR_REQUEST' => 'true']); @@ -395,7 +432,7 @@ public function test_file_upload_performance() 'time' => $executionTime, ]); }); - $signals = json_encode(['file' => $this->validPngBase64]); + $signals = json_encode(['file' => $this->createRC6File($this->validPngBase64, 'file.png')]); $startTime = microtime(true); $response = $this->call('POST', '/upload-perf', [ 'datastar' => $signals, @@ -405,4 +442,81 @@ public function test_file_upload_performance() $response->assertOk(); $this->assertLessThan(2000, $totalTime, 'File upload took too long'); } + + /** @test */ + public function test_rc6_format_preserves_original_filename() + { + Route::post('/rc6-filename', function () { + // Store without custom filename - should use RC6 filename + $path = hyperStorage()->store('photo', 'uploads', 'public'); + $filename = basename($path); + + return hyper()->signals(['filename' => $filename]); + }); + $signals = json_encode(['photo' => $this->createRC6File($this->validPngBase64, 'my-photo.png')]); + $response = $this->call('POST', '/rc6-filename', [ + 'datastar' => $signals, + ], [], [], ['HTTP_DATASTAR_REQUEST' => 'true']); + $response->assertOk(); + // Verify the file was stored with the RC6 filename + Storage::disk('public')->assertExists('uploads/my-photo.png'); + } + + /** @test */ + public function test_rc6_metadata_access() + { + Route::post('/rc6-metadata', function () { + $filename = signals()->getFilename('document'); + $mimeType = signals()->getMimeType('document'); + $isRC6 = signals()->isRC6Format('document'); + + // Assertions to verify the metadata was extracted correctly + $this->assertEquals('report.png', $filename); + $this->assertEquals('image/png', $mimeType); + $this->assertTrue($isRC6); + + return hyper()->signals([ + 'filename' => $filename, + 'mimeType' => $mimeType, + 'isRC6' => $isRC6, + ]); + }); + $signals = json_encode(['document' => $this->createRC6File($this->validPngBase64, 'report.png', 'image/png')]); + $response = $this->call('POST', '/rc6-metadata', [ + 'datastar' => $signals, + ], [], [], ['HTTP_DATASTAR_REQUEST' => 'true']); + $response->assertOk(); + } + + /** @test */ + public function test_backward_compatibility_with_legacy_format() + { + Route::post('/legacy-upload', function () { + $path = hyperStorage()->store('file', 'uploads', 'public'); + + return hyper()->signals(['uploaded' => true, 'path' => $path]); + }); + // Use legacy RC5 format (plain base64 string in array) + $signals = json_encode(['file' => $this->createLegacyFile($this->validPngBase64)]); + $response = $this->call('POST', '/legacy-upload', [ + 'datastar' => $signals, + ], [], [], ['HTTP_DATASTAR_REQUEST' => 'true']); + $response->assertOk(); + } + + /** @test */ + public function test_legacy_format_validation() + { + Route::post('/legacy-validation', function () { + signals()->validate(['avatar' => 'required|b64image|b64max:1024']); + + return hyper()->signals(['valid' => true]); + }); + // Use legacy format + $signals = json_encode(['avatar' => $this->createLegacyFile($this->validPngBase64)]); + $response = $this->call('POST', '/legacy-validation', [ + 'datastar' => $signals, + ], [], [], ['HTTP_DATASTAR_REQUEST' => 'true']); + $response->assertOk(); + } } From 065a2f7be5354aa78adcc3b36ed3b2b1ede9deb4 Mon Sep 17 00:00:00 2001 From: gouater <139002105+dancycodes@users.noreply.github.com> Date: Wed, 12 Nov 2025 15:14:19 +0100 Subject: [PATCH 2/4] fix: data-for almost completely done --- resources/js/hyper.js | 129 ++++++++++++++++---------------------- resources/js/hyper.js.map | 2 +- 2 files changed, 56 insertions(+), 75 deletions(-) diff --git a/resources/js/hyper.js b/resources/js/hyper.js index 946bd46..eab2069 100644 --- a/resources/js/hyper.js +++ b/resources/js/hyper.js @@ -2763,7 +2763,6 @@ attribute({ const sourceSignalPath = parsed.items; const sourceData = getPath(sourceSignalPath); const isNormalized = shouldNormalizeData(sourceData); - const iterationId = `__for_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; const state = { prevKeys: [], lookup: /* @__PURE__ */ new Map(), @@ -2773,8 +2772,7 @@ attribute({ keyExpression, effectCleanup: null, sourceSignalPath, - isNormalized, - iterationId + isNormalized }; iterationStates.set(el, state); const effectCleanup = effect(() => { @@ -2858,22 +2856,6 @@ function diffAndUpdate(newArrayWithKeys, state, ctx) { const prevKeys = state.prevKeys; const changeType = detectChange(prevKeys, newKeys); if (changeType === "no-change") { - if (!state.isNormalized && finalArray.length > 0) { - const firstItem = finalArray[0].item; - if (firstItem && typeof firstItem === "object" && !Array.isArray(firstItem)) { - for (const itemData of finalArray) { - const loopEl = state.lookup.get(itemData.key); - if (loopEl) { - const sanitizedKey = sanitizeKey(itemData.key); - mergePatch({ - [state.iterationId]: { - [sanitizedKey]: itemData.item - } - }); - } - } - } - } return; } else if (changeType === "simple-add") { handleAdd(finalArray, state); @@ -2939,17 +2921,10 @@ function handleRemove(prevKeys, newKeys, state, _ctx, data) { for (const itemData of data) { const loopEl = state.lookup.get(itemData.key); if (loopEl && loopEl.index !== itemData.index) { + const oldIndex = loopEl.index; loopEl.index = itemData.index; if (!state.isNormalized) { - const sanitizedKey = sanitizeKey(itemData.key); - const currentData = root[state.iterationId]?.[sanitizedKey]; - if (currentData !== void 0) { - mergePatch({ - [state.iterationId]: { - [`${sanitizedKey}__index`]: itemData.index - } - }); - } + retransformElement(loopEl.el, state, oldIndex, itemData.index); } } } @@ -2970,17 +2945,10 @@ function handleReorder(data, state, ctx) { const existing = state.lookup.get(item.key); if (existing) { if (existing.index !== item.index) { + const oldIndex = existing.index; existing.index = item.index; if (!state.isNormalized) { - const sanitizedKey = sanitizeKey(item.key); - const currentData = root[state.iterationId]?.[sanitizedKey]; - if (currentData !== void 0) { - mergePatch({ - [state.iterationId]: { - [`${sanitizedKey}__index`]: item.index - } - }); - } + retransformElement(existing.el, state, oldIndex, item.index); } } if (existing.el.previousElementSibling !== prevEl) { @@ -2997,33 +2965,17 @@ function handleReorder(data, state, ctx) { } } function createElement(itemData, state, _ctx) { - const { templateContent, sourceSignalPath, iteratorNames, isNormalized, iterationId } = state; + const { templateContent, sourceSignalPath, iteratorNames, isNormalized } = state; const clone = templateContent.cloneNode(true); const el = clone.firstElementChild; - let signalPath = sourceSignalPath; - let indexSignalPath = null; - if (!isNormalized) { - const sanitizedKey = sanitizeKey(itemData.key); - signalPath = `${iterationId}.${sanitizedKey}`; - indexSignalPath = `${iterationId}.${sanitizedKey}__index`; - mergePatch({ - [iterationId]: { - [sanitizedKey]: itemData.item, - [`${sanitizedKey}__index`]: itemData.index - } - }); - } - transformElement(el, iteratorNames, itemData.index, signalPath, indexSignalPath, isNormalized, itemData.item); + transformElement(el, iteratorNames, itemData.index, sourceSignalPath, isNormalized, itemData.item); return { el, key: itemData.key, index: itemData.index }; } -function sanitizeKey(key) { - return String(key).replace(/[^a-zA-Z0-9_]/g, "_"); -} -function transformElement(el, iterators, index, signalPath, indexSignalPath, isNormalized, value) { +function transformElement(el, iterators, index, sourceArrayPath, isNormalized, value) { const process = (elem) => { Array.from(elem.attributes).forEach((attr) => { if (attr.name.startsWith("data-") && attr.value) { @@ -3032,8 +2984,7 @@ function transformElement(el, iterators, index, signalPath, indexSignalPath, isN attr.name, iterators, index, - signalPath, - indexSignalPath, + sourceArrayPath, isNormalized, value ); @@ -3047,56 +2998,52 @@ function transformElement(el, iterators, index, signalPath, indexSignalPath, isN }; process(el); } -function transformExpression(expr, attrName, iterators, index, signalPath, indexSignalPath, isNormalized, value) { +function transformExpression(expr, attrName, iterators, index, sourceArrayPath, isNormalized, value) { let result = expr; const isSignalName = /^(bind|ref|indicator|signals|computed)/.test(attrName.replace(/^data-/, "")); const literals = []; result = result.replace(/'(?:[^'\\]|\\.)*'/g, (m) => (literals.push(m), `__LIT${literals.length - 1}__`)).replace(/"(?:[^"\\]|\\.)*"/g, (m) => (literals.push(m), `__LIT${literals.length - 1}__`)).replace(/`(?:[^`\\]|\\.)*`/g, (m) => (literals.push(m), `__LIT${literals.length - 1}__`)); if (iterators.index) { - if (indexSignalPath && !isNormalized) { - const prefix = isSignalName ? "" : "$"; - result = result.replace(new RegExp(`\\b${esc(iterators.index)}\\b`, "g"), prefix + indexSignalPath); - } else { - result = result.replace(new RegExp(`\\b${esc(iterators.index)}\\b`, "g"), index.toString()); - } + result = result.replace(new RegExp(`\\b${esc(iterators.index)}\\b`, "g"), index.toString()); } if (iterators.collection) { const prefix = isSignalName ? "" : "$"; - result = result.replace(new RegExp(`\\b${esc(iterators.collection)}\\b`, "g"), prefix + signalPath); + result = result.replace(new RegExp(`\\b${esc(iterators.collection)}\\b`, "g"), prefix + sourceArrayPath); } if (isDestructured(iterators.item)) { const vars = extractVars(iterators.item); const isArrayDestructuring = iterators.item.trim().startsWith("["); vars.forEach((v, idx) => { - let varSignalPath; + let varPath; let varValue; if (isArrayDestructuring) { - varSignalPath = `${signalPath}.${idx}`; + varPath = isNormalized ? `${sourceArrayPath}.${idx}` : `${sourceArrayPath}.${index}.${idx}`; varValue = Array.isArray(value) ? value[idx] : value; } else { - varSignalPath = `${signalPath}.${v}`; + varPath = isNormalized ? `${sourceArrayPath}.${v}` : `${sourceArrayPath}.${index}.${v}`; varValue = value && typeof value === "object" ? value[v] : value; } - result = transformVar(result, v, varSignalPath, isSignalName, isNormalized, varValue); + result = transformVar(result, v, varPath, isSignalName, isNormalized, varValue); }); } else { - result = transformVar(result, iterators.item, signalPath, isSignalName, isNormalized, value); + const itemPath = isNormalized ? sourceArrayPath : `${sourceArrayPath}.${index}`; + result = transformVar(result, iterators.item, itemPath, isSignalName, isNormalized, value); } result = result.replace(/__LIT(\d+)__/g, (_, i) => literals[parseInt(i)]); return result; } -function transformVar(expr, varName, signalPath, isSignalName, isNormalized, value, _iterators) { +function transformVar(expr, varName, arrayPath, isSignalName, isNormalized, value) { if (isNormalized) { return expr.replace(new RegExp(`\\b${esc(varName)}\\b(?!\\.)`, "g"), JSON.stringify(value)); } const prefix = isSignalName ? "" : "$"; expr = expr.replace( new RegExp(`\\b${esc(varName)}\\.([a-zA-Z_][a-zA-Z0-9_.]*)\\b`, "g"), - `${prefix}${signalPath}.$1` + `${prefix}${arrayPath}.$1` ); expr = expr.replace( new RegExp(`\\b${esc(varName)}\\b(?!\\.)`, "g"), - prefix + signalPath + prefix + arrayPath ); return expr; } @@ -3147,6 +3094,40 @@ function extractVars(item) { function esc(str) { return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } +function retransformElement(el, state, oldIndex, newIndex) { + if (oldIndex === newIndex) return; + const sourceArrayPath = state.sourceSignalPath; + const oldIndexPattern = new RegExp( + `(\\$?)\\b${esc(sourceArrayPath)}\\.(${oldIndex})\\b`, + "g" + ); + const processElement = (elem) => { + Array.from(elem.attributes).forEach((attr) => { + if (attr.name.startsWith("data-") && attr.value) { + attr.value = attr.value.replace(oldIndexPattern, `$1${sourceArrayPath}.${newIndex}`); + } + }); + if (elem instanceof HTMLTemplateElement && elem.content) { + Array.from(elem.content.children).forEach((child) => { + if (!child.hasAttribute("data-for")) { + processElement(child); + } + }); + } else { + Array.from(elem.children).forEach((child) => { + if (!child.hasAttribute("data-for")) { + processElement(child); + } + }); + } + }; + processElement(el); + queueMicrotask(() => { + peek(() => { + apply(el); + }); + }); +} function cleanup(state) { if (state.effectCleanup) { state.effectCleanup(); diff --git a/resources/js/hyper.js.map b/resources/js/hyper.js.map index 4f98194..0038067 100644 --- a/resources/js/hyper.js.map +++ b/resources/js/hyper.js.map @@ -1 +1 @@ -{"version":3,"file":"hyper.js","sources":["../src/datastar/engine/consts.ts","../src/datastar/utils/text.ts","../src/datastar/utils/polyfills.ts","../src/datastar/utils/paths.ts","../src/datastar/engine/signals.ts","../src/datastar/utils/dom.ts","../src/datastar/engine/engine.ts","../src/datastar/plugins/actions/peek.ts","../src/datastar/plugins/actions/setAll.ts","../src/datastar/plugins/actions/toggleAll.ts","../src/datastar/plugins/actions/fetch.ts","../src/datastar/plugins/attributes/attr.ts","../src/datastar/plugins/attributes/bind.ts","../src/datastar/plugins/attributes/class.ts","../src/datastar/plugins/attributes/computed.ts","../src/datastar/plugins/attributes/effect.ts","../src/datastar/plugins/attributes/indicator.ts","../src/datastar/plugins/attributes/jsonSignals.ts","../src/datastar/utils/tags.ts","../src/datastar/utils/timing.ts","../src/datastar/utils/view-transitions.ts","../src/datastar/plugins/attributes/on.ts","../src/datastar/plugins/attributes/onIntersect.ts","../src/datastar/plugins/attributes/onInterval.ts","../src/datastar/plugins/attributes/init.ts","../src/datastar/plugins/attributes/onSignalPatch.ts","../src/datastar/plugins/attributes/ref.ts","../src/datastar/plugins/attributes/show.ts","../src/datastar/plugins/attributes/signals.ts","../src/datastar/plugins/attributes/style.ts","../src/datastar/plugins/attributes/text.ts","../src/datastar/engine/morph.ts","../src/datastar/plugins/watchers/patchElements.ts","../src/datastar/plugins/watchers/patchSignals.ts","../src/hyper-ext/plugins/actions/dispatch.ts","../src/hyper-ext/plugins/actions/fileUrl.ts","../src/hyper-ext/plugins/actions/back.ts","../src/hyper-ext/plugins/actions/refresh.ts","../src/hyper-ext/plugins/actions/reload.ts","../src/hyper-ext/plugins/attributes/error.ts","../src/hyper-ext/plugins/attributes/for.ts","../src/hyper-ext/plugins/attributes/if.ts","../src/hyper-ext/plugins/attributes/navigate.ts","../src/hyper-ext/utils/csrf.ts","../src/hyper-ext/plugins/backend/actions/fetch-laravel.ts","../src/hyper-ext/plugins/backend/actions/postx.ts","../src/hyper-ext/plugins/backend/actions/putx.ts","../src/hyper-ext/plugins/backend/actions/patchx.ts","../src/hyper-ext/plugins/backend/actions/deletex.ts","../src/hyper-ext/plugins/backend/actions/navigate.ts","../src/hyper-ext/plugins/backend/watchers/responseInterceptor.ts","../src/hyper-ext/plugins/backend/watchers/globalNavigate.ts","../src/hyper-ext/bundles/hyper.ts"],"sourcesContent":["const lol = /🖕JS_DS🚀/.source\nexport const DSP = lol.slice(0, 5)\nexport const DSS = lol.slice(4)\nexport const DATASTAR_FETCH_EVENT = 'datastar-fetch'\nexport const DATASTAR_SIGNAL_PATCH_EVENT = 'datastar-signal-patch'\n","import type { Modifiers } from '@engine/types'\n\nexport const kebab = (str: string): string =>\n str\n .replace(/([a-z0-9])([A-Z])/g, '$1-$2')\n .replace(/([a-z])([0-9]+)/gi, '$1-$2')\n .replace(/([0-9]+)([a-z])/gi, '$1-$2')\n .toLowerCase()\n\nexport const camel = (str: string): string =>\n kebab(str).replace(/-./g, (x) => x[1].toUpperCase())\n\nexport const snake = (str: string): string => kebab(str).replace(/-/g, '_')\n\nexport const pascal = (str: string): string =>\n camel(str).replace(/(^.|(?<=\\.).)/g, (x) => x[0].toUpperCase())\n\nexport const jsStrToObject = (raw: string) => {\n try {\n return JSON.parse(raw)\n } catch {\n // If JSON parsing fails, try to evaluate as a JavaScript object\n // This is less safe and should be used with caution\n return Function(`return (${raw})`)()\n }\n}\n\nconst caseFns: Record string> = {\n camel: (str) => str.replace(/-[a-z]/g, (x) => x[1].toUpperCase()),\n snake: (str) => str.replace(/-/g, '_'),\n pascal: (str) => str[0].toUpperCase() + caseFns.camel(str.slice(1)),\n}\n\nexport const modifyCasing = (\n str: string,\n mods: Modifiers,\n defaultCase = 'camel',\n): string => {\n for (const c of mods.get('case') || [defaultCase]) {\n str = caseFns[c]?.(str) || str\n }\n return str\n}\n\nexport const aliasify = (name: string) =>\n ALIAS ? `data-${ALIAS}-${name}` : `data-${name}`\n","export const hasOwn: (obj: object, prop: PropertyKey) => boolean =\n // @ts-expect-error\n Object.hasOwn ?? Object.prototype.hasOwnProperty.call\n","import type { Paths } from '@engine/types'\nimport { hasOwn } from '@utils/polyfills'\n\nexport const isPojo = (obj: any): obj is Record =>\n obj !== null &&\n typeof obj === 'object' &&\n (Object.getPrototypeOf(obj) === Object.prototype ||\n Object.getPrototypeOf(obj) === null)\n\nexport const isEmpty = (obj: Record): boolean => {\n for (const prop in obj) {\n if (hasOwn(obj, prop)) {\n return false\n }\n }\n return true\n}\n\nexport const updateLeaves = (\n obj: Record,\n fn: (oldValue: any) => any,\n) => {\n for (const key in obj) {\n const val = obj[key]\n if (isPojo(val) || Array.isArray(val)) {\n updateLeaves(val, fn)\n } else {\n obj[key] = fn(val)\n }\n }\n}\n\nexport const pathToObj = (paths: Paths): Record => {\n const result: Record = {}\n for (const [path, value] of paths) {\n const keys = path.split('.')\n const lastKey = keys.pop()!\n const obj = keys.reduce((acc, key) => (acc[key] ??= {}), result)\n obj[lastKey] = value\n }\n return result\n}\n","import { DATASTAR_SIGNAL_PATCH_EVENT } from '@engine/consts'\nimport type {\n Computed,\n Effect,\n JSONPatch,\n MergePatchArgs,\n Paths,\n Signal,\n SignalFilterOptions,\n} from '@engine/types'\nimport { isPojo, pathToObj } from '@utils/paths'\nimport { hasOwn } from '@utils/polyfills'\n\ninterface ReactiveNode {\n deps_?: Link\n depsTail_?: Link\n subs_?: Link\n subsTail_?: Link\n flags_: ReactiveFlags\n}\n\ninterface Link {\n version_: number\n dep_: ReactiveNode\n sub_: ReactiveNode\n prevSub_?: Link\n nextSub_?: Link\n prevDep_?: Link\n nextDep_?: Link\n}\n\ninterface Stack {\n value_: T\n prev_?: Stack\n}\n\nenum ReactiveFlags {\n None = 0,\n Mutable = 1 << 0,\n Watching = 1 << 1,\n RecursedCheck = 1 << 2,\n Recursed = 1 << 3,\n Dirty = 1 << 4,\n Pending = 1 << 5,\n}\n\nenum EffectFlags {\n Queued = 1 << 6,\n}\n\ninterface AlienEffect extends ReactiveNode {\n fn_(): void\n}\n\ninterface AlienComputed extends ReactiveNode {\n value_?: T\n getter(previousValue?: T): T\n}\n\ninterface AlienSignal extends ReactiveNode {\n previousValue: T\n value_: T\n}\n\nconst currentPatch: Paths = []\nconst queuedEffects: (AlienEffect | undefined)[] = []\nlet batchDepth = 0\nlet notifyIndex = 0\nlet queuedEffectsLength = 0\nlet prevSub: ReactiveNode | undefined\nlet activeSub: ReactiveNode | undefined\nlet version = 0\n\nexport const beginBatch = (): void => {\n batchDepth++\n}\n\nexport const endBatch = (): void => {\n if (!--batchDepth) {\n flush()\n dispatch()\n }\n}\n\nexport const startPeeking = (sub?: ReactiveNode): void => {\n prevSub = activeSub\n activeSub = sub\n}\n\nexport const stopPeeking = (): void => {\n activeSub = prevSub\n prevSub = undefined\n}\n\nexport const signal = (initialValue?: T): Signal => {\n return signalOper.bind(0, {\n previousValue: initialValue,\n value_: initialValue,\n flags_: 1 satisfies ReactiveFlags.Mutable,\n }) as Signal\n}\n\nconst computedSymbol = Symbol('computed')\nexport const computed = (getter: (previousValue?: T) => T): Computed => {\n const c = computedOper.bind(0, {\n flags_: 17 as ReactiveFlags.Mutable | ReactiveFlags.Dirty,\n getter,\n }) as Computed\n // @ts-expect-error\n c[computedSymbol] = 1\n return c\n}\n\nexport const effect = (fn: () => void): Effect => {\n const e: AlienEffect = {\n fn_: fn,\n flags_: 2 satisfies ReactiveFlags.Watching,\n }\n if (activeSub) {\n link(e, activeSub)\n }\n startPeeking(e)\n beginBatch()\n try {\n e.fn_()\n } finally {\n endBatch()\n stopPeeking()\n }\n return effectOper.bind(0, e)\n}\n\nconst flush = () => {\n while (notifyIndex < queuedEffectsLength) {\n const effect = queuedEffects[notifyIndex]!\n queuedEffects[notifyIndex++] = undefined\n run(effect, (effect.flags_ &= ~EffectFlags.Queued))\n }\n notifyIndex = 0\n queuedEffectsLength = 0\n}\n\nconst update = (signal: AlienSignal | AlienComputed): boolean => {\n if ('getter' in signal) {\n return updateComputed(signal)\n }\n return updateSignal(signal, signal.value_)\n}\n\nconst updateComputed = (c: AlienComputed): boolean => {\n startPeeking(c)\n startTracking(c)\n try {\n const oldValue = c.value_\n return oldValue !== (c.value_ = c.getter(oldValue))\n } finally {\n stopPeeking()\n endTracking(c)\n }\n}\n\nconst updateSignal = (s: AlienSignal, value: T): boolean => {\n s.flags_ = 1 satisfies ReactiveFlags.Mutable\n return s.previousValue !== (s.previousValue = value)\n}\n\nconst notify = (e: AlienEffect): void => {\n const flags = e.flags_\n if (!(flags & EffectFlags.Queued)) {\n e.flags_ = flags | EffectFlags.Queued\n const subs = e.subs_\n if (subs) {\n notify(subs.sub_ as AlienEffect)\n } else {\n queuedEffects[queuedEffectsLength++] = e\n }\n }\n}\n\nconst run = (e: AlienEffect, flags: ReactiveFlags): void => {\n if (\n flags & (16 satisfies ReactiveFlags.Dirty) ||\n (flags & (32 satisfies ReactiveFlags.Pending) && checkDirty(e.deps_!, e))\n ) {\n startPeeking(e)\n startTracking(e)\n beginBatch()\n try {\n e.fn_()\n } finally {\n endBatch()\n stopPeeking()\n endTracking(e)\n }\n return\n }\n if (flags & (32 satisfies ReactiveFlags.Pending)) {\n e.flags_ = flags & ~(32 satisfies ReactiveFlags.Pending)\n }\n let link = e.deps_\n while (link) {\n const dep = link.dep_\n const depFlags = dep.flags_\n if (depFlags & EffectFlags.Queued) {\n run(dep as AlienEffect, (dep.flags_ = depFlags & ~EffectFlags.Queued))\n }\n link = link.nextDep_\n }\n}\n\nconst signalOper = (s: AlienSignal, ...value: [T]): T | boolean => {\n if (value.length) {\n if (s.value_ !== (s.value_ = value[0])) {\n s.flags_ = 17 as ReactiveFlags.Mutable | ReactiveFlags.Dirty\n const subs = s.subs_\n if (subs) {\n propagate(subs)\n if (!batchDepth) {\n flush()\n }\n }\n return true\n }\n return false\n }\n const currentValue = s.value_\n if (s.flags_ & (16 satisfies ReactiveFlags.Dirty)) {\n if (updateSignal(s, currentValue)) {\n const subs_ = s.subs_\n if (subs_) {\n shallowPropagate(subs_)\n }\n }\n }\n if (activeSub) {\n link(s, activeSub)\n }\n return currentValue\n}\n\nconst computedOper = (c: AlienComputed): T => {\n const flags = c.flags_\n if (\n flags & (16 satisfies ReactiveFlags.Dirty) ||\n (flags & (32 satisfies ReactiveFlags.Pending) && checkDirty(c.deps_!, c))\n ) {\n if (updateComputed(c)) {\n const subs = c.subs_\n if (subs) {\n shallowPropagate(subs)\n }\n }\n } else if (flags & (32 satisfies ReactiveFlags.Pending)) {\n c.flags_ = flags & ~(32 satisfies ReactiveFlags.Pending)\n }\n if (activeSub) {\n link(c, activeSub)\n }\n return c.value_!\n}\n\nconst effectOper = (e: AlienEffect): void => {\n let dep = e.deps_\n while (dep) {\n dep = unlink(dep, e)\n }\n const sub = e.subs_\n if (sub) {\n unlink(sub)\n }\n e.flags_ = 0 satisfies ReactiveFlags.None\n}\n\nconst link = (dep: ReactiveNode, sub: ReactiveNode): void => {\n const prevDep = sub.depsTail_\n if (prevDep && prevDep.dep_ === dep) {\n return\n }\n const nextDep = prevDep ? prevDep.nextDep_ : sub.deps_\n if (nextDep && nextDep.dep_ === dep) {\n nextDep.version_ = version\n sub.depsTail_ = nextDep\n return\n }\n const prevSub = dep.subsTail_\n if (prevSub && prevSub.version_ === version && prevSub.sub_ === sub) {\n return\n }\n const newLink =\n (sub.depsTail_ =\n dep.subsTail_ =\n {\n version_: version,\n dep_: dep,\n sub_: sub,\n prevDep_: prevDep,\n nextDep_: nextDep,\n prevSub_: prevSub,\n })\n if (nextDep) {\n nextDep.prevDep_ = newLink\n }\n if (prevDep) {\n prevDep.nextDep_ = newLink\n } else {\n sub.deps_ = newLink\n }\n if (prevSub) {\n prevSub.nextSub_ = newLink\n } else {\n dep.subs_ = newLink\n }\n}\n\nconst unlink = (link: Link, sub = link.sub_): Link | undefined => {\n const dep_ = link.dep_\n const prevDep_ = link.prevDep_\n const nextDep_ = link.nextDep_\n const nextSub_ = link.nextSub_\n const prevSub_ = link.prevSub_\n if (nextDep_) {\n nextDep_.prevDep_ = prevDep_\n } else {\n sub.depsTail_ = prevDep_\n }\n if (prevDep_) {\n prevDep_.nextDep_ = nextDep_\n } else {\n sub.deps_ = nextDep_\n }\n if (nextSub_) {\n nextSub_.prevSub_ = prevSub_\n } else {\n dep_.subsTail_ = prevSub_\n }\n if (prevSub_) {\n prevSub_.nextSub_ = nextSub_\n } else if (!(dep_.subs_ = nextSub_)) {\n if ('getter' in dep_) {\n let toRemove = dep_.deps_\n if (toRemove) {\n dep_.flags_ = 17 as ReactiveFlags.Mutable | ReactiveFlags.Dirty\n do {\n toRemove = unlink(toRemove, dep_)\n } while (toRemove)\n }\n } else if (!('previousValue' in dep_)) {\n effectOper(dep_ as AlienEffect)\n }\n }\n return nextDep_\n}\n\nconst propagate = (link: Link): void => {\n let next = link.nextSub_\n let stack: Stack | undefined\n\n top: while (true) {\n const sub = link.sub_\n\n let flags = sub.flags_\n\n if (\n !(\n flags &\n (60 as\n | ReactiveFlags.RecursedCheck\n | ReactiveFlags.Recursed\n | ReactiveFlags.Dirty\n | ReactiveFlags.Pending)\n )\n ) {\n sub.flags_ = flags | (32 satisfies ReactiveFlags.Pending)\n } else if (\n !(flags & (12 as ReactiveFlags.RecursedCheck | ReactiveFlags.Recursed))\n ) {\n flags = 0 satisfies ReactiveFlags.None\n } else if (!(flags & (4 satisfies ReactiveFlags.RecursedCheck))) {\n sub.flags_ =\n (flags & ~(8 satisfies ReactiveFlags.Recursed)) |\n (32 satisfies ReactiveFlags.Pending)\n } else if (\n !(flags & (48 as ReactiveFlags.Dirty | ReactiveFlags.Pending)) &&\n isValidLink(link, sub)\n ) {\n sub.flags_ =\n flags | (40 as ReactiveFlags.Recursed | ReactiveFlags.Pending)\n flags &= 1 satisfies ReactiveFlags.Mutable\n } else {\n flags = 0 satisfies ReactiveFlags.None\n }\n\n if (flags & (2 satisfies ReactiveFlags.Watching)) {\n notify(sub as AlienEffect)\n }\n\n if (flags & (1 satisfies ReactiveFlags.Mutable)) {\n const subSubs = sub.subs_\n if (subSubs) {\n const nextSub = (link = subSubs).nextSub_\n if (nextSub) {\n stack = { value_: next, prev_: stack }\n next = nextSub\n }\n continue\n }\n }\n\n if ((link = next!)) {\n next = link.nextSub_\n continue\n }\n\n while (stack) {\n link = stack.value_!\n stack = stack.prev_\n if (link) {\n next = link.nextSub_\n continue top\n }\n }\n\n break\n }\n}\n\nconst startTracking = (sub: ReactiveNode): void => {\n version++\n sub.depsTail_ = undefined\n sub.flags_ =\n (sub.flags_ &\n ~(56 as\n | ReactiveFlags.Recursed\n | ReactiveFlags.Dirty\n | ReactiveFlags.Pending)) |\n (4 satisfies ReactiveFlags.RecursedCheck)\n}\n\nconst endTracking = (sub: ReactiveNode): void => {\n const depsTail_ = sub.depsTail_\n let toRemove = depsTail_ ? depsTail_.nextDep_ : sub.deps_\n while (toRemove) {\n toRemove = unlink(toRemove, sub)\n }\n sub.flags_ &= ~(4 satisfies ReactiveFlags.RecursedCheck)\n}\n\nconst checkDirty = (link: Link, sub: ReactiveNode): boolean => {\n let stack: Stack | undefined\n let checkDepth = 0\n let dirty = false\n\n top: while (true) {\n const dep = link.dep_\n const flags = dep.flags_\n\n if (sub.flags_ & (16 satisfies ReactiveFlags.Dirty)) {\n dirty = true\n } else if (\n (flags & (17 as ReactiveFlags.Mutable | ReactiveFlags.Dirty)) ===\n (17 as ReactiveFlags.Mutable | ReactiveFlags.Dirty)\n ) {\n if (update(dep as AlienSignal | AlienComputed)) {\n const subs = dep.subs_!\n if (subs.nextSub_) {\n shallowPropagate(subs)\n }\n dirty = true\n }\n } else if (\n (flags & (33 as ReactiveFlags.Mutable | ReactiveFlags.Pending)) ===\n (33 as ReactiveFlags.Mutable | ReactiveFlags.Pending)\n ) {\n if (link.nextSub_ || link.prevSub_) {\n stack = { value_: link, prev_: stack }\n }\n link = dep.deps_!\n sub = dep\n ++checkDepth\n continue\n }\n\n if (!dirty) {\n const nextDep = link.nextDep_\n if (nextDep) {\n link = nextDep\n continue\n }\n }\n\n while (checkDepth--) {\n const firstSub = sub.subs_!\n const hasMultipleSubs = firstSub.nextSub_\n if (hasMultipleSubs) {\n link = stack!.value_\n stack = stack!.prev_\n } else {\n link = firstSub\n }\n if (dirty) {\n if (update(sub as AlienSignal | AlienComputed)) {\n if (hasMultipleSubs) {\n shallowPropagate(firstSub)\n }\n sub = link.sub_\n continue\n }\n dirty = false\n } else {\n sub.flags_ &= ~(32 satisfies ReactiveFlags.Pending)\n }\n sub = link.sub_\n if (link.nextDep_) {\n link = link.nextDep_\n continue top\n }\n }\n\n return dirty\n }\n}\n\nconst shallowPropagate = (link: Link): void => {\n do {\n const sub = link.sub_\n const flags = sub.flags_\n if (\n (flags & (48 as ReactiveFlags.Pending | ReactiveFlags.Dirty)) ===\n (32 satisfies ReactiveFlags.Pending)\n ) {\n sub.flags_ = flags | (16 satisfies ReactiveFlags.Dirty)\n if (flags & (2 satisfies ReactiveFlags.Watching)) {\n notify(sub as AlienEffect)\n }\n }\n } while ((link = link.nextSub_!))\n}\n\nconst isValidLink = (checkLink: Link, sub: ReactiveNode): boolean => {\n let link = sub.depsTail_\n while (link) {\n if (link === checkLink) {\n return true\n }\n link = link.prevDep_\n }\n return false\n}\n\nexport const getPath = (path: string): T | undefined => {\n let result = root\n const split = path.split('.')\n for (const path of split) {\n if (result == null || !hasOwn(result, path)) {\n return\n }\n result = result[path]\n }\n return result as T\n}\n\nconst deep = (value: any, prefix = ''): any => {\n const isArr = Array.isArray(value)\n if (isArr || isPojo(value)) {\n const deepObj = (isArr ? [] : {}) as Record>\n for (const key in value) {\n deepObj[key] = signal(\n deep((value as Record>)[key], `${prefix + key}.`),\n )\n }\n const keys = signal(0)\n return new Proxy(deepObj, {\n get(_, prop: string) {\n // JSON.stringify calls `.toJSON()` directly instead of checking if it exists on the object\n // so we have to check if `toJSON` is being called and prevent a signal from automatically\n // being made so JSON.stringify can fallback to the default stringify\n if (!(prop === 'toJSON' && !hasOwn(deepObj, prop))) {\n // special case for when prop is an array function because every array function needs to\n // be reactive to when the keys change\n if (isArr && prop in Array.prototype) {\n keys()\n return deepObj[prop]\n }\n // if prop is a symbol just return the symbol because we don't want to make up that theres\n // an iterator symbol on an object or not\n if (typeof prop === 'symbol') {\n return deepObj[prop]\n }\n // if obj doesnt have prop OR prop is null or undefined then create a signal and default\n // to an empty string\n if (!hasOwn(deepObj, prop) || deepObj[prop]() == null) {\n deepObj[prop] = signal('')\n dispatch(prefix + prop, '')\n keys(keys() + 1)\n }\n return deepObj[prop]()\n }\n },\n set(_, prop: string, newValue) {\n const path = prefix + prop\n // special case for when setting length so we can make a diff patch\n if (isArr && prop === 'length') {\n const diff = (deepObj[prop] as unknown as number) - newValue\n deepObj[prop] = newValue\n // manually make a diff patch for now, shouldnt have to do this when object diffing is\n // implemented. see https://github.com/starfederation/datastar-dev/issues/274\n if (diff > 0) {\n const patch: Record = {}\n for (let i = newValue; i < deepObj[prop]; i++) {\n patch[i] = null\n }\n dispatch(prefix.slice(0, -1), patch)\n keys(keys() + 1)\n }\n } else if (hasOwn(deepObj, prop)) {\n if (newValue == null) {\n delete deepObj[prop]\n // if newValue is a computed set the computed directly instead of wrapping in signal\n } else if (hasOwn(newValue, computedSymbol)) {\n deepObj[prop] = newValue\n dispatch(path, '')\n // if prop changed after setting it then dispatch\n } else if (deepObj[prop](deep(newValue, `${path}.`))) {\n dispatch(path, newValue)\n }\n // if newValue is null or undefined then noop\n } else if (newValue != null) {\n // if newValue is a computed set the computed directly instead of wrapping in signal\n if (hasOwn(newValue, computedSymbol)) {\n deepObj[prop] = newValue\n dispatch(path, '')\n } else {\n deepObj[prop] = signal(deep(newValue, `${path}.`))\n dispatch(path, newValue)\n }\n keys(keys() + 1)\n }\n\n return true\n },\n deleteProperty(_, prop: string) {\n delete deepObj[prop]\n keys(keys() + 1)\n return true\n },\n ownKeys() {\n keys()\n return Reflect.ownKeys(deepObj)\n },\n has(_, prop) {\n keys()\n return prop in deepObj\n },\n })\n }\n return value\n}\n\nconst dispatch = (path?: string, value?: any) => {\n if (path !== undefined && value !== undefined) {\n currentPatch.push([path, value])\n }\n if (!batchDepth && currentPatch.length) {\n const detail = pathToObj(currentPatch)\n currentPatch.length = 0\n document.dispatchEvent(\n new CustomEvent(DATASTAR_SIGNAL_PATCH_EVENT, {\n detail,\n }),\n )\n }\n}\n\nexport const mergePatch = (\n patch: JSONPatch,\n { ifMissing }: MergePatchArgs = {},\n): void => {\n beginBatch()\n for (const key in patch) {\n if (patch[key] == null) {\n if (!ifMissing) {\n delete root[key]\n }\n } else {\n mergeInner(patch[key], key, root, '', ifMissing)\n }\n }\n endBatch()\n}\n\nexport const mergePaths = (paths: Paths, options?: MergePatchArgs): void =>\n mergePatch(pathToObj(paths), options)\n\nconst mergeInner = (\n patch: any,\n target: string,\n targetParent: Record,\n prefix: string,\n ifMissing: boolean | undefined,\n): void => {\n if (isPojo(patch)) {\n if (\n !(\n hasOwn(targetParent, target) &&\n (isPojo(targetParent[target]) || Array.isArray(targetParent[target]))\n )\n ) {\n targetParent[target] = {}\n }\n\n for (const key in patch) {\n if (patch[key] == null) {\n if (!ifMissing) {\n delete targetParent[target][key]\n }\n } else {\n mergeInner(\n patch[key],\n key,\n targetParent[target],\n `${prefix + target}.`,\n ifMissing,\n )\n }\n }\n } else if (!(ifMissing && hasOwn(targetParent, target))) {\n targetParent[target] = patch\n }\n}\n\nconst toRegExp = (val: string | RegExp): RegExp =>\n typeof val === 'string' ? RegExp(val.replace(/^\\/|\\/$/g, '')) : val\n\n/**\n * Filters the root store based on an include and exclude RegExp\n *\n * @returns The filtered object\n */\nexport const filtered = (\n { include = /.*/, exclude = /(?!)/ }: SignalFilterOptions = {},\n obj: JSONPatch = root,\n): Record => {\n const includeRe = toRegExp(include)\n const excludeRe = toRegExp(exclude)\n const paths: Paths = []\n const stack: [any, string][] = [[obj, '']]\n\n while (stack.length) {\n const [node, prefix] = stack.pop()!\n\n for (const key in node) {\n const path = prefix + key\n if (isPojo(node[key])) {\n stack.push([node[key], `${path}.`])\n } else if (includeRe.test(path) && !excludeRe.test(path)) {\n paths.push([path, getPath(path)])\n }\n }\n }\n\n return pathToObj(paths)\n}\n\nexport const root: Record = deep({})\n","import type { HTMLOrSVG } from '@engine/types'\n\nexport const isHTMLOrSVG = (el: Node): el is HTMLOrSVG =>\n el instanceof HTMLElement ||\n el instanceof SVGElement ||\n el instanceof MathMLElement\n","import { DATASTAR_FETCH_EVENT, DSP, DSS } from '@engine/consts'\nimport { snake } from '@utils/text'\nimport { root } from '@engine/signals'\nimport type {\n ActionPlugin,\n ActionContext,\n AttributeContext,\n AttributePlugin,\n DatastarFetchEvent,\n HTMLOrSVG,\n Requirement,\n WatcherPlugin,\n} from '@engine/types'\nimport { isHTMLOrSVG } from '@utils/dom'\nimport { aliasify } from '@utils/text'\n\nconst url = 'https://data-star.dev/errors'\n\nconst error = (\n ctx: Record,\n reason: string,\n metadata: Record = {},\n) => {\n Object.assign(metadata, ctx)\n const e = new Error()\n const r = snake(reason)\n const q = new URLSearchParams({\n metadata: JSON.stringify(metadata),\n }).toString()\n const c = JSON.stringify(metadata, null, 2)\n e.message = `${reason}\\nMore info: ${url}/${r}?${q}\\nContext: ${c}`\n return e\n}\n\nconst actionPlugins: Map = new Map()\nconst attributePlugins: Map = new Map()\nconst watcherPlugins: Map = new Map()\n\nexport const actions: Record<\n string,\n (ctx: ActionContext, ...args: any[]) => any\n> = new Proxy(\n {},\n {\n get: (_, prop: string) => actionPlugins.get(prop)?.apply,\n has: (_, prop: string) => actionPlugins.has(prop),\n ownKeys: () => Reflect.ownKeys(actionPlugins),\n set: () => false,\n deleteProperty: () => false,\n },\n)\n\n// Map of cleanups keyed by element and attribute name\nconst removals = new Map void>>()\n\nconst queuedAttributes: AttributePlugin[] = []\nconst queuedAttributeNames = new Set()\nexport const attribute = (\n plugin: AttributePlugin,\n): void => {\n queuedAttributes.push(plugin as unknown as AttributePlugin)\n\n if (queuedAttributes.length === 1) {\n setTimeout(() => {\n for (const attribute of queuedAttributes) {\n queuedAttributeNames.add(attribute.name)\n attributePlugins.set(attribute.name, attribute)\n }\n queuedAttributes.length = 0\n apply()\n queuedAttributeNames.clear()\n })\n }\n}\n\nexport const action = (plugin: ActionPlugin): void => {\n actionPlugins.set(plugin.name, plugin)\n}\n\ndocument.addEventListener(DATASTAR_FETCH_EVENT, ((\n evt: CustomEvent,\n) => {\n const plugin = watcherPlugins.get(evt.detail.type)\n if (plugin) {\n plugin.apply(\n {\n error: error.bind(0, {\n plugin: { type: 'watcher', name: plugin.name },\n element: {\n id: (evt.target as Element).id,\n tag: (evt.target as Element).tagName,\n },\n }),\n },\n evt.detail.argsRaw,\n )\n }\n}) as EventListener)\n\nexport const watcher = (plugin: WatcherPlugin): void => {\n watcherPlugins.set(plugin.name, plugin)\n}\n\nconst cleanupEls = (els: Iterable): void => {\n for (const el of els) {\n const cleanups = removals.get(el)\n // If removals has el, delete it and run all cleanup functions\n if (removals.delete(el)) {\n for (const cleanup of cleanups!.values()) {\n cleanup()\n }\n cleanups!.clear()\n }\n }\n}\n\nconst aliasedIgnore = aliasify('ignore')\nconst aliasedIgnoreAttr = `[${aliasedIgnore}]`\nconst shouldIgnore = (el: HTMLOrSVG) =>\n el.hasAttribute(`${aliasedIgnore}__self`) || !!el.closest(aliasedIgnoreAttr)\n\nconst applyEls = (els: Iterable, onlyNew?: boolean): void => {\n for (const el of els) {\n if (!shouldIgnore(el)) {\n for (const key in el.dataset) {\n applyAttributePlugin(\n el,\n key.replace(/[A-Z]/g, '-$&').toLowerCase(),\n el.dataset[key]!,\n onlyNew,\n )\n }\n }\n }\n}\n\nconst observe = (mutations: MutationRecord[]) => {\n for (const {\n target,\n type,\n attributeName,\n addedNodes,\n removedNodes,\n } of mutations) {\n if (type === 'childList') {\n for (const node of removedNodes) {\n if (isHTMLOrSVG(node)) {\n cleanupEls([node])\n cleanupEls(node.querySelectorAll('*'))\n }\n }\n\n for (const node of addedNodes) {\n if (isHTMLOrSVG(node)) {\n applyEls([node])\n applyEls(node.querySelectorAll('*'))\n }\n }\n } else if (\n type === 'attributes' &&\n attributeName!.startsWith('data-') &&\n isHTMLOrSVG(target) &&\n !shouldIgnore(target)\n ) {\n // skip over 'data-'\n const key = attributeName!.slice(5)\n const value = target.getAttribute(attributeName!)\n if (value === null) {\n const cleanups = removals.get(target)\n if (cleanups) {\n cleanups.get(key)?.()\n cleanups.delete(key)\n }\n } else {\n applyAttributePlugin(target, key, value)\n }\n }\n }\n}\n\n// TODO: mutation observer per root so applying to web component doesnt overwrite main observer\nconst mutationObserver = new MutationObserver(observe)\n\nexport const apply = (\n root: HTMLOrSVG | ShadowRoot = document.documentElement,\n): void => {\n if (isHTMLOrSVG(root)) {\n applyEls([root], true)\n }\n applyEls(root.querySelectorAll('*'), true)\n\n mutationObserver.observe(root, {\n subtree: true,\n childList: true,\n attributes: true,\n })\n}\n\nconst applyAttributePlugin = (\n el: HTMLOrSVG,\n attrKey: string,\n value: string,\n onlyNew?: boolean,\n): void => {\n if (!ALIAS || attrKey.startsWith(`${ALIAS}-`)) {\n const rawKey = ALIAS ? attrKey.slice(ALIAS.length + 1) : attrKey\n const [namePart, ...rawModifiers] = rawKey.split('__')\n const [pluginName, key] = namePart.split(/:(.+)/)\n const plugin = attributePlugins.get(pluginName)\n if ((!onlyNew || queuedAttributeNames.has(pluginName)) && plugin) {\n const ctx = {\n el,\n rawKey,\n mods: new Map(),\n error: error.bind(0, {\n plugin: { type: 'attribute', name: plugin.name },\n element: { id: el.id, tag: el.tagName },\n expression: { rawKey, key, value },\n }),\n key,\n value,\n rx: undefined,\n } as AttributeContext\n\n const keyReq =\n (plugin.requirement &&\n (typeof plugin.requirement === 'string'\n ? plugin.requirement\n : plugin.requirement.key)) ||\n 'allowed'\n const valueReq =\n (plugin.requirement &&\n (typeof plugin.requirement === 'string'\n ? plugin.requirement\n : plugin.requirement.value)) ||\n 'allowed'\n\n if (key) {\n if (keyReq === 'denied') {\n throw ctx.error('KeyNotAllowed')\n }\n } else if (keyReq === 'must') {\n throw ctx.error('KeyRequired')\n }\n\n if (value) {\n if (valueReq === 'denied') {\n throw ctx.error('ValueNotAllowed')\n }\n } else if (valueReq === 'must') {\n throw ctx.error('ValueRequired')\n }\n\n if (keyReq === 'exclusive' || valueReq === 'exclusive') {\n if (key && value) {\n throw ctx.error('KeyAndValueProvided')\n }\n if (!key && !value) {\n throw ctx.error('KeyOrValueRequired')\n }\n }\n\n if (value) {\n let cachedRx: GenRxFn\n ctx.rx = (...args: any[]) => {\n if (!cachedRx) {\n cachedRx = genRx(value, {\n returnsValue: plugin.returnsValue,\n argNames: plugin.argNames,\n })\n }\n return cachedRx(el, ...args)\n }\n }\n\n for (const rawMod of rawModifiers) {\n const [label, ...mod] = rawMod.split('.')\n ctx.mods.set(label, new Set(mod))\n }\n\n const cleanup = plugin.apply(ctx)\n if (cleanup) {\n let cleanups = removals.get(el)\n if (cleanups) {\n cleanups.get(rawKey)?.()\n } else {\n cleanups = new Map()\n removals.set(el, cleanups)\n }\n cleanups.set(rawKey, cleanup)\n }\n }\n }\n}\n\ntype GenRxOptions = {\n returnsValue?: boolean\n argNames?: string[]\n}\n\ntype GenRxFn = (el: HTMLOrSVG, ...args: any[]) => T\n\nconst genRx = (\n value: string,\n { returnsValue = false, argNames = [] }: GenRxOptions = {},\n): GenRxFn => {\n let expr = ''\n if (returnsValue) {\n // This regex allows Datastar expressions to support nested\n // regex and strings that contain ; without breaking.\n //\n // Each of these regex defines a block type we want to match\n // (importantly we ignore the content within these blocks):\n //\n // regex \\/(\\\\\\/|[^\\/])*\\/\n // double quotes \"(\\\\\"|[^\\\"])*\"\n // single quotes '(\\\\'|[^'])*'\n // ticks `(\\\\`|[^`])*`\n // iife \\(\\s*((function)\\s*\\(\\s*\\)|(\\(\\s*\\))\\s*=>)\\s*(?:\\{[\\s\\S]*?\\}|[^;)\\{]*)\\s*\\)\\s*\\(\\s*\\)\n //\n // The iife support is (intentionally) limited. It only supports\n // function and arrow syntax with no arguments, and no nested IIFEs.\n //\n // We also want to match the non delimiter part of statements\n // note we only support ; statement delimiters:\n //\n // [^;]\n //\n const statementRe =\n /(\\/(\\\\\\/|[^/])*\\/|\"(\\\\\"|[^\"])*\"|'(\\\\'|[^'])*'|`(\\\\`|[^`])*`|\\(\\s*((function)\\s*\\(\\s*\\)|(\\(\\s*\\))\\s*=>)\\s*(?:\\{[\\s\\S]*?\\}|[^;){]*)\\s*\\)\\s*\\(\\s*\\)|[^;])+/gm\n const statements = value.trim().match(statementRe)\n if (statements) {\n const lastIdx = statements.length - 1\n const last = statements[lastIdx].trim()\n if (!last.startsWith('return')) {\n statements[lastIdx] = `return (${last});`\n }\n expr = statements.join(';\\n')\n }\n } else {\n expr = value.trim()\n }\n\n // Ignore any escaped values\n const escaped = new Map()\n const escapeRe = RegExp(`(?:${DSP})(.*?)(?:${DSS})`, 'gm')\n let counter = 0\n for (const match of expr.matchAll(escapeRe)) {\n const k = match[1]\n const v = `__escaped${counter++}`\n escaped.set(v, k)\n expr = expr.replace(DSP + k + DSS, v)\n }\n\n // Replace signal references with bracket notation\n // Examples:\n // $count -> $['count']\n // $count-- -> $['count']--\n // $foo.bar -> $['foo']['bar']\n // $foo-bar -> $['foo-bar']\n // $foo.bar-baz -> $['foo']['bar-baz']\n // $foo-$bar -> $['foo']-$['bar']\n // $arr[$index] -> $['arr'][$['index']]\n // $['foo'] -> $['foo']\n // $foo[obj.bar] -> $['foo'][obj.bar]\n // $foo['bar.baz'] -> $['foo']['bar.baz']\n // $123 -> $['123']\n // $foo.0.name -> $['foo']['0']['name']\n\n expr = expr\n // $['x'] -> $x (normalize existing bracket notation)\n .replace(/\\$\\['([a-zA-Z_$\\d][\\w$]*)'\\]/g, '$$$1')\n // $x -> $['x'] (including dots and hyphens)\n .replace(/\\$([a-zA-Z_\\d]\\w*(?:[.-]\\w+)*)/g, (_, signalName) =>\n signalName\n .split('.')\n .reduce((acc: string, part: string) => `${acc}['${part}']`, '$'),\n )\n // [$x] -> [$['x']] ($ inside brackets)\n .replace(\n /\\[(\\$[a-zA-Z_\\d]\\w*)\\]/g,\n (_, varName) => `[$['${varName.slice(1)}']]`,\n )\n\n expr = expr.replaceAll(/@(\\w+)\\(/g, '__action(\"$1\",evt,')\n\n // Replace any escaped values\n for (const [k, v] of escaped) {\n expr = expr.replace(k, v)\n }\n\n try {\n const fn = Function('el', '$', '__action', 'evt', ...argNames, expr)\n return (el: HTMLOrSVG, ...args: any[]) => {\n const action = (name: string, evt: Event | undefined, ...args: any[]) => {\n const err = error.bind(0, {\n plugin: { type: 'action', name },\n element: { id: el.id, tag: el.tagName },\n expression: {\n fnContent: expr,\n value,\n },\n })\n const fn = actions[name]\n if (fn) {\n return fn(\n {\n el,\n evt,\n error: err,\n },\n ...args,\n )\n }\n throw err('UndefinedAction')\n }\n try {\n return fn(el, root, action, undefined, ...args)\n } catch (e: any) {\n console.error(e)\n throw error(\n {\n element: { id: el.id, tag: el.tagName },\n expression: {\n fnContent: expr,\n value,\n },\n error: e.message,\n },\n 'ExecuteExpression',\n )\n }\n }\n } catch (e: any) {\n console.error(e)\n throw error(\n {\n expression: {\n fnContent: expr,\n value,\n },\n error: e.message,\n },\n 'GenerateExpression',\n )\n }\n}\n","// Icon: ion:eye\n// Slug: Access signals without subscribing to changes.\n// Description: Allows accessing signals without subscribing to their changes in expressions.\n\nimport { action } from '@engine'\nimport { startPeeking, stopPeeking } from '@engine/signals'\n\naction({\n name: 'peek',\n apply(_, fn: () => any) {\n startPeeking()\n try {\n return fn()\n } finally {\n stopPeeking()\n }\n },\n})\n","// Icon: ion:checkmark-round\n// Slug: Sets the value of all matching signals.\n// Description: Sets the value of all matching signals (or all signals if no filter is used) to the expression provided in the first argument.\n\nimport { action } from '@engine'\nimport {\n filtered,\n mergePatch,\n startPeeking,\n stopPeeking,\n} from '@engine/signals'\nimport type { SignalFilterOptions } from '@engine/types'\nimport { updateLeaves } from '@utils/paths'\n\naction({\n name: 'setAll',\n apply(_, value: any, filter: SignalFilterOptions) {\n // peek because in an effect you would be subscribing to signals and then setting them which\n // would cause an infinite loop and why would you want to infinite loop on purpose\n startPeeking()\n const masked = filtered(filter)\n updateLeaves(masked, () => value)\n mergePatch(masked)\n stopPeeking()\n },\n})\n","// Icon: material-symbols:toggle-off\n// Slug: Toggles the value of all matching signals.\n// Description: Toggles the boolean value of all matching signals (or all signals if no filter is used).\n\nimport { action } from '@engine'\nimport {\n filtered,\n mergePatch,\n startPeeking,\n stopPeeking,\n} from '@engine/signals'\nimport type { SignalFilterOptions } from '@engine/types'\nimport { updateLeaves } from '@utils/paths'\n\naction({\n name: 'toggleAll',\n apply(_, filter: SignalFilterOptions) {\n // peek because in an effect you would be subscribing to signals and then setting them which\n // would cause an infinite loop and why would you want to infinite loop on purpose\n startPeeking()\n const masked = filtered(filter)\n updateLeaves(masked, (oldValue: any) => !oldValue)\n mergePatch(masked)\n stopPeeking()\n },\n})\n","// Icon: ion:eye\n// Slug: Access signals without subscribing to changes.\n// Description: Allows accessing signals without subscribing to their changes in expressions.\n\nimport { action } from '@engine'\nimport { DATASTAR_FETCH_EVENT } from '@engine/consts'\nimport { filtered } from '@engine/signals'\nimport type {\n DatastarFetchEvent,\n HTMLOrSVG,\n SignalFilterOptions,\n} from '@engine/types'\nimport { kebab } from '@utils/text'\n\nconst fetchAbortControllers = new WeakMap()\n\nconst createHttpMethod = (name: string, method: string): void =>\n action({\n name,\n apply: async (\n { el, evt, error },\n url: string,\n {\n selector,\n headers: userHeaders,\n contentType = 'json',\n filterSignals: { include = /.*/, exclude = /(^|\\.)_/ } = {},\n openWhenHidden = false,\n retryInterval = 1000,\n retryScaler = 2,\n retryMaxWaitMs = 30_000,\n retryMaxCount = 10,\n requestCancellation = 'auto',\n }: FetchArgs = {},\n ) => {\n const controller =\n requestCancellation instanceof AbortController\n ? requestCancellation\n : new AbortController()\n const isDisabled = requestCancellation === 'disabled'\n if (!isDisabled) {\n const oldController = fetchAbortControllers.get(el)\n if (oldController) {\n oldController.abort()\n // wait one tick for FINISHED to fire\n await Promise.resolve()\n }\n }\n\n if (!isDisabled && !(requestCancellation instanceof AbortController)) {\n fetchAbortControllers.set(el, controller)\n }\n\n try {\n const observer = new MutationObserver((mutations) => {\n for (const mutation of mutations) {\n for (const removed of mutation.removedNodes) {\n if (removed === el) {\n controller.abort()\n cleanupFn()\n }\n }\n }\n })\n if (el.parentNode) {\n observer.observe(el.parentNode, { childList: true })\n }\n\n let cleanupFn = () => {\n observer.disconnect()\n }\n\n try {\n if (!url?.length) {\n throw error('FetchNoUrlProvided', { action })\n }\n\n const initialHeaders: Record = {\n Accept: 'text/event-stream, text/html, application/json',\n 'Datastar-Request': true,\n }\n if (contentType === 'json') {\n initialHeaders['Content-Type'] = 'application/json'\n }\n const headers = Object.assign({}, initialHeaders, userHeaders)\n\n // We ignore the content-type header if using form data\n // if missing the boundary will be set automatically\n\n const req: FetchEventSourceInit = {\n method,\n headers,\n openWhenHidden,\n retryInterval,\n retryScaler,\n retryMaxWaitMs,\n retryMaxCount,\n signal: controller.signal,\n onopen: async (response: Response) => {\n if (response.status >= 400)\n dispatchFetch(ERROR, el, { status: response.status.toString() })\n },\n onmessage: (evt) => {\n if (!evt.event.startsWith('datastar')) return\n const type = evt.event\n const argsRawLines: Record = {}\n\n for (const line of evt.data.split('\\n')) {\n const i = line.indexOf(' ')\n const k = line.slice(0, i)\n const v = line.slice(i + 1)\n ;(argsRawLines[k] ||= []).push(v)\n }\n\n const argsRaw = Object.fromEntries(\n Object.entries(argsRawLines).map(([k, v]) => [k, v.join('\\n')]),\n )\n\n dispatchFetch(type, el, argsRaw)\n },\n onerror: (error) => {\n if (isWrongContent(error)) {\n // don't retry if the content-type is wrong\n throw error('FetchExpectedTextEventStream', { url })\n }\n // do nothing and it will retry\n if (error) {\n console.error(error.message)\n dispatchFetch(RETRYING, el, { message: error.message })\n }\n },\n }\n\n const urlInstance = new URL(url, document.baseURI)\n const queryParams = new URLSearchParams(urlInstance.search)\n\n if (contentType === 'json') {\n const res = JSON.stringify(filtered({ include, exclude }))\n if (method === 'GET') {\n queryParams.set('datastar', res)\n } else {\n req.body = res\n }\n } else if (contentType === 'form') {\n const formEl = (\n selector ? document.querySelector(selector) : el.closest('form')\n ) as HTMLFormElement\n if (!formEl) {\n throw error('FetchFormNotFound', { action, selector })\n }\n\n // Validate the form\n if (!formEl.checkValidity()) {\n formEl.reportValidity()\n cleanupFn()\n return\n }\n\n // Collect the form data\n\n const formData = new FormData(formEl)\n let submitter = el as HTMLElement | null\n\n if (el === formEl && evt instanceof SubmitEvent) {\n // Get the submitter from the event\n submitter = evt.submitter\n } else {\n // Prevent the form being submitted\n const preventDefault = (evt: Event) => evt.preventDefault()\n formEl.addEventListener('submit', preventDefault)\n cleanupFn = () => {\n formEl.removeEventListener('submit', preventDefault)\n observer.disconnect()\n }\n }\n\n // Append the value of the form submitter if it is a button with a name\n if (submitter instanceof HTMLButtonElement) {\n const name = submitter.getAttribute('name')\n if (name) formData.append(name, submitter.value)\n }\n\n const multipart =\n formEl.getAttribute('enctype') === 'multipart/form-data'\n // Leave the `Content-Type` header empty for multipart encoding so the browser can set it automatically with the correct boundary\n if (!multipart) {\n headers['Content-Type'] = 'application/x-www-form-urlencoded'\n }\n\n const formParams = new URLSearchParams(formData as any)\n if (method === 'GET') {\n for (const [key, value] of formParams) {\n queryParams.append(key, value)\n }\n } else if (multipart) {\n req.body = formData\n } else {\n req.body = formParams\n }\n } else {\n throw error('FetchInvalidContentType', { action, contentType })\n }\n\n dispatchFetch(STARTED, el, {})\n urlInstance.search = queryParams.toString()\n\n try {\n await fetchEventSource(urlInstance.toString(), el, req)\n } catch (e: any) {\n if (!isWrongContent(e)) {\n throw error('FetchFailed', { method, url, error: e.message })\n }\n // exit gracefully and do nothing if the content-type is wrong\n // this can happen if the client is sending a request\n // where no response is expected, and they haven’t\n // set the content-type to text/event-stream\n }\n } finally {\n dispatchFetch(FINISHED, el, {})\n cleanupFn()\n }\n } finally {\n if (fetchAbortControllers.get(el) === controller) {\n fetchAbortControllers.delete(el)\n }\n }\n },\n })\n\ncreateHttpMethod('delete', 'DELETE')\ncreateHttpMethod('get', 'GET')\ncreateHttpMethod('patch', 'PATCH')\ncreateHttpMethod('post', 'POST')\ncreateHttpMethod('put', 'PUT')\n\nexport const STARTED = 'started'\nexport const FINISHED = 'finished'\nexport const ERROR = 'error'\nexport const RETRYING = 'retrying'\nexport const RETRIES_FAILED = 'retries-failed'\n\nconst dispatchFetch = (\n type: string,\n el: HTMLOrSVG,\n argsRaw: Record,\n) =>\n document.dispatchEvent(\n new CustomEvent(DATASTAR_FETCH_EVENT, {\n detail: { type, el, argsRaw },\n }),\n )\n\nconst isWrongContent = (err: any) => `${err}`.includes('text/event-stream')\n\ntype ResponseOverrides =\n | {\n selector?: string\n mode?: string\n useViewTransition?: boolean\n }\n | {\n onlyIfMissing?: boolean\n }\n\nexport type FetchArgs = {\n headers?: Record\n credentials?: RequestCredentials\n openWhenHidden?: boolean\n retryInterval?: number\n retryScaler?: number\n retryMaxWaitMs?: number\n retryMaxCount?: number\n responseOverrides?: ResponseOverrides\n contentType?: 'json' | 'form'\n filterSignals?: SignalFilterOptions\n selector?: string\n requestCancellation?: 'auto' | 'disabled' | AbortController\n}\n\n// Below originally from https://github.com/Azure/fetch-event-source/blob/main/LICENSE\n\n/**\n * Represents a message sent in an event stream\n * https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#Event_stream_format\n */\n\ninterface EventSourceMessage {\n id: string\n event: string\n data: string\n retry?: number\n}\n\n/**\n * Converts a ReadableStream into a callback pattern.\n * @param stream The input ReadableStream.\n * @param onChunk A function that will be called on each new byte chunk in the stream.\n * @returns {Promise} A promise that will be resolved when the stream closes.\n */\nconst getBytes = async (\n stream: ReadableStream,\n onChunk: (arr: Uint8Array) => void,\n): Promise => {\n const reader = stream.getReader()\n let result = await reader.read()\n while (!result.done) {\n onChunk(result.value)\n result = await reader.read()\n }\n}\n\nconst getLines = (onLine: (line: Uint8Array, fieldLength: number) => void) => {\n let buffer: Uint8Array | undefined\n let position: number // current read position\n let fieldLength: number // length of the `field` portion of the line\n let discardTrailingNewline = false\n\n // return a function that can process each incoming byte chunk:\n return (arr: Uint8Array) => {\n if (!buffer) {\n buffer = arr\n position = 0\n fieldLength = -1\n } else {\n // we're still parsing the old line. Append the new bytes into buffer:\n buffer = concat(buffer, arr)\n }\n\n const bufLength = buffer.length\n let lineStart = 0 // index where the current line starts\n while (position < bufLength) {\n if (discardTrailingNewline) {\n if (buffer[position] === 10) lineStart = ++position // skip to next char\n discardTrailingNewline = false\n }\n\n // start looking forward till the end of line:\n let lineEnd = -1 // index of the \\r or \\n char\n for (; position < bufLength && lineEnd === -1; ++position) {\n switch (buffer[position]) {\n case 58: // :\n if (fieldLength === -1) {\n // first colon in line\n fieldLength = position - lineStart\n }\n break\n // @ts-expect-error:7029 \\r case below should fallthrough to \\n:\n // biome-ignore lint/suspicious/noFallthroughSwitchClause: intentional fallthrough for CR to LF\n case 13: // \\r\n discardTrailingNewline = true\n case 10: // \\n\n lineEnd = position\n break\n }\n }\n\n if (lineEnd === -1) break // Wait for the next arr and then continue parsing\n\n // we've reached the line end, send it out:\n onLine(buffer.subarray(lineStart, lineEnd), fieldLength)\n lineStart = position // we're now on the next line\n fieldLength = -1\n }\n\n if (lineStart === bufLength)\n buffer = undefined // we've finished reading it\n else if (lineStart) {\n // Create a new view into buffer beginning at lineStart so we don't\n // need to copy over the previous lines when we get the new arr:\n buffer = buffer.subarray(lineStart)\n position -= lineStart\n }\n }\n}\n\nconst getMessages = (\n onId: (id: string) => void,\n onRetry: (retry: number) => void,\n onMessage?: (msg: EventSourceMessage) => void,\n): ((line: Uint8Array, fieldLength: number) => void) => {\n let message = newMessage()\n const decoder = new TextDecoder()\n\n // return a function that can process each incoming line buffer:\n return (line, fieldLength) => {\n if (!line.length) {\n // empty line denotes end of message. Trigger the callback and start a new message:\n onMessage?.(message)\n message = newMessage()\n } else if (fieldLength > 0) {\n // exclude comments and lines with no values\n // line is of format \":\" or \": \"\n // https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation\n const field = decoder.decode(line.subarray(0, fieldLength))\n const valueOffset = fieldLength + (line[fieldLength + 1] === 32 ? 2 : 1)\n const value = decoder.decode(line.subarray(valueOffset))\n\n switch (field) {\n case 'data':\n message.data = message.data ? `${message.data}\\n${value}` : value\n break\n case 'event':\n message.event = value\n break\n case 'id':\n onId((message.id = value))\n break\n case 'retry': {\n const retry = +value\n if (!Number.isNaN(retry)) {\n // per spec, ignore non-integers\n onRetry((message.retry = retry))\n }\n break\n }\n }\n }\n }\n}\n\nconst concat = (a: Uint8Array, b: Uint8Array) => {\n const res = new Uint8Array(a.length + b.length)\n res.set(a)\n res.set(b, a.length)\n return res\n}\n\nconst newMessage = (): EventSourceMessage => ({\n // data, event, and id must be initialized to empty strings:\n // https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation\n // retry should be initialized to undefined so we return a consistent shape\n // to the js engine all the time: https://mathiasbynens.be/notes/shapes-ics#takeaways\n data: '',\n event: '',\n id: '',\n retry: undefined,\n})\n\ntype FetchEventSourceInit = RequestInit & {\n headers?: Record\n onopen?: (response: Response) => Promise\n onmessage?: (ev: EventSourceMessage) => void\n onclose?: () => void\n onerror?: (err: any) => number | null | undefined | void\n openWhenHidden?: boolean\n fetch?: typeof fetch\n retryInterval?: number\n retryScaler?: number\n retryMaxWaitMs?: number\n retryMaxCount?: number\n overrides?: ResponseOverrides\n}\n\nconst fetchEventSource = (\n input: RequestInfo,\n el: HTMLOrSVG,\n {\n signal: inputSignal,\n headers: inputHeaders,\n onopen: inputOnOpen,\n onmessage,\n onclose,\n onerror,\n openWhenHidden,\n fetch: inputFetch,\n retryInterval = 1_000,\n retryScaler = 2,\n retryMaxWaitMs = 30_000,\n retryMaxCount = 10,\n overrides,\n ...rest\n }: FetchEventSourceInit,\n): Promise => {\n return new Promise((resolve, reject) => {\n // make a copy of the input headers since we may modify it below:\n const headers: Record = {\n ...inputHeaders,\n }\n\n let curRequestController: AbortController\n const onVisibilityChange = () => {\n curRequestController.abort() // close existing request on every visibility change\n if (!document.hidden) create() // page is now visible again, recreate request.\n }\n\n if (!openWhenHidden) {\n document.addEventListener('visibilitychange', onVisibilityChange)\n }\n\n let retryTimer = 0\n const dispose = () => {\n document.removeEventListener('visibilitychange', onVisibilityChange)\n clearTimeout(retryTimer)\n curRequestController.abort()\n }\n\n // if the incoming signal aborts, dispose resources and resolve:\n inputSignal?.addEventListener('abort', () => {\n dispose()\n resolve() // don't waste time constructing/logging errors\n })\n\n const fetch = inputFetch || window.fetch\n const onopen = inputOnOpen || (() => {})\n\n let retries = 0\n let baseRetryInterval = retryInterval\n const create = async () => {\n curRequestController = new AbortController()\n try {\n const response = await fetch(input, {\n ...rest,\n headers,\n signal: curRequestController.signal,\n })\n\n // on successful connection, reset the retry logic\n retries = 0\n retryInterval = baseRetryInterval\n\n await onopen(response)\n\n const dispatchNonSSE = async (\n dispatchType: string,\n response: Response,\n name: string,\n overrides?: ResponseOverrides,\n ...argNames: string[]\n ) => {\n const argsRaw: Record = {\n [name]: await response.text(),\n }\n for (const n of argNames) {\n let v = response.headers.get(`datastar-${kebab(n)}`)\n if (overrides) {\n const o = (overrides as any)[n]\n if (o) v = typeof o === 'string' ? o : JSON.stringify(o)\n }\n if (v) argsRaw[n] = v\n }\n\n dispatchFetch(dispatchType, el, argsRaw)\n dispose()\n resolve()\n }\n\n const ct = response.headers.get('Content-Type')\n if (ct?.includes('text/html')) {\n return await dispatchNonSSE(\n 'datastar-patch-elements',\n response,\n 'elements',\n overrides,\n 'selector',\n 'mode',\n 'useViewTransition',\n )\n }\n\n if (ct?.includes('application/json')) {\n return await dispatchNonSSE(\n 'datastar-patch-signals',\n response,\n 'signals',\n overrides,\n 'onlyIfMissing',\n )\n }\n\n if (ct?.includes('text/javascript')) {\n const script = document.createElement('script')\n const scriptAttributesHeader = response.headers.get(\n 'datastar-script-attributes',\n )\n\n if (scriptAttributesHeader) {\n for (const [name, value] of Object.entries(\n JSON.parse(scriptAttributesHeader),\n )) {\n script.setAttribute(name, value as string)\n }\n }\n script.textContent = await response.text()\n document.head.appendChild(script)\n dispose()\n return\n }\n\n await getBytes(\n response.body!,\n getLines(\n getMessages(\n (id) => {\n if (id) {\n // signals the id and send it back on the next retry:\n headers['last-event-id'] = id\n } else {\n // don't send the last-event-id header anymore:\n delete headers['last-event-id']\n }\n },\n (retry) => {\n baseRetryInterval = retryInterval = retry\n },\n onmessage,\n ),\n ),\n )\n\n onclose?.()\n dispose()\n resolve()\n } catch (err) {\n if (!curRequestController.signal.aborted) {\n // if we haven’t aborted the request ourselves:\n try {\n // check if we need to retry:\n const interval: any = onerror?.(err) || retryInterval\n clearTimeout(retryTimer)\n retryTimer = setTimeout(create, interval)\n retryInterval = Math.min(\n retryInterval * retryScaler,\n retryMaxWaitMs,\n ) // exponential backoff\n if (++retries >= retryMaxCount) {\n dispatchFetch(RETRIES_FAILED, el, {})\n // we should not retry anymore:\n dispose()\n reject('Max retries reached.') // Max retries reached, check your server or network connection\n } else {\n console.error(\n `Datastar failed to reach ${input.toString()} retrying in ${interval}ms.`,\n )\n }\n } catch (innerErr) {\n // we should not retry anymore:\n dispose()\n reject(innerErr)\n }\n }\n }\n }\n\n create()\n })\n}\n","// Icon: material-symbols:edit-attributes-outline\n// Slug: Syncs the value of an attribute with an expression.\n// Description: Sets the value of any HTML attribute to an expression, and keeps it in sync.\n\nimport { attribute } from '@engine'\nimport { effect } from '@engine/signals'\n\nattribute({\n name: 'attr',\n requirement: { value: 'must' },\n returnsValue: true,\n apply({ el, key, rx }) {\n const syncAttr = (key: string, val: any) => {\n if (val === '' || val === true) {\n el.setAttribute(key, '')\n } else if (val === false || val == null) {\n el.removeAttribute(key)\n } else if (typeof val === 'string') {\n el.setAttribute(key, val)\n } else {\n el.setAttribute(key, JSON.stringify(val))\n }\n }\n\n const update = key\n ? () => {\n observer.disconnect()\n const val = rx() as string\n syncAttr(key, val)\n observer.observe(el, {\n attributeFilter: [key],\n })\n }\n : () => {\n observer.disconnect()\n const obj = rx() as Record\n const attributeFilter = Object.keys(obj)\n for (const key of attributeFilter) {\n syncAttr(key, obj[key])\n }\n observer.observe(el, {\n attributeFilter,\n })\n }\n\n const observer = new MutationObserver(update)\n const cleanup = effect(update)\n\n return () => {\n observer.disconnect()\n cleanup()\n }\n },\n})\n","// Icon: akar-icons:link-chain\n// Slug: Creates a signal with two-way data binding.\n// Description: Creates a signal (if one doesn’t already exist) and sets up two-way data binding between it and an element’s value.\n\nimport { attribute } from '@engine'\nimport { effect, getPath, mergePaths } from '@engine/signals'\nimport type { Paths } from '@engine/types'\nimport { aliasify, modifyCasing } from '@utils/text'\n\ntype SignalFile = {\n name: string\n contents: string\n mime: string\n}\n\nconst dataURIRegex = /^data:(?[^;]+);base64,(?.*)$/\nconst empty = Symbol('empty')\n\nconst aliasedBind = aliasify('bind')\n\nattribute({\n name: 'bind',\n requirement: 'exclusive',\n apply({ el, key, mods, value, error }) {\n const signalName = key != null ? modifyCasing(key, mods) : value\n\n let get = (el: any, type: string) =>\n type === 'number' ? +el.value : el.value\n\n let set = (value: any) => {\n ;(el as HTMLInputElement).value = `${value}`\n }\n\n if (el instanceof HTMLInputElement) {\n switch (el.type) {\n case 'range':\n case 'number':\n get = (el: any, type: string) =>\n type === 'string' ? el.value : +el.value\n break\n\n case 'checkbox':\n get = (el: HTMLInputElement, type: string) => {\n if (el.value !== 'on') {\n if (type === 'boolean') {\n return el.checked\n } else {\n return el.checked ? el.value : ''\n }\n } else {\n if (type === 'string') {\n return el.checked ? el.value : ''\n } else {\n return el.checked\n }\n }\n }\n set = (value: string | boolean) => {\n el.checked = typeof value === 'string' ? value === el.value : value\n }\n break\n\n case 'radio':\n // Set up radio button name attribute if not present\n if (!el.getAttribute('name')?.length) {\n el.setAttribute('name', signalName)\n }\n\n get = (el: HTMLInputElement, type: string) =>\n el.checked ? (type === 'number' ? +el.value : el.value) : empty\n set = (value: string | number) => {\n el.checked =\n value === (typeof value === 'number' ? +el.value : el.value)\n }\n break\n case 'file': {\n const syncSignal = () => {\n const files = [...(el.files || [])]\n const signalFiles: SignalFile[] = []\n Promise.all(\n files.map(\n (f) =>\n new Promise((resolve) => {\n const reader = new FileReader()\n reader.onload = () => {\n if (typeof reader.result !== 'string') {\n throw error('InvalidFileResultType', {\n resultType: typeof reader.result,\n })\n }\n const match = reader.result.match(dataURIRegex)\n if (!match?.groups) {\n throw error('InvalidDataUri', {\n result: reader.result,\n })\n }\n signalFiles.push({\n name: f.name,\n contents: match.groups.contents,\n mime: match.groups.mime,\n })\n }\n reader.onloadend = () => resolve()\n reader.readAsDataURL(f)\n }),\n ),\n ).then(() => {\n mergePaths([[signalName, signalFiles]])\n })\n }\n\n el.addEventListener('change', syncSignal)\n el.addEventListener('input', syncSignal)\n\n return () => {\n el.removeEventListener('change', syncSignal)\n el.removeEventListener('input', syncSignal)\n }\n }\n }\n } else if (el instanceof HTMLSelectElement) {\n if (el.multiple) {\n const typeMap = new Map()\n get = (el: HTMLSelectElement) =>\n [...el.selectedOptions].map((option) => {\n const type = typeMap.get(option.value)\n return type === 'string' || type == null\n ? option.value\n : +option.value\n })\n\n set = (value: (string | number)[]) => {\n for (const option of el.options) {\n if (value.includes(option.value)) {\n typeMap.set(option.value, 'string')\n option.selected = true\n } else if (value.includes(+option.value)) {\n typeMap.set(option.value, 'number')\n option.selected = true\n } else {\n option.selected = false\n }\n }\n }\n }\n } else if (el instanceof HTMLTextAreaElement) {\n // default case\n } else {\n // web component\n get = (el: Element) =>\n 'value' in el ? el.value : el.getAttribute('value')\n set = (value: any) => {\n if ('value' in el) {\n el.value = value\n } else {\n el.setAttribute('value', value)\n }\n }\n }\n\n const initialValue = getPath(signalName)\n const type = typeof initialValue\n\n let path = signalName\n if (\n Array.isArray(initialValue) &&\n !(el instanceof HTMLSelectElement && el.multiple)\n ) {\n const signalNameKebab = key ? key : value!\n const inputs = document.querySelectorAll(\n `[${aliasedBind}\\\\:${CSS.escape(signalNameKebab)}],[${aliasedBind}=\"${CSS.escape(signalNameKebab)}\"]`,\n ) as NodeListOf\n\n const paths: Paths = []\n let i = 0\n for (const input of inputs) {\n paths.push([`${path}.${i}`, get(input, 'none')])\n\n if (el === input) {\n break\n }\n i++\n }\n mergePaths(paths, { ifMissing: true })\n path = `${path}.${i}`\n } else {\n mergePaths([[path, get(el, type)]], {\n ifMissing: true,\n })\n }\n\n const syncSignal = () => {\n const signalValue = getPath(path)\n if (signalValue != null) {\n const value = get(el, typeof signalValue)\n if (value !== empty) {\n mergePaths([[path, value]])\n }\n }\n }\n\n el.addEventListener('input', syncSignal)\n el.addEventListener('change', syncSignal)\n const cleanup = effect(() => {\n set(getPath(path))\n })\n\n return () => {\n cleanup()\n el.removeEventListener('input', syncSignal)\n el.removeEventListener('change', syncSignal)\n }\n },\n})\n","// Icon: ic:baseline-format-paint\n// Slug: Adds or removes a class based on an expression.\n// Description: Adds or removes a class to or from an element based on an expression.\n\nimport { attribute } from '@engine'\nimport { effect } from '@engine/signals'\nimport { modifyCasing } from '@utils/text'\n\nattribute({\n name: 'class',\n requirement: {\n value: 'must',\n },\n returnsValue: true,\n apply({ key, el, mods, rx }) {\n if (key) {\n key = modifyCasing(key, mods, 'kebab')\n }\n\n const callback = () => {\n observer.disconnect()\n\n const classes = key\n ? { [key]: rx() as boolean }\n : (rx() as Record)\n\n for (const k in classes) {\n const classNames = k.split(/\\s+/).filter((cn) => cn.length > 0)\n if (classes[k]) {\n for (const name of classNames) {\n if (!el.classList.contains(name)) {\n el.classList.add(name)\n }\n }\n } else {\n for (const name of classNames) {\n if (el.classList.contains(name)) {\n el.classList.remove(name)\n }\n }\n }\n }\n\n observer.observe(el, { attributeFilter: ['class'] })\n }\n\n const observer = new MutationObserver(callback)\n const cleanup = effect(callback)\n\n return () => {\n observer.disconnect()\n cleanup()\n\n const classes = key\n ? { [key]: rx() as boolean }\n : (rx() as Record)\n\n for (const k in classes) {\n const classNames = k.split(/\\s+/).filter((cn) => cn.length > 0)\n for (const name of classNames) {\n el.classList.remove(name)\n }\n }\n }\n },\n})\n","// Icon: streamline-ultimate:wifi-signal-2\n// Slug: Creates a computed signal.\n// Description: Creates a signal that is computed based on an expression.\n\nimport { attribute } from '@engine'\nimport { computed, mergePaths, mergePatch } from '@engine/signals'\nimport { modifyCasing } from '@utils/text'\nimport { updateLeaves } from '@utils/paths'\n\nattribute({\n name: 'computed',\n requirement: {\n value: 'must',\n },\n returnsValue: true,\n apply({ key, mods, rx, error }) {\n if (key) {\n mergePaths([[modifyCasing(key, mods), computed(rx)]])\n } else {\n const patch = Object.assign({}, rx() as Record any>)\n updateLeaves(patch, (old) => {\n if (typeof old === 'function') {\n return computed(old)\n } else {\n throw error('ComputedExpectedFunction')\n }\n })\n mergePatch(patch)\n }\n },\n})\n","// Icon: oui:security-signal\n// Slug: Executes an expression when signals change.\n// Description: Executes an expression on page load and whenever any signals in the expression change.\n\nimport { attribute } from '@engine'\nimport { effect } from '@engine/signals'\n\nattribute({\n name: 'effect',\n requirement: {\n key: 'denied',\n value: 'must',\n },\n apply: ({ rx }) => effect(rx),\n})\n","// Icon: streamline:signal-loading-remix\n// Slug: Creates an indicator for whether an SSE request is in flight.\n// Description: Creates a signal and sets its value to `true` while an SSE request request is in flight, otherwise `false`.\n\nimport { attribute } from '@engine'\nimport { DATASTAR_FETCH_EVENT } from '@engine/consts'\nimport { mergePaths } from '@engine/signals'\nimport type { DatastarFetchEvent } from '@engine/types'\nimport { FINISHED, STARTED } from '@plugins/actions/fetch'\nimport { modifyCasing } from '@utils/text'\n\nattribute({\n name: 'indicator',\n requirement: 'exclusive',\n apply({ el, key, mods, value }) {\n const signalName = key != null ? modifyCasing(key, mods) : value\n\n mergePaths([[signalName, false]])\n\n const watcher = ((event: CustomEvent) => {\n const { type, el: elt } = event.detail\n if (elt !== el) {\n return\n }\n switch (type) {\n case STARTED:\n mergePaths([[signalName, true]])\n break\n case FINISHED:\n mergePaths([[signalName, false]])\n break\n }\n }) as EventListener\n document.addEventListener(DATASTAR_FETCH_EVENT, watcher)\n return () => {\n mergePaths([[signalName, false]])\n document.removeEventListener(DATASTAR_FETCH_EVENT, watcher)\n }\n },\n})\n","// Icon: si:json-fill\n// Slug: Outputs a JSON stringified version of signals.\n// Description: Sets the text content of an element to a reactive JSON stringified version of signals.\n\nimport { attribute } from '@engine'\nimport { effect, filtered } from '@engine/signals'\nimport type { SignalFilterOptions } from '@engine/types'\nimport { jsStrToObject } from '@utils/text'\n\nattribute({\n name: 'json-signals',\n requirement: {\n key: 'denied',\n },\n apply({ el, value, mods }) {\n const spaces = mods.has('terse') ? 0 : 2\n let filters: SignalFilterOptions = {}\n if (value) {\n filters = jsStrToObject(value)\n }\n\n const callback = () => {\n observer.disconnect()\n el.textContent = JSON.stringify(filtered(filters), null, spaces)\n observer.observe(el, {\n childList: true,\n characterData: true,\n subtree: true,\n })\n }\n const observer = new MutationObserver(callback)\n const cleanup = effect(callback)\n\n return () => {\n observer.disconnect()\n cleanup()\n }\n },\n})\n","export const tagToMs = (args: Set) => {\n if (!args || args.size <= 0) return 0\n for (const arg of args) {\n if (arg.endsWith('ms')) {\n return +arg.replace('ms', '')\n }\n if (arg.endsWith('s')) {\n return +arg.replace('s', '') * 1000\n }\n try {\n return Number.parseFloat(arg)\n } catch (_) {}\n }\n return 0\n}\n\nexport const tagHas = (\n tags: Set,\n tag: string,\n defaultValue = false,\n) => {\n if (!tags) return defaultValue\n return tags.has(tag.toLowerCase())\n}\n\nexport const tagFirst = (tags?: Set, defaultValue = ''): string => {\n if (tags && tags.size > 0) {\n for (const tag of tags) {\n return tag\n }\n }\n return defaultValue\n}\n","import type { EventCallbackHandler, Modifiers } from '@engine/types'\nimport { tagHas, tagToMs } from '@utils/tags'\n\nexport const delay = (\n callback: EventCallbackHandler,\n wait: number,\n): EventCallbackHandler => {\n return (...args: any[]) => {\n setTimeout(() => {\n callback(...args)\n }, wait)\n }\n}\n\nexport const debounce = (\n callback: EventCallbackHandler,\n wait: number,\n leading = false,\n trailing = true,\n): EventCallbackHandler => {\n let timer = 0\n return (...args: any[]) => {\n timer && clearTimeout(timer)\n\n if (leading && !timer) {\n callback(...args)\n }\n\n timer = setTimeout(() => {\n if (trailing) {\n callback(...args)\n }\n timer && clearTimeout(timer)\n timer = 0\n }, wait)\n }\n}\n\nexport const throttle = (\n callback: EventCallbackHandler,\n wait: number,\n leading = true,\n trailing = false,\n): EventCallbackHandler => {\n let waiting = false\n\n return (...args: any[]) => {\n if (waiting) return\n\n if (leading) {\n callback(...args)\n }\n\n waiting = true\n setTimeout(() => {\n if (trailing) {\n callback(...args)\n }\n waiting = false\n }, wait)\n }\n}\n\nexport const modifyTiming = (\n callback: EventCallbackHandler,\n mods: Modifiers,\n): EventCallbackHandler => {\n const delayArgs = mods.get('delay')\n if (delayArgs) {\n const wait = tagToMs(delayArgs)\n callback = delay(callback, wait)\n }\n\n const debounceArgs = mods.get('debounce')\n if (debounceArgs) {\n const wait = tagToMs(debounceArgs)\n const leading = tagHas(debounceArgs, 'leading', false)\n const trailing = !tagHas(debounceArgs, 'notrailing', false)\n callback = debounce(callback, wait, leading, trailing)\n }\n\n const throttleArgs = mods.get('throttle')\n if (throttleArgs) {\n const wait = tagToMs(throttleArgs)\n const leading = !tagHas(throttleArgs, 'noleading', false)\n const trailing = tagHas(throttleArgs, 'trailing', false)\n callback = throttle(callback, wait, leading, trailing)\n }\n\n return callback\n}\n","import type { EventCallbackHandler, Modifiers } from '@engine/types'\n\nexport const supportsViewTransitions = !!document.startViewTransition\n\nexport const modifyViewTransition = (\n callback: EventCallbackHandler,\n mods: Modifiers,\n): EventCallbackHandler => {\n if (mods.has('viewtransition') && supportsViewTransitions) {\n const cb = callback // I hate javascript\n callback = (...args: any[]) =>\n document.startViewTransition(() => cb(...args))\n }\n\n return callback\n}\n","// Icon: material-symbols:mail\n// Slug: Attaches an event listener to an element.\n// Description: Attaches an event listener to an element, executing an expression whenever the event is triggered.\n\nimport { attribute } from '@engine'\nimport {\n DATASTAR_FETCH_EVENT,\n DATASTAR_SIGNAL_PATCH_EVENT,\n} from '@engine/consts'\nimport { beginBatch, endBatch } from '@engine/signals'\nimport { modifyCasing } from '@utils/text'\nimport { modifyTiming } from '@utils/timing'\nimport { modifyViewTransition } from '@utils/view-transitions'\n\nattribute({\n name: 'on',\n requirement: 'must',\n argNames: ['evt'],\n apply({ el, key, mods, rx }) {\n let target: Element | Window | Document = el\n if (mods.has('window')) target = window\n let callback = (evt?: Event) => {\n if (evt) {\n if (mods.has('prevent')) {\n evt.preventDefault()\n }\n if (mods.has('stop')) {\n evt.stopPropagation()\n }\n }\n beginBatch()\n rx(evt)\n endBatch()\n }\n callback = modifyViewTransition(callback, mods)\n callback = modifyTiming(callback, mods)\n const evtListOpts: AddEventListenerOptions = {\n capture: mods.has('capture'),\n passive: mods.has('passive'),\n once: mods.has('once'),\n }\n if (mods.has('outside')) {\n target = document\n const cb = callback\n callback = (evt?: Event) => {\n if (!el.contains(evt?.target as HTMLElement)) {\n cb(evt)\n }\n }\n }\n const eventName = modifyCasing(key, mods, 'kebab')\n // Listen for Datastar events on the document\n if (\n eventName === DATASTAR_FETCH_EVENT ||\n eventName === DATASTAR_SIGNAL_PATCH_EVENT\n ) {\n target = document\n }\n // Prevent default on form submit events\n if (el instanceof HTMLFormElement && eventName === 'submit') {\n const cb = callback\n callback = (evt?: Event) => {\n evt?.preventDefault()\n cb(evt)\n }\n }\n target.addEventListener(eventName, callback, evtListOpts)\n return () => {\n target.removeEventListener(eventName, callback)\n }\n },\n})\n","// Icon: mdi-light:vector-intersection\n// Slug: Runs an expression on intersection.\n// Description: Runs an expression when the element intersects with the viewport.\n\nimport { attribute } from '@engine'\nimport { beginBatch, endBatch } from '@engine/signals'\nimport type { HTMLOrSVG } from '@engine/types'\nimport { modifyTiming } from '@utils/timing'\nimport { modifyViewTransition } from '@utils/view-transitions'\n\nconst once = new WeakSet()\n\nattribute({\n name: 'on-intersect',\n requirement: {\n key: 'denied',\n value: 'must',\n },\n apply({ el, mods, rx }) {\n let callback = () => {\n beginBatch()\n rx()\n endBatch()\n }\n callback = modifyViewTransition(callback, mods)\n callback = modifyTiming(callback, mods)\n const options = { threshold: 0 }\n if (mods.has('full')) {\n options.threshold = 1\n } else if (mods.has('half')) {\n options.threshold = 0.5\n }\n let observer: IntersectionObserver | null = new IntersectionObserver(\n (entries) => {\n for (const entry of entries) {\n if (entry.isIntersecting) {\n callback()\n if (observer && once.has(el)) {\n observer.disconnect()\n }\n }\n }\n },\n options,\n )\n observer.observe(el)\n if (mods.has('once')) {\n once.add(el)\n }\n return () => {\n if (!mods.has('once')) {\n once.delete(el)\n }\n if (observer) {\n observer.disconnect()\n observer = null\n }\n }\n },\n})\n","// Icon: material-symbols:timer-outline\n// Slug: Runs an expression at a regular interval.\n// Description: Runs an expression at a regular interval.\n\nimport { attribute } from '@engine'\nimport { beginBatch, endBatch } from '@engine/signals'\nimport { tagHas, tagToMs } from '@utils/tags'\nimport { modifyViewTransition } from '@utils/view-transitions'\n\nattribute({\n name: 'on-interval',\n requirement: {\n key: 'denied',\n value: 'must',\n },\n apply({ mods, rx }) {\n let callback = () => {\n beginBatch()\n rx()\n endBatch()\n }\n callback = modifyViewTransition(callback, mods)\n let duration = 1000\n const durationArgs = mods.get('duration')\n if (durationArgs) {\n duration = tagToMs(durationArgs)\n const leading = tagHas(durationArgs, 'leading', false)\n if (leading) {\n callback()\n }\n }\n const intervalId = setInterval(callback, duration)\n return () => {\n clearInterval(intervalId)\n }\n },\n})\n","// Icon: material-symbols:timer-play-outline\n// Slug: Runs an expression when loaded into the DOM.\n// Description: Runs an expression when the element is loaded into the DOM.\n\nimport { attribute } from '@engine'\nimport { beginBatch, endBatch } from '@engine/signals'\nimport { tagToMs } from '@utils/tags'\nimport { delay } from '@utils/timing'\nimport { modifyViewTransition } from '@utils/view-transitions'\n\nattribute({\n name: 'init',\n requirement: {\n key: 'denied',\n value: 'must',\n },\n apply({ rx, mods }) {\n let callback = () => {\n beginBatch()\n rx()\n endBatch()\n }\n callback = modifyViewTransition(callback, mods)\n let wait = 0\n const delayArgs = mods.get('delay')\n if (delayArgs) {\n wait = tagToMs(delayArgs)\n if (wait > 0) {\n callback = delay(callback, wait)\n }\n }\n callback()\n },\n})\n","// Icon: material-symbols:change-circle-outline\n// Slug: Runs an expression when signals are patched.\n// Description: Runs an expression whenever one or more signals are patched.\n\nimport { attribute } from '@engine'\nimport { DATASTAR_SIGNAL_PATCH_EVENT } from '@engine/consts'\nimport { beginBatch, endBatch, filtered } from '@engine/signals'\nimport type { JSONPatch, SignalFilterOptions } from '@engine/types'\nimport { isEmpty } from '@utils/paths'\nimport { jsStrToObject } from '@utils/text'\nimport { modifyTiming } from '@utils/timing'\n\nattribute({\n name: 'on-signal-patch',\n requirement: {\n value: 'must',\n },\n argNames: ['patch'],\n returnsValue: true,\n apply({ el, key, mods, rx, error }) {\n if (!!key && key !== 'filter') {\n throw error('KeyNotAllowed')\n }\n\n // Look for data-on-signal-patch-filter data attribute\n const filtersRaw = el.getAttribute('data-on-signal-patch-filter')\n let filters: SignalFilterOptions = {}\n if (filtersRaw) {\n filters = jsStrToObject(filtersRaw)\n }\n\n const callback: EventListener = modifyTiming(\n (evt: CustomEvent) => {\n const watched = filtered(filters, evt.detail)\n if (!isEmpty(watched)) {\n beginBatch()\n rx(watched)\n endBatch()\n }\n },\n mods,\n )\n\n document.addEventListener(DATASTAR_SIGNAL_PATCH_EVENT, callback)\n return () => {\n document.removeEventListener(DATASTAR_SIGNAL_PATCH_EVENT, callback)\n }\n },\n})\n","// Icon: mdi:cursor-pointer\n// Slug: Creates a reference to an element.\n// Description: Creates a new signal that is a reference to the element on which the data attribute is placed.\n\nimport { attribute } from '@engine'\nimport { mergePaths } from '@engine/signals'\nimport { modifyCasing } from '@utils/text'\n\nattribute({\n name: 'ref',\n requirement: 'exclusive',\n apply({ el, key, mods, value }) {\n const signalName = key != null ? modifyCasing(key, mods) : value\n mergePaths([[signalName, el]])\n },\n})\n","// Icon: streamline:interface-edit-view-eye-eyeball-open-view\n// Slug: Shows or hides an element.\n// Description: Shows or hides an element based on whether an expression evaluates to `true` or `false`.\n\nimport { attribute } from '@engine'\nimport { effect } from '@engine/signals'\n\nconst NONE = 'none'\nconst DISPLAY = 'display'\n\nattribute({\n name: 'show',\n requirement: {\n key: 'denied',\n value: 'must',\n },\n returnsValue: true,\n apply({ el, rx }) {\n const update = () => {\n observer.disconnect()\n const shouldShow = rx()\n if (shouldShow) {\n if (el.style.display === NONE) el.style.removeProperty(DISPLAY)\n } else {\n el.style.setProperty(DISPLAY, NONE)\n }\n observer.observe(el, { attributeFilter: ['style'] })\n }\n const observer = new MutationObserver(update)\n const cleanup = effect(update)\n\n return () => {\n observer.disconnect()\n cleanup()\n }\n },\n})\n","// Icon: streamline:wifi-signal-full-remix\n// Slug: Patches signals into the existing signals.\n// Description: Patches (adds, updates or removes) one or more signals into the existing signals.\n\nimport { attribute } from '@engine'\nimport { mergePatch, mergePaths } from '@engine/signals'\nimport { modifyCasing } from '@utils/text'\n\nattribute({\n name: 'signals',\n returnsValue: true,\n apply({ key, mods, rx }) {\n const ifMissing = mods.has('ifmissing')\n\n if (key) {\n key = modifyCasing(key, mods)\n mergePaths([[key, rx?.()]], { ifMissing })\n } else {\n const patch = Object.assign({}, rx?.() as Record)\n mergePatch(patch, { ifMissing })\n }\n },\n})\n","// Icon: material-symbols:format-paint-outline\n// Slug: Sets inline styles on an element based on an expression.\n// Description: Sets CSS styles on an element using either key-based or object syntax, and keeps them in sync with reactive signals.\n\nimport { attribute } from '@engine'\nimport { effect } from '@engine/signals'\nimport { kebab } from '@utils/text'\n\nattribute({\n name: 'style',\n requirement: {\n value: 'must',\n },\n returnsValue: true,\n apply({ key, el, rx }) {\n const { style } = el\n const initialStyles = new Map()\n\n const apply = (prop: string, value: any) => {\n const initial = initialStyles.get(prop)\n if (!value && value !== 0) {\n initial !== undefined &&\n (initial\n ? style.setProperty(prop, initial)\n : style.removeProperty(prop))\n } else {\n initial === undefined &&\n initialStyles.set(prop, style.getPropertyValue(prop))\n style.setProperty(prop, String(value))\n }\n }\n\n const update = () => {\n observer.disconnect()\n\n if (key) {\n apply(key, rx())\n } else {\n const styles = rx() as Record\n\n for (const [prop, initial] of initialStyles) {\n prop in styles ||\n (initial\n ? style.setProperty(prop, initial)\n : style.removeProperty(prop))\n }\n\n for (const prop in styles) {\n apply(kebab(prop), styles[prop])\n }\n }\n\n observer.observe(el, { attributeFilter: ['style'] })\n }\n\n const observer = new MutationObserver(update)\n const cleanup = effect(update)\n\n return () => {\n observer.disconnect()\n cleanup()\n for (const [prop, initial] of initialStyles) {\n initial ? style.setProperty(prop, initial) : style.removeProperty(prop)\n }\n }\n },\n})\n","// Icon: icon-park-outline:text\n// Slug: Binds the text content of an element.\n// Description: Binds the text content of an element to an expression.\n\nimport { attribute } from '@engine'\nimport { effect } from '@engine/signals'\n\nattribute({\n name: 'text',\n requirement: {\n key: 'denied',\n value: 'must',\n },\n returnsValue: true,\n apply({ el, rx }) {\n const update = () => {\n observer.disconnect()\n el.textContent = `${rx()}`\n observer.observe(el, {\n childList: true,\n characterData: true,\n subtree: true,\n })\n }\n\n const observer = new MutationObserver(update)\n const cleanup = effect(update)\n\n return () => {\n observer.disconnect()\n cleanup()\n }\n },\n})\n","import { isHTMLOrSVG } from '@utils/dom'\nimport { aliasify } from '@utils/text'\n\nconst ctxIdMap = new Map>()\nconst ctxPersistentIds = new Set()\nconst oldIdTagNameMap = new Map()\nconst duplicateIds = new Set()\nconst ctxPantry = document.createElement('div')\nctxPantry.hidden = true\n\nconst aliasedIgnoreMorph = aliasify('ignore-morph')\nconst aliasedIgnoreMorphAttr = `[${aliasedIgnoreMorph}]`\nexport const morph = (\n oldElt: Element | ShadowRoot,\n newContent: DocumentFragment | Element,\n mode: 'outer' | 'inner' = 'outer',\n): void => {\n if (\n (isHTMLOrSVG(oldElt) &&\n isHTMLOrSVG(newContent) &&\n oldElt.hasAttribute(aliasedIgnoreMorph) &&\n newContent.hasAttribute(aliasedIgnoreMorph)) ||\n oldElt.parentElement?.closest(aliasedIgnoreMorphAttr)\n ) {\n return\n }\n\n const normalizedElt = document.createElement('div')\n normalizedElt.append(newContent)\n document.body.insertAdjacentElement('afterend', ctxPantry)\n\n // Computes the set of IDs that persist between the two contents excluding duplicates\n const oldIdElements = oldElt.querySelectorAll('[id]')\n for (const { id, tagName } of oldIdElements) {\n if (oldIdTagNameMap.has(id)) {\n duplicateIds.add(id)\n } else {\n oldIdTagNameMap.set(id, tagName)\n }\n }\n if (oldElt instanceof Element && oldElt.id) {\n if (oldIdTagNameMap.has(oldElt.id)) {\n duplicateIds.add(oldElt.id)\n } else {\n oldIdTagNameMap.set(oldElt.id, oldElt.tagName)\n }\n }\n\n ctxPersistentIds.clear()\n const newIdElements = normalizedElt.querySelectorAll('[id]')\n for (const { id, tagName } of newIdElements) {\n if (ctxPersistentIds.has(id)) {\n duplicateIds.add(id)\n } else if (oldIdTagNameMap.get(id) === tagName) {\n ctxPersistentIds.add(id)\n }\n }\n\n for (const id of duplicateIds) {\n ctxPersistentIds.delete(id)\n }\n\n oldIdTagNameMap.clear()\n duplicateIds.clear()\n ctxIdMap.clear()\n\n const parent = mode === 'outer' ? oldElt.parentElement! : oldElt\n populateIdMapWithTree(parent, oldIdElements)\n populateIdMapWithTree(normalizedElt, newIdElements)\n\n morphChildren(\n parent,\n normalizedElt,\n mode === 'outer' ? oldElt : null,\n oldElt.nextSibling,\n )\n\n ctxPantry.remove()\n}\n\n// This is the core algorithm for matching up children.\n// The idea is to use ID sets to try to match up nodes as faithfully as possible.\n// We greedily match, which allows us to keep the algorithm fast,\n// but by using ID sets, we are able to better match up with content deeper in the DOM.\nconst morphChildren = (\n oldParent: Element | ShadowRoot, // the old content that we are merging the new content into\n newParent: Element, // the parent element of the new content\n insertionPoint: Node | null = null, // the point in the DOM we start morphing at (defaults to first child)\n endPoint: Node | null = null, // the point in the DOM we stop morphing at (defaults to after last child)\n): void => {\n // normalize\n if (\n oldParent instanceof HTMLTemplateElement &&\n newParent instanceof HTMLTemplateElement\n ) {\n // we can pretend the DocumentElement is an Element\n oldParent = oldParent.content as unknown as Element\n newParent = newParent.content as unknown as Element\n }\n insertionPoint ??= oldParent.firstChild\n\n // run through all the new content\n for (const newChild of newParent.childNodes) {\n // once we reach the end of the old parent content skip to the end and insert the rest\n if (insertionPoint && insertionPoint !== endPoint) {\n const bestMatch = findBestMatch(newChild, insertionPoint, endPoint)\n if (bestMatch) {\n // if the node to morph is not at the insertion point then remove/move up to it\n if (bestMatch !== insertionPoint) {\n let cursor: Node | null = insertionPoint\n // Remove nodes between the start and end nodes\n while (cursor && cursor !== bestMatch) {\n const tempNode = cursor\n cursor = cursor.nextSibling\n removeNode(tempNode)\n }\n }\n morphNode(bestMatch, newChild)\n insertionPoint = bestMatch.nextSibling\n continue\n }\n }\n\n // if the matching node is elsewhere in the original content\n if (newChild instanceof Element && ctxPersistentIds.has(newChild.id)) {\n // move it and all its children here and morph, will always be found\n // Search for an element by ID within the document and pantry, and move it using moveBefore.\n const movedChild = document.getElementById(newChild.id) as Element\n\n // Removes an element from its ancestors' ID maps.\n // This is needed when an element is moved from the \"future\" via `moveBeforeId`.\n // Otherwise, its erstwhile ancestors could be mistakenly moved to the pantry rather than being deleted,\n // preventing their removal hooks from being called.\n let current = movedChild\n while ((current = current.parentNode as Element)) {\n const idSet = ctxIdMap.get(current)\n if (idSet) {\n idSet.delete(newChild.id)\n if (!idSet.size) {\n ctxIdMap.delete(current)\n }\n }\n }\n\n moveBefore(oldParent, movedChild, insertionPoint)\n morphNode(movedChild, newChild)\n insertionPoint = movedChild.nextSibling\n continue\n }\n\n // This performs the action of inserting a new node while handling situations where the node contains\n // elements with persistent IDs and possible state info we can still preserve by moving in and then morphing\n if (ctxIdMap.has(newChild)) {\n // node has children with IDs with possible state so create a dummy elt of same type and apply full morph algorithm\n const newEmptyChild = document.createElement(\n (newChild as Element).tagName,\n )\n oldParent.insertBefore(newEmptyChild, insertionPoint)\n morphNode(newEmptyChild, newChild)\n insertionPoint = newEmptyChild.nextSibling\n } else {\n // optimization: no id state to preserve so we can just insert a clone of the newChild and its descendants\n const newClonedChild = document.importNode(newChild, true) // importNode to not mutate newParent\n oldParent.insertBefore(newClonedChild, insertionPoint)\n insertionPoint = newClonedChild.nextSibling\n }\n }\n\n // remove any remaining old nodes that didn't match up with new content\n while (insertionPoint && insertionPoint !== endPoint) {\n const tempNode = insertionPoint\n insertionPoint = insertionPoint.nextSibling\n removeNode(tempNode)\n }\n}\n\n// Scans forward from the startPoint to the endPoint looking for a match for the node.\n// It looks for an id set match first, then a soft match.\n// We abort soft matching if we find two future soft matches, to reduce churn.\nconst findBestMatch = (\n node: Node,\n startPoint: Node | null,\n endPoint: Node | null,\n): Node | null => {\n let bestMatch: Node | null | undefined = null\n let nextSibling = node.nextSibling\n let siblingSoftMatchCount = 0\n let displaceMatchCount = 0\n\n // Max ID matches we are willing to displace in our search\n const nodeMatchCount = ctxIdMap.get(node)?.size || 0\n\n let cursor = startPoint\n while (cursor && cursor !== endPoint) {\n // soft matching is a prerequisite for id set matching\n if (isSoftMatch(cursor, node)) {\n let isIdSetMatch = false\n const oldSet = ctxIdMap.get(cursor)\n const newSet = ctxIdMap.get(node)\n\n if (newSet && oldSet) {\n for (const id of oldSet) {\n // a potential match is an id in the new and old nodes that\n // has not already been merged into the DOM\n // But the newNode content we call this on has not been\n // merged yet and we don't allow duplicate IDs so it is simple\n if (newSet.has(id)) {\n isIdSetMatch = true\n break\n }\n }\n }\n\n if (isIdSetMatch) {\n return cursor // found an id set match, we're done!\n }\n\n // we haven’t yet saved a soft match fallback\n // the current soft match will hard match something else in the future, leave it\n if (!bestMatch && !ctxIdMap.has(cursor)) {\n // optimization: if node can't id set match, we can just return the soft match immediately\n if (!nodeMatchCount) {\n return cursor\n }\n // save this as the fallback if we get through the loop without finding a hard match\n bestMatch = cursor\n }\n }\n\n // check for IDs we may be displaced when matching\n displaceMatchCount += ctxIdMap.get(cursor)?.size || 0\n if (displaceMatchCount > nodeMatchCount) {\n // if we are going to displace more IDs than the node contains then\n // we do not have a good candidate for an ID match, so return\n break\n }\n\n if (bestMatch === null && nextSibling && isSoftMatch(cursor, nextSibling)) {\n // The next new node has a soft match with this node, so\n // increment the count of future soft matches\n siblingSoftMatchCount++\n nextSibling = nextSibling.nextSibling\n\n // If there are two future soft matches, block soft matching for this node to allow\n // future siblings to soft match. This is to reduce churn in the DOM when an element\n // is prepended.\n if (siblingSoftMatchCount >= 2) {\n bestMatch = undefined\n }\n }\n\n cursor = cursor.nextSibling\n }\n\n return bestMatch || null\n}\n\n// ok to cast: if one is not element, `id` and `tagName` will be null and we'll just compare that.\nconst isSoftMatch = (oldNode: Node, newNode: Node): boolean =>\n oldNode.nodeType === newNode.nodeType &&\n (oldNode as Element).tagName === (newNode as Element).tagName &&\n // If oldElt has an `id` with possible state and it doesn’t match newElt.id then avoid morphing.\n // We'll still match an anonymous node with an IDed newElt, though, because if it got this far,\n // its not persistent, and new nodes can't have any hidden state.\n (!(oldNode as Element).id ||\n (oldNode as Element).id === (newNode as Element).id)\n\n// Gets rid of an unwanted DOM node; strategy depends on nature of its reuse:\n// - Persistent nodes will be moved to the pantry for later reuse\n// - Other nodes will have their hooks called, and then are removed\nconst removeNode = (node: Node): void => {\n // are we going to id set match this later?\n ctxIdMap.has(node)\n ? // skip callbacks and move to pantry\n moveBefore(ctxPantry, node, null)\n : // remove for realsies\n node.parentNode?.removeChild(node)\n}\n\n// Moves an element before another element within the same parent.\n// Uses the proposed `moveBefore` API if available (and working), otherwise falls back to `insertBefore`.\n// This is essentially a forward-compat wrapper.\nconst moveBefore: (parentNode: Node, node: Node, after: Node | null) => void =\n // @ts-expect-error\n removeNode.call.bind(ctxPantry.moveBefore ?? ctxPantry.insertBefore)\n\nconst aliasedPreserveAttr = aliasify('preserve-attr')\n\n// syncs the oldNode to the newNode, copying over all attributes and\n// inner element state from the newNode to the oldNode\nconst morphNode = (\n oldNode: Node, // root node to merge content into\n newNode: Node, // new content to merge\n): Node => {\n const type = newNode.nodeType\n\n // if is an element type, sync the attributes from the\n // new node into the new node\n if (type === 1 /* element type */) {\n const oldElt = oldNode as Element\n const newElt = newNode as Element\n if (\n oldElt.hasAttribute(aliasedIgnoreMorph) &&\n newElt.hasAttribute(aliasedIgnoreMorph)\n ) {\n return oldNode\n }\n\n // many bothans died to bring us this information:\n // https://github.com/patrick-steele-idem/morphdom/blob/master/src/specialElHandlers.js\n // https://github.com/choojs/nanomorph/blob/master/lib/morph.js#L113\n if (\n oldElt instanceof HTMLInputElement &&\n newElt instanceof HTMLInputElement &&\n newElt.type !== 'file'\n ) {\n // https://github.com/bigskysoftware/idiomorph/issues/27\n // | old input value | new input value | behaviour |\n // | --------------- | ---------------- | -------------------------------------- |\n // | `null` | `null` | preserve old input value |\n // | some value | the same value | preserve old input value |\n // | some value | `null` | set old input value to `\"\"` |\n // | `null` | some value | set old input value to new input value |\n // | some value | some other value | set old input value to new input value |\n if (newElt.getAttribute('value') !== oldElt.getAttribute('value')) {\n oldElt.value = newElt.getAttribute('value') ?? ''\n }\n } else if (\n oldElt instanceof HTMLTextAreaElement &&\n newElt instanceof HTMLTextAreaElement\n ) {\n if (newElt.value !== oldElt.value) {\n oldElt.value = newElt.value\n }\n if (oldElt.firstChild && oldElt.firstChild.nodeValue !== newElt.value) {\n oldElt.firstChild.nodeValue = newElt.value\n }\n }\n\n const preserveAttrs = (\n (newNode as HTMLElement).getAttribute(aliasedPreserveAttr) ?? ''\n ).split(' ')\n\n for (const { name, value } of newElt.attributes) {\n if (\n oldElt.getAttribute(name) !== value &&\n !preserveAttrs.includes(name)\n ) {\n oldElt.setAttribute(name, value)\n }\n }\n\n for (let i = oldElt.attributes.length - 1; i >= 0; i--) {\n const { name } = oldElt.attributes[i]!\n if (!newElt.hasAttribute(name) && !preserveAttrs.includes(name)) {\n oldElt.removeAttribute(name)\n }\n }\n\n if (!oldElt.isEqualNode(newElt)) {\n morphChildren(oldElt, newElt)\n }\n }\n\n if (type === 8 /* comment */ || type === 3 /* text */) {\n if (oldNode.nodeValue !== newNode.nodeValue) {\n oldNode.nodeValue = newNode.nodeValue\n }\n }\n\n return oldNode\n}\n\n// A bottom-up algorithm that populates a map of Element -> IdSet.\n// The ID set for a given element is the set of all IDs contained within its subtree.\n// As an optimization, we filter these IDs through the given list of persistent IDs,\n// because we don't need to bother considering IDed elements that won't be in the new content.\nconst populateIdMapWithTree = (\n root: Element | ShadowRoot | null,\n elements: Iterable,\n): void => {\n for (const elt of elements) {\n if (ctxPersistentIds.has(elt.id)) {\n let current: Element | null = elt\n // walk up the parent hierarchy of that element, adding the ID of element to the parent's ID set\n while (current && current !== root) {\n let idSet = ctxIdMap.get(current)\n // if the ID set doesn’t exist, create it and insert it in the map\n if (!idSet) {\n idSet = new Set()\n ctxIdMap.set(current, idSet)\n }\n idSet.add(elt.id)\n current = current.parentElement\n }\n }\n }\n}\n","// Icon: material-symbols:cloud-download\n// Slug: Patches elements into the DOM.\n// Description: Patches elements into the DOM.\n\nimport { watcher } from '@engine'\nimport type { WatcherContext } from '@engine/types'\nimport { morph } from '@engine/morph'\nimport { supportsViewTransitions } from '@utils/view-transitions'\n\ntype PatchElementsMode =\n | 'remove'\n | 'outer'\n | 'inner'\n | 'replace'\n | 'prepend'\n | 'append'\n | 'before'\n | 'after'\n\ntype PatchElementsArgs = {\n elements: string\n mode: PatchElementsMode\n selector: string\n useViewTransition: boolean\n}\n\nwatcher({\n name: 'datastar-patch-elements',\n apply(\n ctx,\n { elements = '', selector = '', mode = 'outer', useViewTransition },\n ) {\n switch (mode) {\n case 'remove':\n case 'outer':\n case 'inner':\n case 'replace':\n case 'prepend':\n case 'append':\n case 'before':\n case 'after':\n break\n default:\n throw ctx.error('PatchElementsInvalidMode', { mode })\n }\n\n if (!selector && mode !== 'outer' && mode !== 'replace') {\n throw ctx.error('PatchElementsExpectedSelector')\n }\n\n const args2: PatchElementsArgs = {\n mode,\n selector,\n elements,\n useViewTransition: useViewTransition?.trim() === 'true',\n }\n\n if (supportsViewTransitions && useViewTransition) {\n document.startViewTransition(() => onPatchElements(ctx, args2))\n } else {\n onPatchElements(ctx, args2)\n }\n },\n})\n\nconst onPatchElements = (\n { error }: WatcherContext,\n { elements, selector, mode }: PatchElementsArgs,\n) => {\n const elementsWithSvgsRemoved = elements.replace(\n /]*>|>)([\\s\\S]*?)<\\/svg>/gim,\n '',\n )\n const hasHtml = /<\\/html>/.test(elementsWithSvgsRemoved)\n const hasHead = /<\\/head>/.test(elementsWithSvgsRemoved)\n const hasBody = /<\\/body>/.test(elementsWithSvgsRemoved)\n\n const newDocument = new DOMParser().parseFromString(\n hasHtml || hasHead || hasBody\n ? elements\n : ``,\n 'text/html',\n )\n\n let newContent = document.createDocumentFragment()\n if (hasHtml) {\n newContent.appendChild(newDocument.documentElement)\n } else if (hasHead && hasBody) {\n newContent.appendChild(newDocument.head)\n newContent.appendChild(newDocument.body)\n } else if (hasHead) {\n newContent.appendChild(newDocument.head)\n } else if (hasBody) {\n newContent.appendChild(newDocument.body)\n } else {\n newContent = newDocument.querySelector('template')!.content\n }\n\n if (!selector && (mode === 'outer' || mode === 'replace')) {\n for (const child of newContent.children) {\n let target: Element\n if (child instanceof HTMLHtmlElement) {\n target = document.documentElement\n } else if (child instanceof HTMLBodyElement) {\n target = document.body\n } else if (child instanceof HTMLHeadElement) {\n target = document.head\n } else {\n target = document.getElementById(child.id)!\n if (!target) {\n console.warn(error('PatchElementsNoTargetsFound'), {\n element: { id: child.id },\n })\n continue\n }\n }\n\n applyToTargets(mode as PatchElementsMode, child, [target])\n }\n } else {\n const targets = document.querySelectorAll(selector)\n if (!targets.length) {\n console.warn(error('PatchElementsNoTargetsFound'), { selector })\n return\n }\n\n applyToTargets(mode as PatchElementsMode, newContent, targets)\n }\n}\n\nconst scripts = new WeakSet()\nfor (const script of document.querySelectorAll('script')) {\n scripts.add(script)\n}\n\nconst execute = (target: Element): void => {\n const elScripts =\n target instanceof HTMLScriptElement\n ? [target]\n : target.querySelectorAll('script')\n for (const old of elScripts) {\n if (!scripts.has(old)) {\n const script = document.createElement('script')\n for (const { name, value } of old.attributes) {\n script.setAttribute(name, value)\n }\n script.text = old.text\n old.replaceWith(script)\n scripts.add(script)\n }\n }\n}\n\nconst applyPatchMode = (\n targets: Iterable,\n element: DocumentFragment | Element,\n action: string,\n) => {\n for (const target of targets) {\n const cloned = element.cloneNode(true) as Element\n execute(cloned)\n // @ts-expect-error\n target[action](cloned)\n }\n}\n\nconst applyToTargets = (\n mode: PatchElementsMode,\n element: DocumentFragment | Element,\n targets: Iterable,\n) => {\n switch (mode) {\n case 'remove':\n for (const target of targets) {\n target.remove()\n }\n break\n case 'outer':\n case 'inner':\n for (const target of targets) {\n morph(target, element.cloneNode(true) as Element, mode)\n execute(target)\n }\n break\n case 'replace':\n applyPatchMode(targets, element, 'replaceWith')\n break\n case 'prepend':\n case 'append':\n case 'before':\n case 'after':\n applyPatchMode(targets, element, mode)\n }\n}\n","// Icon: material-symbols:settings-input-antenna\n// Slug: Patches signals.\n// Description: Patches signals.\n\nimport { watcher } from '@engine'\nimport { mergePatch } from '@engine/signals'\nimport { jsStrToObject } from '@utils/text'\n\nwatcher({\n name: 'datastar-patch-signals',\n apply({ error }, { signals, onlyIfMissing }) {\n if (signals) {\n const ifMissing = onlyIfMissing?.trim() === 'true'\n mergePatch(jsStrToObject(signals), { ifMissing })\n } else {\n throw error('PatchSignalsExpectedSignals')\n }\n },\n})\n","// Icon: material-symbols:send\n// Slug: Dispatches custom browser events for component communication.\n// Description: Dispatches CustomEvents globally or to specific elements, enabling event-driven reactive patterns like Livewire.\n\nimport { action } from '@engine'\n\n/**\n * Dispatch Action - Event Communication System\n *\n * Mirrors Livewire's dispatch system, enabling both frontend and backend\n * to trigger custom events for component communication.\n *\n * Usage:\n * @dispatch('event-name') // Global dispatch\n * @dispatch('event-name', {data: 'value'}) // With data\n * @dispatch('event-name', {id: 123}, {selector: '#target'}) // Targeted\n *\n * Options:\n * - selector: CSS selector for targeted dispatch (multiple elements supported)\n * - window: Force window dispatch (default: true if no selector)\n * - bubbles: Event bubbles (default: true)\n * - cancelable: Event can be canceled (default: true)\n * - composed: Event composes through shadow DOM (default: true)\n */\naction({\n name: 'dispatch',\n apply: (\n { el },\n eventName: string,\n data?: any,\n options?: {\n selector?: string\n window?: boolean\n bubbles?: boolean\n cancelable?: boolean\n composed?: boolean\n }\n ) => {\n // Validate event name\n if (!eventName || typeof eventName !== 'string') {\n console.error(\n '[Hyper Dispatch] Invalid event name. Must be a non-empty string.',\n { eventName }\n )\n return\n }\n\n // Default options\n const opts = {\n selector: options?.selector,\n window: options?.window ?? (!options?.selector), // Default to window if no selector\n bubbles: options?.bubbles ?? true,\n cancelable: options?.cancelable ?? true,\n composed: options?.composed ?? true,\n }\n\n // Create CustomEvent\n const event = new CustomEvent(eventName, {\n detail: data || {},\n bubbles: opts.bubbles,\n cancelable: opts.cancelable,\n composed: opts.composed,\n })\n\n // Targeted dispatch\n if (opts.selector) {\n const targets = document.querySelectorAll(opts.selector)\n\n if (targets.length === 0) {\n console.warn(\n `[Hyper Dispatch] No elements found for selector: ${opts.selector}`,\n { eventName, selector: opts.selector }\n )\n return\n }\n\n targets.forEach((target) => {\n target.dispatchEvent(new CustomEvent(eventName, {\n detail: data || {},\n bubbles: opts.bubbles,\n cancelable: opts.cancelable,\n composed: opts.composed,\n }))\n })\n\n return\n }\n\n // Global dispatch (window)\n if (opts.window) {\n window.dispatchEvent(event)\n return\n }\n\n // Element dispatch (fallback to current element)\n el.dispatchEvent(event)\n },\n})\n","// Icon: material-symbols:attachment\n// Slug: Converts file sources to valid HTML src/href URLs with smart fallbacks.\n// Description: Handles base64 arrays from file binding and provides fallbacks. File paths are auto-converted by Laravel backend.\n\nimport { action } from '@engine'\nimport type { ActionContext } from '@engine/types'\n\n/**\n * FileUrl Action - Simplified and Reliable\n *\n * Now much simpler since Laravel handles path-to-URL conversion.\n * Frontend only needs to handle:\n * - Base64 arrays from Datastar file inputs\n * - URLs (already converted by Laravel)\n * - Fallbacks for missing/empty data\n *\n * Usage Examples:\n * @fileUrl($profilePicture) // Auto-detect with fallback\n * @fileUrl($document, {fallback: '/default.pdf'}) // Custom fallback\n * @fileUrl('/storage/file.jpg') // URL passthrough\n * @fileUrl($avatar, {defaultMime: 'image/png'}) // Custom MIME for base64\n */\naction({\n name: 'fileUrl',\n apply: (\n _ctx: ActionContext,\n fileSource: any,\n options?: {\n fallback?: string\n defaultMime?: string\n mimeSignal?: string // Name of companion MIME signal\n }\n ): string => {\n const opts = options || {}\n const fallback = opts.fallback || ''\n const defaultMime = opts.defaultMime || 'application/octet-stream'\n const mimeSignal = opts.mimeSignal\n\n // Handle null, undefined, or empty values\n if (fileSource == null) {\n return fallback\n }\n\n // Handle arrays (from Datastar file binding)\n if (Array.isArray(fileSource)) {\n // Empty array means no file selected\n if (fileSource.length === 0) {\n return fallback\n }\n\n const firstItem = fileSource[0]\n\n // RC6 Format: Array of {name, contents, mime} objects\n if (typeof firstItem === 'object' && firstItem !== null && 'contents' in firstItem) {\n const { contents, mime } = firstItem as { contents: string; mime: string; name?: string }\n\n // Validate contents\n if (!contents || typeof contents !== 'string') {\n return fallback\n }\n\n // Use MIME from object (RC6 provides it automatically)\n const mimeType = mime || defaultMime\n\n // Construct data URL\n return `data:${mimeType};base64,${contents}`\n }\n\n // Legacy Format: Array of base64 strings (for backward compatibility)\n const base64Content = firstItem\n if (!base64Content || typeof base64Content !== 'string') {\n return fallback\n }\n\n // Determine MIME type\n let mimeType = defaultMime\n\n // Try to get MIME from companion signal if specified\n if (mimeSignal && typeof window !== 'undefined') {\n const mimeArray = ((window as any).$ || {})[mimeSignal]\n if (Array.isArray(mimeArray) && mimeArray.length > 0) {\n mimeType = mimeArray[0] || defaultMime\n }\n }\n\n // Construct data URL\n return `data:${mimeType};base64,${base64Content}`\n }\n\n // Handle strings (URLs, paths, data URLs)\n if (typeof fileSource === 'string') {\n const trimmed = fileSource.trim()\n\n // Empty string\n if (!trimmed) {\n return fallback\n }\n\n // Laravel should have converted file paths to proper URLs\n // We just return whatever string we got\n return trimmed\n }\n\n // Handle unexpected types gracefully\n if (typeof fileSource === 'object') {\n console.warn(\n 'FileUrl action received unexpected object:',\n fileSource\n )\n }\n\n return fallback\n },\n})\n","// Icon: material-symbols:arrow-back\n// Slug: Navigate back with fallback support\n// Description: Convenience action for going back to previous page or fallback URL\n\nimport { action, actions } from '@engine'\nimport type { ActionContext } from '@engine/types'\n\n/**\n * Back Action - Navigate to Previous Page\n *\n * Convenience action for navigating back in browser history.\n * Falls back to a specified URL if no referrer available.\n *\n * USAGE EXAMPLES:\n *\n * @back() // Go back or to '/'\n * @back('/dashboard') // Go back or to '/dashboard'\n * @back('/home', 'back') // With custom navigation key\n * @back('/home', 'back', {merge: true}) // With options\n */\naction({\n name: 'back',\n apply: async (\n ctx: ActionContext,\n fallback: string = '/',\n key: string = 'back',\n options: Record = {}\n ): Promise => {\n // Determine the back URL\n let backUrl = fallback\n\n // Try to get the previous URL from browser history\n if (\n document.referrer &&\n document.referrer !== window.location.href\n ) {\n try {\n const referrerUrl = new URL(document.referrer)\n const currentUrl = new URL(window.location.href)\n\n // Only use referrer if it's from the same origin\n if (referrerUrl.origin === currentUrl.origin) {\n backUrl = document.referrer\n }\n } catch (error) {\n console.warn('Error parsing referrer URL:', error)\n // Use fallback\n }\n }\n\n // Call @navigate action\n const navigateAction = actions.navigate\n\n if (navigateAction) {\n await navigateAction(ctx, backUrl, key, {\n merge: true, // Default to merge for back navigation\n ...options,\n })\n } else {\n console.error('Navigate action not found. Falling back to location.href')\n window.location.href = backUrl\n }\n },\n})\n\n/**\n * EXAMPLES FOR DOCUMENTATION:\n *\n * // Basic usage - go back or to home\n * \n *\n * // With custom fallback\n * \n *\n * // With navigation key (for targeted updates)\n * \n *\n * // With options\n * \n */\n","// Icon: material-symbols:refresh\n// Slug: Refresh current page reactively\n// Description: Convenience action for refreshing the current page using Datastar navigation\n\nimport { action, actions } from '@engine'\nimport type { ActionContext } from '@engine/types'\n\n/**\n * Refresh Action - Reactive Page Refresh\n *\n * Convenience action for refreshing the current page using Datastar navigation\n * (not a full page reload). Preserves reactive state and uses SSE updates.\n *\n * USAGE EXAMPLES:\n *\n * @refresh() // Refresh current page (merge by default)\n * @refresh('refresh') // With custom navigation key\n * @refresh('refresh', {merge: false}) // Refresh without merging query params\n */\naction({\n name: 'refresh',\n apply: async (\n ctx: ActionContext,\n key: string = 'refresh',\n options: Record = {}\n ): Promise => {\n // Get current URL\n const currentUrl = window.location.href\n\n // Call @navigate action with current URL\n const navigateAction = actions.navigate\n\n if (navigateAction) {\n await navigateAction(ctx, currentUrl, key, {\n merge: true, // Default to merge for refresh\n replace: true, // Replace history entry (don't add new)\n ...options,\n })\n } else {\n console.error('Navigate action not found. Falling back to location.reload')\n window.location.reload()\n }\n },\n})\n\n/**\n * EXAMPLES FOR DOCUMENTATION:\n *\n * // Basic refresh - updates current page reactively\n * \n *\n * // With custom navigation key (for targeted updates)\n * \n *\n * // Clean refresh (no query param merging)\n * \n *\n * // Polling pattern\n *
\n * Auto-refreshing content\n *
\n */\n","// Icon: material-symbols:restart-alt\n// Slug: Force full page reload\n// Description: Hard refresh that discards all client state and reloads the page completely\n\nimport { action } from '@engine'\nimport type { ActionContext } from '@engine/types'\n\n/**\n * Reload Action - Hard Page Refresh\n *\n * Forces a complete page reload, discarding all client-side state.\n * This breaks out of the reactive mode and performs a traditional full page load.\n *\n * Use when you need to completely reset the application state or when\n * reactive updates are insufficient (e.g., after major configuration changes).\n *\n * USAGE EXAMPLES:\n *\n * @reload() // Force full page reload\n */\naction({\n name: 'reload',\n apply: (_ctx: ActionContext): void => {\n // Perform hard refresh\n // This is equivalent to the browser's refresh button (F5)\n window.location.reload()\n },\n})\n\n/**\n * EXAMPLES FOR DOCUMENTATION:\n *\n * // Full page reload (discards all state)\n * \n *\n * // Use after settings change\n * \n *\n * // Emergency reset\n * \n */\n","// Icon: material-symbols:error-outline\n// Slug: Shows validation error for a field.\n// Description: Displays the first validation error message for a specified field from the errors signal.\n\nimport { attribute } from '@engine'\nimport { effect, computed, getPath, mergePatch } from '@engine/signals'\n\nattribute({\n name: 'error',\n requirement: {\n key: 'denied',\n value: 'must',\n },\n apply({ el, value }) {\n const fieldName = value.trim()\n\n // Ensure the 'errors' signal exists and is initialized as an empty object\n // This works like the signals plugin - creates the signal if missing\n mergePatch({ errors: {} }, { ifMissing: true })\n\n // Create a computed that reactively reads the error for this field\n const errorComputed = computed(() => {\n const errors = getPath('errors')\n if (errors && errors[fieldName]) {\n const fieldErrors = errors[fieldName]\n if (Array.isArray(fieldErrors) && fieldErrors.length > 0) {\n return fieldErrors[0]\n } else if (typeof fieldErrors === 'string') {\n return fieldErrors\n }\n }\n return null\n })\n\n // Effect that updates the DOM when the error changes\n const cleanup = effect(() => {\n const errorMessage = errorComputed()\n\n if (errorMessage) {\n el.style.removeProperty('display')\n el.textContent = errorMessage\n } else {\n el.style.setProperty('display', 'none')\n el.textContent = ''\n }\n })\n\n return cleanup\n },\n})\n","// Icon: material-symbols:repeat\n// Slug: Iterates over an array or object with efficient diffing.\n// Description: Production-ready loop directive with keyed diffing, zero signal pollution, and full Datastar reactivity integration.\n\nimport { attribute, apply } from '@engine'\nimport { effect, getPath, mergePatch, root, beginBatch, endBatch, startPeeking, stopPeeking } from '@engine/signals'\nimport type { HTMLOrSVG } from '@engine/types'\n\n/**\n * ARCHITECTURE: Clean Loop Implementation\n *\n * PRINCIPLES:\n * 1. NO SIGNAL POLLUTION - Only source signals exist (e.g., $users)\n * 2. Direct mapping: iterator \"user\" → source path \"users.0\", \"users.1\", etc.\n * 3. Datastar's Proxy-based reactivity handles all property mutations\n * 4. Keyed diffing reuses DOM elements, retransforming indices on position changes\n * 5. Efficient change detection: no-change, simple-add, simple-remove, complex\n */\n\ninterface IterationState {\n prevKeys: any[];\n lookup: Map;\n template: HTMLTemplateElement;\n templateContent: DocumentFragment;\n iteratorNames: ParsedExpression;\n keyExpression: string | null;\n effectCleanup: (() => void) | null;\n sourceSignalPath: string;\n isNormalized: boolean;\n iterationId: string;\n}\n\ninterface LoopElement {\n el: HTMLOrSVG;\n key: any;\n index: number;\n}\n\ninterface ParsedExpression {\n item: string;\n index?: string;\n collection?: string;\n items: string;\n}\n\nconst iterationStates = new WeakMap()\n\n// Helper function to peek without tracking\nconst peek = (fn: () => void) => {\n startPeeking()\n try {\n fn()\n } finally {\n stopPeeking()\n }\n}\n\nattribute({\n name: 'for',\n requirement: {\n key: 'denied',\n value: 'must',\n },\n apply(ctx) {\n const { el, value, mods, error } = ctx\n\n // Validate template element\n if (!(el instanceof HTMLTemplateElement)) {\n throw error('ForMustBeOnTemplate', {\n message: 'data-for must be used on