Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 83
0.00% covered (danger)
0.00%
0 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
ViteTwigExtension
0.00% covered (danger)
0.00%
0 / 83
0.00% covered (danger)
0.00%
0 / 11
552
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getFunctions
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
2
 renderStyles
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
 renderScripts
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
 renderProductionStyles
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 renderProductionScripts
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
6
 renderDevStyles
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 renderDevScripts
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
 isDevServerRunning
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 handleError
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3declare(strict_types=1);
4
5namespace BuyerKiosk\Vite;
6
7/**
8 * Twig Extension for Vite Asset Injection
9 *
10 * Provides two Twig functions for injecting Vite-bundled assets:
11 * - vite_styles(entry): Outputs <link> tags for CSS (use in <head>)
12 * - vite_scripts(entry): Outputs <script type="module"> tags (use at end of body)
13 *
14 * Supports two modes:
15 * - Production: Uses manifest.json for hashed asset URLs
16 * - Development: Uses Vite dev server URLs with HMR support
17 *
18 * @example
19 * {# In <head> - CSS first to prevent FOUC #}
20 * {{ vite_styles('workspace') }}
21 *
22 * {# At end of <body> - JS after legacy scripts #}
23 * {{ vite_scripts('workspace') }}
24 */
25class ViteTwigExtension extends \Twig_Extension
26{
27    /**
28     * Vite manifest reader
29     */
30    private ViteManifest $manifest;
31
32    /**
33     * Whether dev server mode is enabled
34     */
35    private bool $devServerEnabled;
36
37    /**
38     * Dev server URL (e.g., http://localhost:5173)
39     */
40    private string $devServerUrl;
41
42    /**
43     * Whether to render error messages in HTML (vs throwing exceptions)
44     */
45    private bool $renderErrors;
46
47    /**
48     * @param ViteManifest $manifest Manifest reader instance
49     * @param bool $devServerEnabled Enable dev server mode (default: false)
50     * @param string $devServerUrl Dev server URL (default: http://localhost:5173)
51     * @param bool $renderErrors Render errors as HTML comments (default: true)
52     */
53    public function __construct(
54        ViteManifest $manifest,
55        bool $devServerEnabled = false,
56        string $devServerUrl = 'http://localhost:5173',
57        bool $renderErrors = true
58    ) {
59        $this->manifest = $manifest;
60        $this->devServerEnabled = $devServerEnabled;
61        $this->devServerUrl = rtrim($devServerUrl, '/');
62        $this->renderErrors = $renderErrors;
63    }
64
65    /**
66     * @inheritDoc
67     */
68    public function getName(): string
69    {
70        return 'vite';
71    }
72
73    /**
74     * @inheritDoc
75     */
76    public function getFunctions(): array
77    {
78        return [
79            new \Twig_SimpleFunction(
80                'vite_styles',
81                [$this, 'renderStyles'],
82                ['is_safe' => ['html']]
83            ),
84            new \Twig_SimpleFunction(
85                'vite_scripts',
86                [$this, 'renderScripts'],
87                ['is_safe' => ['html']]
88            ),
89        ];
90    }
91
92    /**
93     * Render CSS <link> tags for an entry
94     *
95     * Should be called in <head> to prevent FOUC.
96     *
97     * @param string $entry Entry name (e.g., 'workspace')
98     * @return string HTML link tags
99     */
100    public function renderStyles(string $entry): string
101    {
102        try {
103            if ($this->devServerEnabled && $this->isDevServerRunning()) {
104                // In dev mode, Vite injects CSS via JS, so we don't need separate link tags
105                // However, we can still include the entry's CSS for initial load
106                return $this->renderDevStyles($entry);
107            }
108
109            return $this->renderProductionStyles($entry);
110        } catch (ViteManifestException $e) {
111            return $this->handleError($e, 'styles', $entry);
112        }
113    }
114
115    /**
116     * Render JS <script> tags for an entry
117     *
118     * Should be called at end of <body> to ensure:
119     * - Legacy scripts run first
120     * - No blocking of page render
121     *
122     * @param string $entry Entry name (e.g., 'workspace')
123     * @return string HTML script tags
124     */
125    public function renderScripts(string $entry): string
126    {
127        try {
128            if ($this->devServerEnabled && $this->isDevServerRunning()) {
129                return $this->renderDevScripts($entry);
130            }
131
132            return $this->renderProductionScripts($entry);
133        } catch (ViteManifestException $e) {
134            return $this->handleError($e, 'scripts', $entry);
135        }
136    }
137
138    /**
139     * Render production CSS links from manifest
140     */
141    private function renderProductionStyles(string $entry): string
142    {
143        $cssUrls = $this->manifest->getCssUrls($entry);
144
145        if (empty($cssUrls)) {
146            return '';
147        }
148
149        $html = [];
150        foreach ($cssUrls as $url) {
151            $html[] = sprintf(
152                '<link rel="stylesheet" href="%s">',
153                htmlspecialchars($url, ENT_QUOTES, 'UTF-8')
154            );
155        }
156
157        return implode("\n", $html);
158    }
159
160    /**
161     * Render production script tags from manifest
162     */
163    private function renderProductionScripts(string $entry): string
164    {
165        $jsUrl = $this->manifest->getJsUrl($entry);
166        $preloadUrls = $this->manifest->getPreloadUrls($entry);
167
168        $html = [];
169
170        // Add modulepreload hints for imported chunks
171        foreach ($preloadUrls as $url) {
172            $html[] = sprintf(
173                '<link rel="modulepreload" href="%s">',
174                htmlspecialchars($url, ENT_QUOTES, 'UTF-8')
175            );
176        }
177
178        // Add the main entry script
179        $html[] = sprintf(
180            '<script type="module" src="%s"></script>',
181            htmlspecialchars($jsUrl, ENT_QUOTES, 'UTF-8')
182        );
183
184        return implode("\n", $html);
185    }
186
187    /**
188     * Render dev server CSS (minimal - Vite handles HMR injection)
189     */
190    private function renderDevStyles(string $entry): string
191    {
192        // In dev mode, Vite injects CSS via JS for HMR
193        // We return empty string; styles will be injected by Vite client
194        return '';
195    }
196
197    /**
198     * Render dev server script tags
199     */
200    private function renderDevScripts(string $entry): string
201    {
202        $html = [];
203
204        // Vite client for HMR
205        $html[] = sprintf(
206            '<script type="module" src="%s/@vite/client"></script>',
207            $this->devServerUrl
208        );
209
210        // Entry point
211        $html[] = sprintf(
212            '<script type="module" src="%s/resources/js/%s.js"></script>',
213            $this->devServerUrl,
214            htmlspecialchars($entry, ENT_QUOTES, 'UTF-8')
215        );
216
217        return implode("\n", $html);
218    }
219
220    /**
221     * Check if dev server is running
222     *
223     * Makes a quick HEAD request to avoid blocking.
224     */
225    private function isDevServerRunning(): bool
226    {
227        static $isRunning = null;
228
229        if ($isRunning !== null) {
230            return $isRunning;
231        }
232
233        // Quick check with short timeout
234        $context = stream_context_create([
235            'http' => [
236                'method' => 'HEAD',
237                'timeout' => 0.5,
238            ],
239        ]);
240
241        $headers = @get_headers($this->devServerUrl, 0, $context);
242        $isRunning = $headers !== false && strpos($headers[0] ?? '', '200') !== false;
243
244        return $isRunning;
245    }
246
247    /**
248     * Handle errors - either render as HTML comment or throw
249     */
250    private function handleError(ViteManifestException $e, string $type, string $entry): string
251    {
252        $message = sprintf(
253            'Vite %s error for entry "%s": %s',
254            $type,
255            $entry,
256            $e->getMessage()
257        );
258
259        if ($this->renderErrors) {
260            return sprintf('<!-- %s -->', htmlspecialchars($message, ENT_QUOTES, 'UTF-8'));
261        }
262
263        throw $e;
264    }
265}