Created
June 21, 2026 13:06
-
-
Save lukaskleinschmidt/67089c186a1415dcd2acb169f9bf05f2 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| namespace LukasKleinschmidt; | |
| use Exception; | |
| use Kirby\Cms\App; | |
| use Kirby\Toolkit\Html; | |
| use Kirby\Toolkit\Str; | |
| use Stringable; | |
| class Vite implements Stringable | |
| { | |
| /** | |
| * The Vite instance. | |
| */ | |
| protected static Vite $instance; | |
| /** | |
| * The Content Security Policy nonce to apply to all generated tags. | |
| */ | |
| protected ?string $nonce = null; | |
| /** | |
| * The key to check for integrity hashes within the manifest. | |
| */ | |
| protected string|false $integrity = 'integrity'; | |
| /** | |
| * The configured entry points. | |
| */ | |
| protected array $entries = []; | |
| /** | |
| * The path to the "hot" file. | |
| */ | |
| protected ?string $hotFile = null; | |
| /** | |
| * The path to the build directory. | |
| */ | |
| protected string $buildDirectory = 'build'; | |
| /** | |
| * The name of the manifest file. | |
| */ | |
| protected string $manifest = 'manifest.json'; | |
| /** | |
| * The preload tag attributes. | |
| */ | |
| protected array $preloadTagAttributesResolvers = []; | |
| /** | |
| * The script tag attributes. | |
| */ | |
| protected array $scriptTagAttributesResolvers = []; | |
| /** | |
| * The style tag attributes. | |
| */ | |
| protected array $styleTagAttributesResolvers = []; | |
| /** | |
| * The preloaded assets. | |
| */ | |
| protected array $preloadedAssets = []; | |
| /** | |
| * The cached manifest files. | |
| */ | |
| protected static array $manifests = []; | |
| /** | |
| * Create a new Vite instance. | |
| */ | |
| public function __construct( | |
| protected App $app, | |
| ) {} | |
| /** | |
| * Generate Vite tags for an entrypoint. | |
| * | |
| * @param string|string[] $entries | |
| * | |
| * @throws \Exception | |
| */ | |
| public function __invoke(array|string $entries, string $buildDirectory = null): ?string | |
| { | |
| $manifest = $this->manifest($buildDirectory ??= $this->buildDirectory); | |
| $entries = (array) $entries; | |
| $exists = []; | |
| foreach ($entries as $key => $value) { | |
| $query = Str::template($value, [ | |
| 'kirby' => $this->app, | |
| 'site' => $this->app->site(), | |
| 'page' => $this->app->site()->page(), | |
| ]); | |
| if ($optional = str_starts_with($query, '@')) { | |
| $query = substr($query, 1); | |
| } | |
| if ($optional || $query !== $value) { | |
| if ($this->exists($query) || array_key_exists($query, $manifest)) { | |
| $entries[$key] = $query; | |
| $exists[] = $query; | |
| } else { | |
| unset($entries[$key]); | |
| } | |
| } | |
| } | |
| if ($this->isRunningHot()) { | |
| array_unshift($entries, '@vite/client'); | |
| return join('', array_map(fn (string $value) => | |
| $this->makeTag($value, $this->hotAsset($value)) | |
| , $entries)); | |
| } | |
| $preloads = []; | |
| $assets = []; | |
| foreach ($entries as $key) { | |
| try { | |
| $chunk = $this->chunk($manifest, $key); | |
| } catch (Exception $e) { | |
| if (! in_array($key, $exists)) throw $e; | |
| } | |
| if (! isset($chunk)) { | |
| continue; | |
| } | |
| $args = [ | |
| $key, | |
| url($buildDirectory . '/'. $file = $chunk['file']), | |
| $chunk, | |
| $manifest, | |
| ]; | |
| if (! isset($preloads[$file])) { | |
| $preloads[$file] = $this->makePreloadTag(...$args); | |
| } | |
| if (! isset($assets[$file])) { | |
| $assets[$file] = $this->makeTag(...$args); | |
| } | |
| $this->resolveImports($chunk, $buildDirectory, $manifest, $assets, $preloads); | |
| $this->resolveCss($chunk, $buildDirectory, $manifest, $assets, $preloads); | |
| } | |
| uksort($preloads, fn ($a, $b) => | |
| $this->isStylePath($a) === $this->isStylePath($b) ? 0 : 1 | |
| ); | |
| uksort($assets, fn ($a, $b) => | |
| $this->isStylePath($a) === $this->isStylePath($b) ? 0 : 1 | |
| ); | |
| return join('', $preloads) . join('', $assets); | |
| } | |
| /** | |
| * Create a new Vite instance. | |
| */ | |
| public static function instance(App $app = null): static | |
| { | |
| return static::$instance ??= new static($app ?? App::instance()); | |
| } | |
| /** | |
| * Returns a copy of the current Vite instance. | |
| */ | |
| public static function copy(): static | |
| { | |
| return clone static::instance(); | |
| } | |
| /** | |
| * Get the chunk for the given entry point / asset. | |
| * | |
| * @throws \Exception | |
| */ | |
| protected function chunk(array $manifest, string $file): array | |
| { | |
| if (! isset($manifest[$file])) { | |
| throw new Exception("Unable to locate file in Vite manifest: {$file}"); | |
| } | |
| return $manifest[$file]; | |
| } | |
| /** | |
| * Check if a source file exists. | |
| */ | |
| protected function exists(string $file): bool | |
| { | |
| $base = $this->app->root('vite:base') ?? $this->app->root('base'); | |
| $index = $this->app->root('index'); | |
| $paths = [ | |
| $base ?? $index, | |
| dirname($index), | |
| ]; | |
| foreach ($paths as $path) { | |
| if (file_exists(rtrim($path, '/') . '/' . $file)) { | |
| return true; | |
| } | |
| } | |
| return false; | |
| } | |
| /** | |
| * Get the the manifest file for the given build directory. | |
| * | |
| * @throws \Exception | |
| */ | |
| protected function manifest(string $buildDirectory): array | |
| { | |
| $path = $this->manifestPath($buildDirectory); | |
| if (! isset(static::$manifests[$path])) { | |
| if (! is_file($path)) { | |
| throw new Exception("Vite manifest not found at: {$path}"); | |
| } | |
| static::$manifests[$path] = json_decode(file_get_contents($path), true); | |
| } | |
| return static::$manifests[$path]; | |
| } | |
| /** | |
| * Get the path to the manifest file for the given build directory. | |
| */ | |
| protected function manifestPath(string $buildDirectory): string | |
| { | |
| return $this->app->root('index') . '/' . $buildDirectory . '/' . $this->manifest; | |
| } | |
| /** | |
| * Make a tag for the given chunk. | |
| */ | |
| protected function makeTag(string $src, string $url, array $chunk = [], array $manifest = []): string | |
| { | |
| if ($this->isStylePath($url)) { | |
| return $this->makeStyleTag( | |
| $url, | |
| $this->resolveStyleTagAttributes($src, $url, $chunk, $manifest) | |
| ); | |
| } | |
| return $this->makeScriptTag( | |
| $url, | |
| $this->resolveScriptTagAttributes($src, $url, $chunk, $manifest) | |
| ); | |
| } | |
| /** | |
| * Generate a script tag with attributes for the given URL. | |
| */ | |
| protected function makeScriptTag(string $url, array $attributes = []): string | |
| { | |
| return Html::tag('script', '', array_merge([ | |
| 'type' => 'module', | |
| 'src' => $url, | |
| 'nonce' => $this->nonce(), | |
| ], $attributes)); | |
| } | |
| /** | |
| * Generate a link tag with attributes for the given URL. | |
| */ | |
| protected function makeStyleTag(string $url, array $attributes = []): string | |
| { | |
| return Html::tag('link', '', array_merge([ | |
| 'rel' => 'stylesheet', | |
| 'href' => $url, | |
| 'nonce' => $this->nonce(), | |
| ], $attributes)); | |
| } | |
| /** | |
| * Make a preload tag for the given chunk. | |
| */ | |
| protected function makePreloadTag(string $src, string $url, array $chunk = [], array $manifest = []): string | |
| { | |
| $attributes = $this->resolvePreloadTagAttributes($src, $url, $chunk, $manifest); | |
| return $this->preloadedAssets[$url] ??= Html::tag('link', '', $attributes); | |
| } | |
| /** | |
| * Resolve the attributes for the chunks generated script tag. | |
| */ | |
| protected function resolveScriptTagAttributes(string $src, string $url, array $chunk = [], array $manifest = []): array | |
| { | |
| $attributes = $this->integrity !== false | |
| ? ['integrity' => $chunk[$this->integrity] ?? false] | |
| : []; | |
| foreach ($this->scriptTagAttributesResolvers as $resolver) { | |
| $attributes = array_merge($attributes, $resolver($src, $url, $chunk, $manifest)); | |
| } | |
| return $attributes; | |
| } | |
| /** | |
| * Resolve the attributes for the chunks generated stylesheet tag. | |
| */ | |
| protected function resolveStyleTagAttributes(string $src, string $url, array $chunk = [], array $manifest = []): array | |
| { | |
| $attributes = $this->integrity !== false | |
| ? ['integrity' => $chunk[$this->integrity] ?? false] | |
| : []; | |
| foreach ($this->styleTagAttributesResolvers as $resolver) { | |
| $attributes = array_merge($attributes, $resolver($src, $url, $chunk, $manifest)); | |
| } | |
| return $attributes; | |
| } | |
| /** | |
| * Resolve the attributes for the chunks generated preload tag. | |
| */ | |
| protected function resolvePreloadTagAttributes(string $src, string $url, array $chunk = [], array $manifest = []): array | |
| { | |
| $attributes = $this->isStylePath($url) ? [ | |
| 'rel' => 'preload', | |
| 'as' => 'style', | |
| 'href' => $url, | |
| 'nonce' => $this->nonce(), | |
| 'crossorigin' => $this->resolveStyleTagAttributes($src, $url, $chunk, $manifest)['crossorigin'] ?? false, | |
| ] : [ | |
| 'rel' => 'modulepreload', | |
| 'href' => $url, | |
| 'nonce' => $this->nonce(), | |
| 'crossorigin' => $this->resolveScriptTagAttributes($src, $url, $chunk, $manifest)['crossorigin'] ?? false, | |
| ]; | |
| $attributes = $this->integrity !== false | |
| ? array_merge($attributes, ['integrity' => $chunk[$this->integrity] ?? false]) | |
| : $attributes; | |
| foreach ($this->preloadTagAttributesResolvers as $resolver) { | |
| $attributes = array_merge($attributes, $resolver($src, $url, $chunk, $manifest)); | |
| } | |
| return $attributes; | |
| } | |
| /** | |
| * Get the preloaded assets. | |
| */ | |
| public function preloadedAssets(): array | |
| { | |
| return $this->preloadedAssets; | |
| } | |
| /** | |
| * Get the URL for an asset. | |
| */ | |
| public function asset(string $asset, string $buildDirectory = null): string | |
| { | |
| $buildDirectory ??= $this->buildDirectory; | |
| if ($this->isRunningHot()) { | |
| return $this->hotAsset($asset); | |
| } | |
| $chunk = $this->chunk($this->manifest($buildDirectory), $asset); | |
| return url($buildDirectory . '/' . $chunk['file']); | |
| } | |
| /** | |
| * Get URL(s) of CSS file(s) associated with an asset. | |
| * | |
| * @param string $asset The asset to get CSS for | |
| * @param string|null $buildDirectory Optional build directory | |
| * @return array Array of CSS file URLs | |
| */ | |
| public function assetCss(string $asset, string $buildDirectory = null): array | |
| { | |
| $buildDirectory ??= $this->buildDirectory; | |
| if ($this->isRunningHot()) { | |
| return []; | |
| } | |
| $chunk = $this->chunk($this->manifest($buildDirectory), $asset); | |
| $cssFiles = []; | |
| // Get direct CSS files | |
| foreach ($chunk['css'] ?? [] as $cssFile) { | |
| $cssFiles[] = url($buildDirectory . '/' . $cssFile); | |
| } | |
| return $cssFiles; | |
| } | |
| /** | |
| * Generate React refresh runtime script. | |
| */ | |
| public function reactRefresh(): string | |
| { | |
| if (! $this->isRunningHot()) { | |
| return ''; | |
| } | |
| $attributes = Html::attr([ | |
| 'nonce' => $this->nonce(), | |
| ]); | |
| return sprintf( | |
| <<<'HTML' | |
| <script type="module" %s> | |
| import RefreshRuntime from '%s' | |
| RefreshRuntime.injectIntoGlobalHook(window) | |
| window.$RefreshReg$ = () => {} | |
| window.$RefreshSig$ = () => (type) => type | |
| window.__vite_plugin_react_preamble_installed__ = true | |
| </script> | |
| HTML, | |
| $attributes, | |
| $this->hotAsset('@react-refresh') | |
| ); | |
| } | |
| /** | |
| * Determine whether the given path is a style file. | |
| */ | |
| public function isStylePath(string $path): bool | |
| { | |
| return preg_match('/\.(css|less|sass|scss|styl|stylus|pcss|postcss)$/', $path) === 1; | |
| } | |
| /** | |
| * Determine if the HMR server is running. | |
| */ | |
| public function isRunningHot(): bool | |
| { | |
| return is_file($this->hotFile()); | |
| } | |
| /** | |
| * Get the Vite "hot" file path. | |
| */ | |
| public function hotFile(): string | |
| { | |
| return $this->hotFile ?? $this->app->root('index') . '/hot'; | |
| } | |
| /** | |
| * Get the path to a given asset when running in HMR mode. | |
| */ | |
| public function hotAsset(string $asset): string | |
| { | |
| return rtrim(file_get_contents($this->hotFile())) . '/' . $asset; | |
| } | |
| /** | |
| * Get the Content Security Policy nonce applied to all generated tags. | |
| */ | |
| public function nonce(): ?string | |
| { | |
| return $this->nonce; | |
| } | |
| /** | |
| * Resolve related css files. | |
| */ | |
| public function resolveCss(array $chunk, string $buildDirectory, array $manifest, array &$assets, array &$preloads): void | |
| { | |
| foreach ($chunk['css'] ?? [] as $key) { | |
| $chunks = array_filter($manifest, fn ($value) => | |
| $value['file'] === $key | |
| ); | |
| if (empty($chunks)) { | |
| $chunks[$key] = ['file' => $key]; | |
| } | |
| $key = array_key_first($chunks); | |
| $chunk = current($chunks); | |
| $file = $chunk['file']; | |
| $args = [ | |
| $key, | |
| url($buildDirectory . '/'. $file), | |
| $chunk, | |
| $manifest, | |
| ]; | |
| if (! isset($assets[$file])) { | |
| $assets[$file] = $this->makeTag(...$args); | |
| } | |
| if (! isset($preloads[$file])) { | |
| $preloads[$file] = $this->makePreloadTag(...$args); | |
| } | |
| } | |
| } | |
| /** | |
| * Resolve related imports. | |
| */ | |
| public function resolveImports(array $chunk, string $buildDirectory, array $manifest, array &$assets, array &$preloads): void | |
| { | |
| foreach ($chunk['imports'] ?? [] as $key) { | |
| $chunk = $this->chunk($manifest, $key); | |
| $file = $chunk['file']; | |
| if (! isset($preloads[$file])) { | |
| $preloads[$file] = $this->makePreloadTag( | |
| $key, | |
| url($buildDirectory . '/'. $file), | |
| $chunk, | |
| $manifest, | |
| ); | |
| } | |
| $this->resolveCss($chunk, $buildDirectory, $manifest, $assets, $preloads); | |
| } | |
| } | |
| /** | |
| * Generate or set a Content Security Policy nonce to apply to all generated tags. | |
| */ | |
| public function useNonce(string $nonce = null): static | |
| { | |
| $this->nonce = $nonce ?? Str::random(40); | |
| return $this; | |
| } | |
| /** | |
| * Set the filename for the manifest file. | |
| */ | |
| public function useManifest(string $name): static | |
| { | |
| $this->manifest = $name; | |
| return $this; | |
| } | |
| /** | |
| * Use the given key to detect integrity hashes in the manifest. | |
| */ | |
| public function useIntegrity(string|false $key): static | |
| { | |
| $this->integrity = $key; | |
| return $this; | |
| } | |
| /** | |
| * Set the Vite "hot" file path. | |
| */ | |
| public function useHotFile(string $path): static | |
| { | |
| $this->hotFile = $path; | |
| return $this; | |
| } | |
| /** | |
| * Set the Vite build directory. | |
| */ | |
| public function useBuildDirectory(string $path): static | |
| { | |
| $this->buildDirectory = $path; | |
| return $this; | |
| } | |
| /** | |
| * Use the given callback to resolve attributes for script tags. | |
| */ | |
| public function useScriptTagAttributes(array|callable $attributes): static | |
| { | |
| if (! is_callable($attributes)) { | |
| $attributes = fn () => $attributes; | |
| } | |
| $this->scriptTagAttributesResolvers[] = $attributes; | |
| return $this; | |
| } | |
| /** | |
| * Use the given callback to resolve attributes for style tags. | |
| */ | |
| public function useStyleTagAttributes(array|callable $attributes): static | |
| { | |
| if (! is_callable($attributes)) { | |
| $attributes = fn () => $attributes; | |
| } | |
| $this->styleTagAttributesResolvers[] = $attributes; | |
| return $this; | |
| } | |
| /** | |
| * Use the given callback to resolve attributes for preload tags. | |
| */ | |
| public function usePreloadTagAttributes(array|callable $attributes): static | |
| { | |
| if (! is_callable($attributes)) { | |
| $attributes = fn () => $attributes; | |
| } | |
| $this->preloadTagAttributesResolvers[] = $attributes; | |
| return $this; | |
| } | |
| /** | |
| * Set the Vite entry points. | |
| */ | |
| public function withEntries(array $entries): static | |
| { | |
| $this->entries = $entries; | |
| return $this; | |
| } | |
| /** | |
| * Get the Vite tag content as a string of HTML. | |
| */ | |
| public function __toString(): string | |
| { | |
| return $this->__invoke($this->entries); | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment