• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

valksor / php-dev-build / 21323318062

24 Jan 2026 11:21PM UTC coverage: 27.706% (-2.8%) from 30.503%
21323318062

push

github

k0d3r1s
wip

1 of 2 new or added lines in 2 files covered. (50.0%)

909 existing lines in 16 files now uncovered.

791 of 2855 relevant lines covered (27.71%)

0.96 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/Service/ImportmapService.php
1
<?php declare(strict_types = 1);
2

3
/*
4
 * This file is part of the Valksor package.
5
 *
6
 * (c) Davis Zalitis (k0d3r1s)
7
 * (c) SIA Valksor <packages@valksor.com>
8
 *
9
 * For the full copyright and license information, please view the LICENSE
10
 * file that was distributed with this source code.
11
 */
12

13
namespace ValksorDev\Build\Service;
14

15
use FilesystemIterator;
16
use JsonException;
17
use RecursiveDirectoryIterator;
18
use RecursiveIteratorIterator;
19
use RuntimeException;
20
use Symfony\Component\Console\Command\Command;
21
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
22
use Symfony\Component\Process\Exception\ProcessFailedException;
23
use Symfony\Component\Process\Process;
24
use Valksor\Bundle\Service\PathFilter;
25
use Valksor\Component\Sse\Service\AbstractService;
26
use Valksor\FullStack;
27
use ValksorDev\Build\Binary\EsbuildBinary;
28

29
use function array_key_exists;
30
use function array_keys;
31
use function basename;
32
use function class_exists;
33
use function closedir;
34
use function copy;
35
use function count;
36
use function dirname;
37
use function file_exists;
38
use function function_exists;
39
use function is_array;
40
use function is_dir;
41
use function is_file;
42
use function opendir;
43
use function pcntl_async_signals;
44
use function pcntl_signal;
45
use function readdir;
46
use function rmdir;
47
use function sprintf;
48
use function str_contains;
49
use function str_ends_with;
50
use function str_starts_with;
51
use function stream_select;
52
use function strlen;
53
use function substr;
54
use function unlink;
55

56
use const DIRECTORY_SEPARATOR;
57
use const SIGHUP;
58
use const SIGINT;
59
use const SIGTERM;
60

61
/**
62
 * Importmap service for building and watching JavaScript modules.
63
 *
64
 * This service handles:
65
 * - Processing JavaScript modules across multi-app structure
66
 * - Building with optional esbuild minification
67
 * - Watch mode with file system monitoring
68
 * - Managing shared infrastructure and app-specific assets
69
 * - Integration with ValkSSE component assets
70
 */
71
final class ImportmapService extends AbstractService
72
{
73
    /**
74
     * Path filter for ignoring directories and files during asset discovery.
75
     */
76
    private PathFilter $filter;
77

78
    public function __construct(
79
        ParameterBagInterface $bag,
80
    ) {
UNCOV
81
        parent::__construct($bag);
×
UNCOV
82
        $this->filter = PathFilter::createDefault($this->projectDir);
×
83
    }
84

85
    /**
86
     * @param array<string,mixed> $config Configuration: ['watch' => bool, 'minify' => bool, 'esbuild' => ?string]
87
     *
88
     * @throws JsonException
89
     */
90
    public function start(
91
        array $config = [],
92
    ): int {
UNCOV
93
        $watch = $config['watch'];
×
94
        $minify = $config['minify'];
×
95
        $esbuild = $this->resolveEsbuildExecutable($minify);
×
96

97
        $roots = $this->collectRoots();
×
98

UNCOV
99
        if ([] === $roots) {
×
100
            $this->io->warning('No asset roots found.');
×
101

102
            return Command::SUCCESS;
×
103
        }
104

105
        $this->io->note(sprintf('Processing %d root%s', count($roots), 1 === count($roots) ? '' : 's'));
×
106

UNCOV
107
        $this->buildAll($roots, $esbuild, $minify);
×
108

109
        if (!$watch) {
×
UNCOV
110
            return Command::SUCCESS;
×
111
        }
112

UNCOV
113
        if (!function_exists('pcntl_async_signals')) {
×
114
            $this->io->error('Watch mode requires the pcntl extension which is not available.');
×
115

UNCOV
116
            return Command::FAILURE;
×
117
        }
118

UNCOV
119
        return $this->watchRoots($roots, $esbuild, $minify);
×
120
    }
121

122
    /**
123
     * Get the service name for identification in the build system.
124
     *
125
     * @return string The service identifier 'importmap'
126
     */
127
    public static function getServiceName(): string
128
    {
UNCOV
129
        return 'importmap';
×
130
    }
131

132
    /**
133
     * @param array<int,array{label:string,source:string,dist:string}> $roots
134
     */
135
    private function buildAll(
136
        array $roots,
137
        ?string $esbuild,
138
        bool $minify,
139
    ): void {
140
        // Clear and recreate all dist directories for clean builds
UNCOV
141
        foreach ($roots as $root) {
×
UNCOV
142
            $this->removeDirectory($root['dist']);
×
143
            $this->ensureDirectory($root['dist']);
×
144
        }
145

146
        // Collect all JavaScript modules from all root directories
147
        // This includes shared infrastructure, ValkSSE components, and app-specific modules
UNCOV
148
        $modules = [];
×
149

UNCOV
150
        foreach ($roots as $root) {
×
151
            foreach ($this->collectModules($root) as $module) {
×
152
                $modules[] = $module;
×
153
            }
154
        }
155

UNCOV
156
        if ([] === $modules) {
×
157
            $this->io->warning('No JavaScript modules found.');
×
158

UNCOV
159
            return;
×
160
        }
161

162
        $this->io->section(sprintf('Building %d module%s', count($modules), 1 === count($modules) ? '' : 's'));
×
UNCOV
163
        $failures = 0;
×
164

UNCOV
165
        foreach ($modules as $module) {
×
166
            if (!$this->writeModule($module['source'], $module['output'], $esbuild, $minify)) {
×
167
                $failures++;
×
168
            }
169
        }
170

UNCOV
171
        if ($failures > 0) {
×
UNCOV
172
            $this->io->error(sprintf('Importmap sync completed with %d failure%s.', $failures, 1 === $failures ? '' : 's'));
×
173
        } else {
UNCOV
174
            $this->io->success('Importmap sync completed.');
×
175
        }
176
    }
177

178
    /**
179
     * @param array{label:string,source:string,dist:string} $root
180
     *
181
     * @return array<int,array{label:string,source:string,output:string}>
182
     */
183
    private function collectModules(
184
        array $root,
185
    ): array {
186
        if (!is_dir($root['source'])) {
×
187
            return [];
×
188
        }
189

190
        $modules = [];
×
191
        $iterator = new RecursiveIteratorIterator(
×
192
            new RecursiveDirectoryIterator($root['source'], FilesystemIterator::SKIP_DOTS),
×
UNCOV
193
        );
×
194

195
        foreach ($iterator as $file) {
×
196
            if (!$file->isFile()) {
×
UNCOV
197
                continue;
×
198
            }
199

200
            if ('js' !== $file->getExtension()) {
×
UNCOV
201
                continue;
×
202
            }
203

204
            if (str_contains($file->getPathname(), DIRECTORY_SEPARATOR . 'dist' . DIRECTORY_SEPARATOR)) {
×
205
                continue;
×
206
            }
207

208
            $relative = substr($file->getPathname(), strlen($root['source']) + 1);
×
209
            $output = $root['dist'] . DIRECTORY_SEPARATOR . $relative;
×
UNCOV
210
            $modules[] = [
×
UNCOV
211
                'label' => $root['label'],
×
212
                'source' => $file->getPathname(),
×
UNCOV
213
                'output' => $output,
×
UNCOV
214
            ];
×
215
        }
216

UNCOV
217
        return $modules;
×
218
    }
219

220
    /**
221
     * @return array<int,array{label:string,source:string,dist:string}>
222
     */
223
    private function collectRoots(): array
224
    {
225
        $roots = [];
×
226

227
        // Multi-app project structure: Discover shared and component assets
228

229
        // Shared infrastructure JavaScript (common utilities, components used across apps)
UNCOV
230
        $sharedJs = $this->parameterBag->get('kernel.project_dir') . DIRECTORY_SEPARATOR . $this->parameterBag->get('valksor.project.infrastructure_dir') . '/assets/js';
×
UNCOV
231
        $sharedDist = $this->parameterBag->get('kernel.project_dir') . DIRECTORY_SEPARATOR . $this->parameterBag->get('valksor.project.infrastructure_dir') . '/assets/dist';
×
232

233
        if (is_dir($sharedJs)) {
×
UNCOV
234
            $roots[] = ['label' => 'shared', 'source' => $sharedJs, 'dist' => $sharedDist];
×
235
        }
236

237
        // Determine ValkSSE component assets path based on installation type
238
        $projectDir = $this->parameterBag->get('kernel.project_dir');
×
239

240
        if (is_dir($projectDir . '/valksor')) {
×
241
            // Development/local installation
UNCOV
242
            $path = '/valksor/src/Valksor/Component/Sse/Resources/assets';
×
243
        } elseif (class_exists(FullStack::class)) {
×
244
            // Full-stack package installation
UNCOV
245
            $path = '/vendor/valksor/valksor/src/Valksor/Component/Sse/Resources/assets';
×
246
        } else {
247
            // Standalone SSE package installation
UNCOV
248
            $path = '/vendor/valksor/php-sse/Resources/assets';
×
249
        }
250

251
        if (is_dir($projectDir . $path . '/js')) {
×
UNCOV
252
            $roots[] = ['label' => 'valksorsse', 'source' => $projectDir . $path . '/js', 'dist' => $sharedDist];
×
253
        }
254

255
        // Discover application-specific JavaScript modules
256
        $appsDir = $this->parameterBag->get('kernel.project_dir') . DIRECTORY_SEPARATOR . $this->parameterBag->get('valksor.project.apps_dir');
×
257

258
        if (is_dir($appsDir)) {
×
259
            $handle = opendir($appsDir);
×
260

UNCOV
261
            if (false !== $handle) {
×
262
                try {
UNCOV
263
                    while (($entry = readdir($handle)) !== false) {
×
264
                        if ('.' === $entry || '..' === $entry) {
×
UNCOV
265
                            continue;
×
266
                        }
267

268
                        // Each app has its own assets/js directory for app-specific modules
UNCOV
269
                        $appSource = $appsDir . DIRECTORY_SEPARATOR . $entry . '/assets/js';
×
270

271
                        if (!is_dir($appSource)) {
×
272
                            continue;
×
273
                        }
274

UNCOV
275
                        $roots[] = [
×
UNCOV
276
                            'label' => $entry,
×
277
                            'source' => $appSource,
×
UNCOV
278
                            'dist' => $appsDir . DIRECTORY_SEPARATOR . $entry . '/assets/dist',
×
UNCOV
279
                        ];
×
280
                    }
281
                } finally {
282
                    closedir($handle);
×
283
                }
284
            }
285
        }
286

UNCOV
287
        return $roots;
×
288
    }
289

290
    private function removeDirectory(
291
        string $directory,
292
    ): void {
293
        if (!is_dir($directory)) {
×
294
            return;
×
295
        }
296

297
        $iterator = new RecursiveIteratorIterator(
×
298
            new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS),
×
299
            RecursiveIteratorIterator::CHILD_FIRST,
×
UNCOV
300
        );
×
301

UNCOV
302
        foreach ($iterator as $file) {
×
UNCOV
303
            if ($file->isDir()) {
×
UNCOV
304
                rmdir($file->getPathname());
×
305
            } else {
UNCOV
306
                unlink($file->getPathname());
×
307
            }
308
        }
309

UNCOV
310
        rmdir($directory);
×
311
    }
312

313
    private function removeModule(
314
        string $target,
315
    ): void {
UNCOV
316
        if (is_file($target)) {
×
UNCOV
317
            unlink($target);
×
318
        }
319
    }
320

321
    /**
322
     * @throws JsonException
323
     */
324
    private function resolveEsbuildExecutable(
325
        bool $minify,
326
    ): ?string {
UNCOV
327
        if (!$minify) {
×
UNCOV
328
            return null; // No minification needed, use simple copy
×
329
        }
330

331
        // Ensure esbuild binary is available
UNCOV
332
        $esbuildPath = $this->parameterBag->get('kernel.project_dir') . '/var/esbuild/esbuild';
×
333

UNCOV
334
        $esbuildManager = new EsbuildBinary()->createManager($this->parameterBag->get('kernel.project_dir') . '/var');
×
335

336
        // Ensure the latest esbuild is installed
337
        $esbuildManager->ensureLatest();
×
338

339
        return $esbuildPath;
×
340
    }
341

342
    /**
343
     * @param array<int,array{label:string,source:string,dist:string}> $roots
344
     */
345
    private function watchRoots(
346
        array $roots,
347
        ?string $esbuild,
348
        bool $minify,
349
    ): int {
350
        $this->io->section('Entering importmap watch mode. Press CTRL+C to stop.');
×
351

UNCOV
352
        $this->running = true;
×
353
        $this->shouldReload = false;
×
354
        $this->shouldShutdown = false;
×
355

UNCOV
356
        $rootToModules = [];
×
UNCOV
357
        $outputMap = [];
×
UNCOV
358
        $rootMetadata = [];
×
359

360
        foreach ($roots as $root) {
×
361
            $rootPath = $root['source'];
×
UNCOV
362
            $rootMetadata[$rootPath] = $root;
×
363

364
            if (!is_array($rootToModules) ? array_key_exists($rootPath, $rootToModules) : isset($rootToModules[$rootPath])) {
×
365
                $rootToModules[$rootPath] = [];
×
366
            }
367

UNCOV
368
            foreach ($this->collectModules($root) as $module) {
×
369
                $rootToModules[$rootPath][$module['source']] = $module;
×
UNCOV
370
                $outputMap[$module['output']] = true;
×
371
            }
372
        }
373

UNCOV
374
        $watcher = new RecursiveInotifyWatcher($this->filter, function (string $path) use (&$rootToModules, &$outputMap, $rootMetadata, $esbuild, $minify): void {
×
375
            if (is_array($outputMap) ? array_key_exists($path, $outputMap) : isset($outputMap[$path])) {
×
376
                return; // ignore writes to dist
×
377
            }
378

379
            foreach ($rootToModules as $root => $modules) {
×
380
                if (!str_starts_with($path, $root)) {
×
UNCOV
381
                    continue;
×
382
                }
383

UNCOV
384
                $relative = substr($path, strlen($root) + 1);
×
385

386
                if ('' === $relative || !str_ends_with($relative, '.js')) {
×
UNCOV
387
                    continue;
×
388
                }
389

390
                if (str_contains($relative, 'dist' . DIRECTORY_SEPARATOR)) {
×
UNCOV
391
                    continue;
×
392
                }
393

UNCOV
394
                $sourcePath = $root . DIRECTORY_SEPARATOR . $relative;
×
395
                $distRoot = $rootMetadata[$root]['dist'] ?? null;
×
396

397
                if (null === $distRoot) {
×
398
                    continue;
×
399
                }
400

UNCOV
401
                $outputPath = $distRoot . DIRECTORY_SEPARATOR . $relative;
×
402

403
                if (!file_exists($sourcePath)) {
×
404
                    $this->removeModule($outputPath);
×
UNCOV
405
                    unset($rootToModules[$root][$sourcePath], $outputMap[$outputPath]);
×
406

407
                    continue;
×
408
                }
409

410
                $label = $modules[$sourcePath]['label'] ?? $rootMetadata[$root]['label'] ?? basename($root);
×
UNCOV
411
                $module = [
×
UNCOV
412
                    'label' => $label,
×
413
                    'source' => $sourcePath,
×
414
                    'output' => $outputPath,
×
415
                ];
×
416

417
                if ($this->writeModule($module['source'], $module['output'], $esbuild, $minify)) {
×
418
                    $rootToModules[$root][$sourcePath] = $module;
×
419
                    $outputMap[$module['output']] = true;
×
420
                }
421
            }
422
        });
×
423

424
        foreach (array_keys($rootToModules) as $root) {
×
425
            $watcher->addRoot($root);
×
426
        }
427

428
        pcntl_async_signals(true);
×
429
        pcntl_signal(SIGINT, function (): void {
×
UNCOV
430
            $this->stop();
×
431
        });
×
432
        pcntl_signal(SIGTERM, function (): void {
×
UNCOV
433
            $this->stop();
×
434
        });
×
UNCOV
435
        pcntl_signal(SIGHUP, function (): void {
×
436
            $this->reload();
×
437
        });
×
438

439
        while ($this->running && !$this->shouldShutdown) {
×
UNCOV
440
            $stream = $watcher->getStream();
×
UNCOV
441
            $read = [$stream];
×
442
            $write = null;
×
UNCOV
443
            $except = null;
×
UNCOV
444
            $ready = @stream_select($read, $write, $except, 0, 250_000);
×
445

446
            if (false === $ready) {
×
UNCOV
447
                continue;
×
448
            }
449
            $watcher->poll();
×
450

UNCOV
451
            if ($this->shouldReload) {
×
452
                $this->io->newLine();
×
453
                $this->io->section('Reloading importmap sync...');
×
454
                $this->shouldReload = false;
×
455

456
                // Rebuild all
UNCOV
457
                $this->buildAll($roots, $esbuild, $minify);
×
458

459
                // Refresh watcher state
UNCOV
460
                $rootToModules = [];
×
UNCOV
461
                $outputMap = [];
×
462

463
                foreach ($roots as $root) {
×
UNCOV
464
                    $rootPath = $root['source'];
×
465
                    $rootToModules[$rootPath] = [];
×
466

UNCOV
467
                    foreach ($this->collectModules($root) as $module) {
×
UNCOV
468
                        $rootToModules[$rootPath][$module['source']] = $module;
×
UNCOV
469
                        $outputMap[$module['output']] = true;
×
470
                    }
471
                }
472

UNCOV
473
                $this->io->success('Importmap reloaded.');
×
474
            }
475
        }
476

UNCOV
477
        $this->io->newLine();
×
478
        $this->io->success('Importmap watch terminated.');
×
479

480
        return Command::SUCCESS;
×
481
    }
482

483
    private function writeModule(
484
        string $source,
485
        string $target,
486
        ?string $esbuild,
487
        bool $minify,
488
    ): bool {
489
        $this->ensureDirectory(dirname($target));
×
490

491
        if ($minify && null !== $esbuild) {
×
492
            // Use esbuild for minification when enabled
493
            if (!is_file($esbuild)) {
×
UNCOV
494
                throw new RuntimeException(sprintf('esbuild binary not found: %s', $esbuild));
×
495
            }
496

UNCOV
497
            if (!chmod($esbuild, 0o755)) {
×
UNCOV
498
                throw new RuntimeException(sprintf('Failed to set executable permissions on esbuild binary: %s', $esbuild));
×
499
            }
500
            $process = new Process([
×
501
                $esbuild,
×
UNCOV
502
                $source,
×
503
                '--outfile=' . $target,
×
UNCOV
504
                '--format=esm',      // ES Module format for modern browsers
×
UNCOV
505
                '--target=es2020',   // Target modern JavaScript features
×
506
                '--minify',          // Enable minification
×
UNCOV
507
            ], $this->parameterBag->get('kernel.project_dir'));
×
UNCOV
508
            $process->setTimeout(null);
×
509

510
            try {
UNCOV
511
                $process->mustRun();
×
512

UNCOV
513
                return true;
×
UNCOV
514
            } catch (ProcessFailedException $exception) {
×
UNCOV
515
                $this->io->error(sprintf('esbuild failed for %s: %s', $source, $exception->getProcess()->getErrorOutput() ?: $exception->getMessage()));
×
516

UNCOV
517
                return false;
×
518
            }
519
        }
520

521
        // Simple copy when minification is disabled
UNCOV
522
        if (!copy($source, $target)) {
×
UNCOV
523
            $this->io->error(sprintf('Failed to copy %s to %s', $source, $target));
×
524

UNCOV
525
            return false;
×
526
        }
527

UNCOV
528
        return true;
×
529
    }
530
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc