diff --git a/README.md b/README.md index be44114..d561cda 100644 --- a/README.md +++ b/README.md @@ -582,7 +582,7 @@ VITE_CACHE_CONFIG=default ``` > [!TIP] -> Caching is disabled by default. Enable it in production for ~5-10x faster manifest reads (~0.1ms vs ~2-7ms per request). +> Enabling caching could improve manifest read performance, particularly when using memory-based solutions such as Redis that avoid file I/O operations. ### Multiple Configurations diff --git a/src/Service/AssetService.php b/src/Service/AssetService.php index 3e8bc71..1523262 100644 --- a/src/Service/AssetService.php +++ b/src/Service/AssetService.php @@ -7,6 +7,7 @@ use CakeVite\Enum\ScriptType; use CakeVite\Exception\ConfigurationException; use CakeVite\ValueObject\AssetTag; +use CakeVite\ValueObject\ManifestCollection; use CakeVite\ValueObject\ManifestEntry; use CakeVite\ValueObject\ViteConfig; @@ -111,17 +112,18 @@ private function generateDevelopmentScriptTags(ViteConfig $config, array $option private function generateProductionScriptTags(ViteConfig $config, array $options): array { $env = $this->environmentService->detect($config); - $manifest = $this->manifestService->load($config, $env); + $fullManifest = $this->manifestService->load($config, $env); - // Apply filters if specified + // Apply filters if specified (for selecting which entries to render) + $manifestForEntries = $fullManifest; if (!empty($options['files'])) { - $manifest = $manifest->filterByPattern($options['files']); + $manifestForEntries = $manifestForEntries->filterByPattern($options['files']); } elseif (!empty($options['filter'])) { - $manifest = $manifest->filterByPattern($options['filter']); + $manifestForEntries = $manifestForEntries->filterByPattern($options['filter']); } // Filter to script entries and sort by load order - $entries = $manifest + $entries = $manifestForEntries ->filterByType(AssetType::Script) ->filterEntries() ->sortByLoadOrder(); @@ -131,9 +133,10 @@ private function generateProductionScriptTags(ViteConfig $config, array $options $alreadyPreloaded = []; // Generate preload tags first (if enabled) + // Use full manifest to resolve import keys if (!$config->preloadMode->isNone()) { foreach ($entries as $entry) { - $preloadTags = $this->generatePreloadTags($entry, $config, $alreadyPreloaded); + $preloadTags = $this->generatePreloadTags($entry, $fullManifest, $config, $alreadyPreloaded); $tags = array_merge($tags, $preloadTags); } } @@ -163,19 +166,22 @@ private function generateProductionScriptTags(ViteConfig $config, array $options * Generate preload tags for an entry's imports * * @param \CakeVite\ValueObject\ManifestEntry $entry Manifest entry + * @param \CakeVite\ValueObject\ManifestCollection $manifest Full manifest for resolving imports * @param \CakeVite\ValueObject\ViteConfig $config Configuration * @param array $alreadyPreloaded Track preloaded URLs to avoid duplicates * @return array<\CakeVite\ValueObject\AssetTag> */ private function generatePreloadTags( ManifestEntry $entry, + ManifestCollection $manifest, ViteConfig $config, array &$alreadyPreloaded, ): array { $tags = []; $pluginPrefix = $config->pluginName ? $config->pluginName . '.' : ''; - foreach ($entry->getImportUrls() as $importUrl) { + // Resolve import keys to actual file URLs using the manifest + foreach ($manifest->resolveImportUrls($entry->imports) as $importUrl) { $fullUrl = $pluginPrefix . $importUrl; if (isset($alreadyPreloaded[$fullUrl])) { diff --git a/src/ValueObject/ManifestCollection.php b/src/ValueObject/ManifestCollection.php index 6e4d76e..eacc85d 100644 --- a/src/ValueObject/ManifestCollection.php +++ b/src/ValueObject/ManifestCollection.php @@ -84,4 +84,53 @@ public function sortByLoadOrder(): self return new self($items); } + + /** + * Find entry by manifest key + * + * @param string $key Manifest key to search for + * @return \CakeVite\ValueObject\ManifestEntry|null Entry if found, null otherwise + */ + public function findByKey(string $key): ?ManifestEntry + { + foreach ($this as $entry) { + if ($entry->key === $key) { + return $entry; + } + } + + return null; + } + + /** + * Resolve import keys to file URLs + * + * In Vite's manifest, the `imports` array contains keys that reference + * other entries in the manifest, not direct file paths. This method + * looks up each import key and returns the actual file URL. + * + * Performance: Uses indexBy() for O(n+m) complexity instead of O(n*m) + * with repeated linear searches. + * + * @param array $importKeys Import keys from manifest entry + * @return array Resolved file URLs + */ + public function resolveImportUrls(array $importKeys): array + { + if ($importKeys === []) { + return []; + } + + // Create keyed lookup for O(1) access - O(n) operation done once + $keyedManifest = $this->indexBy(fn(ManifestEntry $entry): string => $entry->key)->toArray(); + + $urls = []; + foreach ($importKeys as $key) { + if (isset($keyedManifest[$key]) && $keyedManifest[$key] instanceof ManifestEntry) { + $urls[] = $keyedManifest[$key]->getUrl(); + } + } + + return $urls; + } } diff --git a/src/View/Helper/ViteHelper.php b/src/View/Helper/ViteHelper.php index 9fb0a5b..52932ec 100644 --- a/src/View/Helper/ViteHelper.php +++ b/src/View/Helper/ViteHelper.php @@ -97,9 +97,17 @@ public function script(array|string $options = [], array|ViteConfig|null $config $tags = $this->getAssetService()->generateScriptTags($config, $options); - // Render script tags + // Render tags (preload tags as link elements, script tags as script elements) foreach ($tags as $tag) { - $this->Html->script($tag->url, array_merge(['block' => $block], $tag->attributes)); + if ($tag->isPreload) { + // Render preload tags as + $preloadType = $tag->preloadType ?? 'modulepreload'; + $linkTag = $this->buildPreloadLinkTag($preloadType, $tag->url, $tag->attributes); + $this->getView()->append($block, $linkTag); + } else { + // Render regular script tags + $this->Html->script($tag->url, array_merge(['block' => $block], $tag->attributes)); + } } // Add dependent CSS if in production @@ -310,6 +318,58 @@ private function mergePreloadOption( return ['preload' => $preloadMode]; } + /** + * Build preload link tag with proper attribute rendering + * + * Supports attributes like crossorigin, integrity, as, etc. + * Filters out 'rel' attribute to avoid conflicts with preloadType. + * + * @param string $preloadType Preload type (modulepreload, preload, etc.) + * @param string $url Asset URL + * @param array $attributes HTML attributes + * @return string Complete link tag HTML + */ + private function buildPreloadLinkTag(string $preloadType, string $url, array $attributes): string + { + // Filter out 'rel' attribute to avoid conflicts + $attributes = array_filter( + $attributes, + fn($key): bool => $key !== 'rel', + ARRAY_FILTER_USE_KEY, + ); + + // Build attribute string + $attributesString = ''; + foreach ($attributes as $attrName => $attrValue) { + if ($attrValue === null) { + continue; + } + + // Handle boolean attributes (e.g., async, defer) + if (is_bool($attrValue)) { + if ($attrValue) { + $attributesString .= ' ' . h((string)$attrName); + } + + continue; + } + + // Regular attributes with values + $attributesString .= sprintf( + ' %s="%s"', + h((string)$attrName), + h((string)$attrValue), + ); + } + + return sprintf( + '', + h($preloadType), + h($url), + $attributesString, + ); + } + /** * Get or create asset service * diff --git a/tests/Fixture/manifest-with-imports.json b/tests/Fixture/manifest-with-imports.json index 047099b..cf7c556 100644 --- a/tests/Fixture/manifest-with-imports.json +++ b/tests/Fixture/manifest-with-imports.json @@ -4,19 +4,22 @@ "src": "src/app.ts", "isEntry": true, "css": ["assets/app-xyz789.css"], - "imports": ["assets/vendor-def456.js", "assets/utils-ghi789.js"] + "imports": ["_vendor-def456.js", "_utils-ghi789.js"] }, - "assets/vendor-def456.js": { + "_vendor-def456.js": { "file": "assets/vendor-def456.js", + "src": "_vendor-def456.js", "isEntry": false, - "imports": ["assets/react-jkl012.js"] + "imports": ["_react-jkl012.js"] }, - "assets/utils-ghi789.js": { + "_utils-ghi789.js": { "file": "assets/utils-ghi789.js", + "src": "_utils-ghi789.js", "isEntry": false }, - "assets/react-jkl012.js": { + "_react-jkl012.js": { "file": "assets/react-jkl012.js", + "src": "_react-jkl012.js", "isEntry": false } } diff --git a/tests/TestCase/ValueObject/ManifestCollectionTest.php b/tests/TestCase/ValueObject/ManifestCollectionTest.php new file mode 100644 index 0000000..f89b894 --- /dev/null +++ b/tests/TestCase/ValueObject/ManifestCollectionTest.php @@ -0,0 +1,261 @@ +file = 'assets/app.js'; + + $entry1 = ManifestEntry::fromManifestData('src/app.ts', $data1, null); + + $data2 = new stdClass(); + $data2->file = 'assets/vendor.js'; + + $entry2 = ManifestEntry::fromManifestData('_vendor.js', $data2, null); + + $collection = new ManifestCollection([$entry1, $entry2]); + + $found = $collection->findByKey('_vendor.js'); + + $this->assertNotNull($found); + $this->assertSame('_vendor.js', $found->key); + $this->assertSame('assets/vendor.js', $found->file); + } + + /** + * Test findByKey returns null when key does not exist + */ + public function testFindByKeyReturnsNullWhenNotExists(): void + { + $data = new stdClass(); + $data->file = 'assets/app.js'; + + $entry = ManifestEntry::fromManifestData('src/app.ts', $data, null); + + $collection = new ManifestCollection([$entry]); + + $found = $collection->findByKey('nonexistent.js'); + + $this->assertNull($found); + } + + /** + * Test resolveImportUrls resolves manifest keys to file URLs + */ + public function testResolveImportUrlsResolvesKeysToFileUrls(): void + { + // Create manifest entries + $appData = new stdClass(); + $appData->file = 'assets/app-abc123.js'; + $appData->imports = ['_vendor-def456.js', '_utils-ghi789.js']; + + $appEntry = ManifestEntry::fromManifestData('src/app.ts', $appData, null); + + $vendorData = new stdClass(); + $vendorData->file = 'assets/vendor-def456.js'; + + $vendorEntry = ManifestEntry::fromManifestData('_vendor-def456.js', $vendorData, null); + + $utilsData = new stdClass(); + $utilsData->file = 'assets/utils-ghi789.js'; + + $utilsEntry = ManifestEntry::fromManifestData('_utils-ghi789.js', $utilsData, null); + + $collection = new ManifestCollection([$appEntry, $vendorEntry, $utilsEntry]); + + // Resolve the app entry's imports + $resolvedUrls = $collection->resolveImportUrls($appEntry->imports); + + $expected = [ + '/assets/vendor-def456.js', + '/assets/utils-ghi789.js', + ]; + + $this->assertSame($expected, $resolvedUrls); + } + + /** + * Test resolveImportUrls with build directory + */ + public function testResolveImportUrlsWithBuildDirectory(): void + { + $appData = new stdClass(); + $appData->file = 'assets/app.js'; + $appData->imports = ['_vendor.js']; + + $appEntry = ManifestEntry::fromManifestData('src/app.ts', $appData, 'build'); + + $vendorData = new stdClass(); + $vendorData->file = 'assets/vendor.js'; + + $vendorEntry = ManifestEntry::fromManifestData('_vendor.js', $vendorData, 'build'); + + $collection = new ManifestCollection([$appEntry, $vendorEntry]); + + $resolvedUrls = $collection->resolveImportUrls($appEntry->imports); + + $this->assertSame(['/build/assets/vendor.js'], $resolvedUrls); + } + + /** + * Test resolveImportUrls skips keys that don't exist in manifest + */ + public function testResolveImportUrlsSkipsMissingKeys(): void + { + $appData = new stdClass(); + $appData->file = 'assets/app.js'; + $appData->imports = ['_vendor.js', '_missing.js', '_utils.js']; + + $appEntry = ManifestEntry::fromManifestData('src/app.ts', $appData, null); + + $vendorData = new stdClass(); + $vendorData->file = 'assets/vendor.js'; + + $vendorEntry = ManifestEntry::fromManifestData('_vendor.js', $vendorData, null); + + $utilsData = new stdClass(); + $utilsData->file = 'assets/utils.js'; + + $utilsEntry = ManifestEntry::fromManifestData('_utils.js', $utilsData, null); + + $collection = new ManifestCollection([$appEntry, $vendorEntry, $utilsEntry]); + + $resolvedUrls = $collection->resolveImportUrls($appEntry->imports); + + // Should skip _missing.js + $expected = [ + '/assets/vendor.js', + '/assets/utils.js', + ]; + + $this->assertSame($expected, $resolvedUrls); + } + + /** + * Test resolveImportUrls with empty imports array + */ + public function testResolveImportUrlsWithEmptyArray(): void + { + $data = new stdClass(); + $data->file = 'assets/app.js'; + + $entry = ManifestEntry::fromManifestData('src/app.ts', $data, null); + + $collection = new ManifestCollection([$entry]); + + $resolvedUrls = $collection->resolveImportUrls([]); + + $this->assertSame([], $resolvedUrls); + } + + /** + * Test resolveImportUrls resolves nested imports + */ + public function testResolveImportUrlsWithNestedImports(): void + { + // App imports vendor, vendor imports react + $appData = new stdClass(); + $appData->file = 'assets/app.js'; + $appData->imports = ['_vendor.js']; + + $appEntry = ManifestEntry::fromManifestData('src/app.ts', $appData, null); + + $vendorData = new stdClass(); + $vendorData->file = 'assets/vendor.js'; + $vendorData->imports = ['_react.js']; + + $vendorEntry = ManifestEntry::fromManifestData('_vendor.js', $vendorData, null); + + $reactData = new stdClass(); + $reactData->file = 'assets/react.js'; + + $reactEntry = ManifestEntry::fromManifestData('_react.js', $reactData, null); + + $collection = new ManifestCollection([$appEntry, $vendorEntry, $reactEntry]); + + // Resolve app's imports (should get vendor) + $appImports = $collection->resolveImportUrls($appEntry->imports); + $this->assertSame(['/assets/vendor.js'], $appImports); + + // Resolve vendor's imports (should get react) + $vendorImports = $collection->resolveImportUrls($vendorEntry->imports); + $this->assertSame(['/assets/react.js'], $vendorImports); + } + + /** + * Test filterByType works with resolved collections + */ + public function testFilterByTypeStillWorksAfterResolution(): void + { + $jsData = new stdClass(); + $jsData->file = 'assets/app.js'; + + $jsEntry = ManifestEntry::fromManifestData('src/app.ts', $jsData, null); + + $cssData = new stdClass(); + $cssData->file = 'assets/app.css'; + + $cssEntry = ManifestEntry::fromManifestData('src/app.css', $cssData, null); + + $collection = new ManifestCollection([$jsEntry, $cssEntry]); + + $scripts = $collection->filterByType(AssetType::Script); + $this->assertCount(1, $scripts); + + $styles = $collection->filterByType(AssetType::Style); + $this->assertCount(1, $styles); + } + + /** + * Test resolveImportUrls performance optimization with indexBy + * + * Verifies that the method uses O(n+m) keyed lookup instead of O(n*m) linear search + */ + public function testResolveImportUrlsUsesEfficientLookup(): void + { + // Create a larger manifest to demonstrate performance optimization + $entries = []; + $importKeys = []; + + // Create 50 entries + for ($i = 0; $i < 50; $i++) { + $data = new stdClass(); + $data->file = sprintf('assets/chunk-%d.js', $i); + + $key = sprintf('_chunk-%d.js', $i); + $entries[] = ManifestEntry::fromManifestData($key, $data, null); + + // Every 5th entry is an import target + if ($i % 5 === 0) { + $importKeys[] = $key; + } + } + + $collection = new ManifestCollection($entries); + + // Resolve 10 imports from 50 entries + // With O(n*m): 10 * 50 = 500 iterations + // With O(n+m) indexBy: 50 + 10 = 60 iterations + $resolved = $collection->resolveImportUrls($importKeys); + + $this->assertCount(10, $resolved); + $this->assertSame('/assets/chunk-0.js', $resolved[0]); + $this->assertSame('/assets/chunk-45.js', $resolved[9]); + } +} diff --git a/tests/TestCase/ValueObject/ManifestEntryTest.php b/tests/TestCase/ValueObject/ManifestEntryTest.php index 4ff5cff..6842743 100644 --- a/tests/TestCase/ValueObject/ManifestEntryTest.php +++ b/tests/TestCase/ValueObject/ManifestEntryTest.php @@ -253,7 +253,10 @@ public function testManifestEntryIsReadonly(): void } /** - * Test getImportUrls returns import URLs + * Test getImportUrls returns import keys with path processing + * + * Note: These are still keys, not resolved file paths. To resolve keys to actual + * file paths, use ManifestCollection::resolveImportUrls() instead. */ public function testGetImportUrlsReturnsImportUrls(): void { diff --git a/tests/TestCase/View/Helper/ViteHelperTest.php b/tests/TestCase/View/Helper/ViteHelperTest.php index 6678222..f9c806e 100644 --- a/tests/TestCase/View/Helper/ViteHelperTest.php +++ b/tests/TestCase/View/Helper/ViteHelperTest.php @@ -602,4 +602,129 @@ public function testScriptWithNamedConfigDirect(): void // Should use api config $this->assertStringContainsString('/api/', $result); } + + /** + * Test preload link tags render with additional attributes + */ + public function testPreloadLinkTagsRenderWithAttributes(): void + { + $config = [ + 'build' => ['manifestPath' => TESTS . 'Fixture' . DS . 'manifest-with-imports.json'], + 'preload' => 'link-tag', + 'forceProductionMode' => true, + ]; + + // Mock AssetService to inject attributes + $this->Vite->script(['files' => ['src/app.ts']], $config); + + $result = $this->View->fetch('script'); + + // Should contain preload tags + $this->assertStringContainsString('modulepreload', $result); + $this->assertStringContainsString(' ['manifestPath' => TESTS . 'Fixture' . DS . 'manifest-with-imports.json'], + 'preload' => 'link-tag', + 'forceProductionMode' => true, + ]); + + $this->Vite->script(['files' => ['src/app.ts']]); + + $result = $this->View->fetch('script'); + + // Verify preload tags are generated + $this->assertStringContainsString('modulepreload', $result); + } + + /** + * Test buildPreloadLinkTag filters out rel attribute + */ + public function testBuildPreloadLinkTagFiltersRelAttribute(): void + { + // Use reflection to test private method + $reflection = new ReflectionClass($this->Vite); + $method = $reflection->getMethod('buildPreloadLinkTag'); + + $result = $method->invoke( + $this->Vite, + 'modulepreload', + '/assets/vendor.js', + ['rel' => 'should-be-filtered', 'crossorigin' => 'anonymous'], + ); + + // Should only have modulepreload from parameter, not from attributes + $this->assertStringContainsString('rel="modulepreload"', $result); + $this->assertStringNotContainsString('should-be-filtered', $result); + $this->assertStringContainsString('crossorigin="anonymous"', $result); + } + + /** + * Test buildPreloadLinkTag handles boolean attributes + */ + public function testBuildPreloadLinkTagHandlesBooleanAttributes(): void + { + $reflection = new ReflectionClass($this->Vite); + $method = $reflection->getMethod('buildPreloadLinkTag'); + + $result = $method->invoke( + $this->Vite, + 'modulepreload', + '/assets/vendor.js', + ['async' => true, 'defer' => false], + ); + + // async=true should render as boolean attribute + $this->assertStringContainsString(' async', $result); + $this->assertStringNotContainsString('async="', $result); + + // defer=false should not render + $this->assertStringNotContainsString('defer', $result); + } + + /** + * Test buildPreloadLinkTag handles null attributes + */ + public function testBuildPreloadLinkTagIgnoresNullAttributes(): void + { + $reflection = new ReflectionClass($this->Vite); + $method = $reflection->getMethod('buildPreloadLinkTag'); + + $result = $method->invoke( + $this->Vite, + 'modulepreload', + '/assets/vendor.js', + ['crossorigin' => null, 'integrity' => 'sha256-abc123'], + ); + + // null attributes should be skipped + $this->assertStringNotContainsString('crossorigin', $result); + $this->assertStringContainsString('integrity="sha256-abc123"', $result); + } + + /** + * Test buildPreloadLinkTag escapes special characters + */ + public function testBuildPreloadLinkTagEscapesSpecialCharacters(): void + { + $reflection = new ReflectionClass($this->Vite); + $method = $reflection->getMethod('buildPreloadLinkTag'); + + $result = $method->invoke( + $this->Vite, + 'modulepreload', + '/assets/vendor.js', + ['data-test' => ''], + ); + + // Should escape HTML entities + $this->assertStringNotContainsString('