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

valksor / php-dev-build / 19384258487

15 Nov 2025 04:07AM UTC coverage: 19.747% (+2.5%) from 17.283%
19384258487

push

github

k0d3r1s
prettier

16 of 30 new or added lines in 4 files covered. (53.33%)

516 existing lines in 7 files now uncovered.

484 of 2451 relevant lines covered (19.75%)

1.03 hits per line

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

0.0
/Command/IconsGenerateCommand.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\Command;
14

15
use DOMDocument;
16
use JsonException;
17
use RuntimeException;
18
use Symfony\Component\Console\Attribute\Argument;
19
use Symfony\Component\Console\Attribute\AsCommand;
20
use Symfony\Component\Console\Input\InputInterface;
21
use Symfony\Component\Console\Output\OutputInterface;
22
use Symfony\Component\Console\Style\SymfonyStyle;
23
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
24
use ValksorDev\Build\Binary\LucideBinary;
25
use ValksorDev\Build\Provider\ProviderRegistry;
26

27
use function array_diff;
28
use function array_intersect;
29
use function array_map;
30
use function array_values;
31
use function closedir;
32
use function count;
33
use function file_get_contents;
34
use function file_put_contents;
35
use function glob;
36
use function implode;
37
use function in_array;
38
use function is_dir;
39
use function is_file;
40
use function json_decode;
41
use function opendir;
42
use function readdir;
43
use function rtrim;
44
use function sprintf;
45
use function str_ends_with;
46
use function substr;
47
use function unlink;
48

49
use const DIRECTORY_SEPARATOR;
50
use const GLOB_NOSORT;
51
use const JSON_THROW_ON_ERROR;
52

53
#[AsCommand(name: 'valksor:icons', description: 'Generate Twig SVG icons using Lucide and local overrides.')]
54
final class IconsGenerateCommand extends AbstractCommand
55
{
56
    private string $cacheRoot;
57
    private SymfonyStyle $io;
58
    private string $sharedIdentifier;
59

60
    public function __construct(
61
        ParameterBagInterface $parameterBag,
62
        ProviderRegistry $providerRegistry,
63
    ) {
64
        parent::__construct($parameterBag, $providerRegistry);
×
65
        $this->sharedIdentifier = $this->getInfrastructureDir();
×
66
    }
67

68
    /**
69
     * @throws JsonException
70
     */
71
    public function __invoke(
72
        #[Argument(
73
            description: 'Generate icons for a specific app (or "shared"). Default: all',
74
        )]
75
        ?string $target,
76
        InputInterface $input,
77
        OutputInterface $output,
78
    ): int {
79
        $this->io = $this->createSymfonyStyle($input, $output);
×
80
        $projectRoot = $this->resolveProjectRoot();
×
81
        $this->cacheRoot = $projectRoot . '/var/lucide';
×
82
        $this->ensureDirectory($this->cacheRoot);
×
83

84
        $lucideDir = $this->ensureLucideIcons();
×
85

86
        if (null === $lucideDir) {
×
87
            $this->io->warning('No Lucide icon source could be located. Only local and shared overrides will be used.');
×
88
        }
89

90
        $sharedIcons = $this->readJsonList($this->getInfrastructureDir() . '/assets/icons.json');
×
91
        $appIcons = $this->collectAppIcons($sharedIcons);
×
92

93
        $targets = $this->determineTargets($target, $sharedIcons, $appIcons);
×
94

95
        if ([] === $targets) {
×
96
            $this->io->warning('No icon targets found.');
×
97
            $this->cleanAllIconDirectories();
×
98

99
            return $this->handleCommandSuccess();
×
100
        }
101

102
        $localIconsDir = $projectRoot . '/project/js/icons';
×
103
        $sharedIconsDir = $this->getInfrastructureDir() . '/assets/icons';
×
104

105
        $generated = 0;
×
106

107
        foreach ($targets as $targetId => $iconNames) {
×
108
            $generated += $this->generateForTarget(
×
109
                $targetId,
×
110
                $iconNames,
×
111
                $localIconsDir,
×
112
                $sharedIconsDir,
×
113
                $lucideDir,
×
114
            );
×
115
        }
116

UNCOV
117
        if (0 === $generated) {
×
118
            $this->io->warning('No icons generated.');
×
119

UNCOV
120
            return $this->handleCommandSuccess();
×
121
        }
122

UNCOV
123
        return $this->handleCommandSuccess(sprintf('Generated %d icon file%s.', $generated, 1 === $generated ? '' : 's'), $this->io);
×
124
    }
125

126
    /**
127
     * Clean all known icon directories when no targets are found.
128
     */
129
    private function cleanAllIconDirectories(): void
130
    {
UNCOV
131
        $this->io->text('[CLEANUP] No icon targets found, cleaning all known icon directories...');
×
132

133
        // Clean shared icons directory
UNCOV
134
        $sharedIconsDir = $this->getInfrastructureDir() . '/templates/icons';
×
135

UNCOV
136
        if (is_dir($sharedIconsDir)) {
×
137
            $this->cleanExistingTwigIcons($sharedIconsDir);
×
138
            $this->io->text('[CLEANUP] Cleaned shared icons directory: ' . $sharedIconsDir);
×
139
        }
140

141
        // Clean app-specific icons directories
UNCOV
142
        $appsDir = $this->getAppsDir();
×
143

UNCOV
144
        if (is_dir($appsDir)) {
×
145
            $handle = opendir($appsDir);
×
146

UNCOV
147
            if (false !== $handle) {
×
148
                try {
UNCOV
149
                    while (($entry = readdir($handle)) !== false) {
×
150
                        if ('.' === $entry || '..' === $entry) {
×
151
                            continue;
×
152
                        }
153

UNCOV
154
                        $appIconsDir = $appsDir . '/' . $entry . '/templates/icons';
×
155

UNCOV
156
                        if (is_dir($appIconsDir)) {
×
157
                            $this->cleanExistingTwigIcons($appIconsDir);
×
158
                            $this->io->text(sprintf('[CLEANUP] Cleaned app icons directory: %s (%s)', $appIconsDir, $entry));
×
159
                        }
160
                    }
161
                } finally {
UNCOV
162
                    closedir($handle);
×
163
                }
164
            }
165
        }
166
    }
167

168
    private function cleanExistingTwigIcons(
169
        string $directory,
170
    ): void {
UNCOV
171
        $handle = opendir($directory);
×
172

UNCOV
173
        if (false === $handle) {
×
174
            return;
×
175
        }
176

177
        try {
UNCOV
178
            while (($entry = readdir($handle)) !== false) {
×
179
                if ('.' === $entry || '..' === $entry) {
×
180
                    continue;
×
181
                }
182

UNCOV
183
                if (!str_ends_with($entry, '.svg.twig')) {
×
184
                    continue;
×
185
                }
186

UNCOV
187
                @unlink($directory . DIRECTORY_SEPARATOR . $entry);
×
188
            }
189
        } finally {
UNCOV
190
            closedir($handle);
×
191
        }
192
    }
193

194
    /**
195
     * Clean up orphaned icons that are no longer in the current icon list.
196
     *
197
     * @param array<int,string> $currentIcons
198
     */
199
    private function cleanOrphanedIcons(
200
        string $directory,
201
        array $currentIcons,
202
    ): void {
UNCOV
203
        if (!is_dir($directory)) {
×
204
            return;
×
205
        }
206

UNCOV
207
        $handle = opendir($directory);
×
208

UNCOV
209
        if (false === $handle) {
×
210
            return;
×
211
        }
212

213
        try {
UNCOV
214
            while (($entry = readdir($handle)) !== false) {
×
215
                if ('.' === $entry || '..' === $entry) {
×
216
                    continue;
×
217
                }
218

UNCOV
219
                if (!str_ends_with($entry, '.svg.twig')) {
×
220
                    continue;
×
221
                }
222

223
                // Extract icon name from filename (remove .svg.twig extension)
UNCOV
224
                $iconName = substr($entry, 0, -9);
×
225

226
                // If this icon is not in the current list, remove it
UNCOV
227
                if (!in_array($iconName, $currentIcons, true)) {
×
228
                    $filePath = $directory . DIRECTORY_SEPARATOR . $entry;
×
229

UNCOV
230
                    if (@unlink($filePath)) {
×
231
                        $this->io->text(sprintf('[CLEANUP] Removed orphaned icon: %s', $iconName));
×
232
                    } else {
UNCOV
233
                        $this->io->warning(sprintf('[CLEANUP] Failed to remove orphaned icon: %s', $iconName));
×
234
                    }
235
                }
236
            }
237
        } finally {
UNCOV
238
            closedir($handle);
×
239
        }
240
    }
241

242
    /**
243
     * @return array<string,array<int,string>>
244
     */
245
    private function collectAppIcons(
246
        array $sharedIcons,
247
    ): array {
UNCOV
248
        $appsDir = $this->getAppsDir();
×
249
        $result = [];
×
250

UNCOV
251
        if (!is_dir($appsDir)) {
×
252
            return $result;
×
253
        }
254

UNCOV
255
        $handle = opendir($appsDir);
×
256

UNCOV
257
        if (false === $handle) {
×
258
            return $result;
×
259
        }
260

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

UNCOV
267
                $iconsPath = $appsDir . '/' . $entry . '/assets/icons.json';
×
268

UNCOV
269
                if (!is_file($iconsPath)) {
×
270
                    continue;
×
271
                }
272

UNCOV
273
                $icons = $this->readJsonList($iconsPath);
×
274

275
                // Don't skip empty icons.json files - they need cleanup
276

UNCOV
277
                $duplicates = array_values(array_intersect($icons, $sharedIcons));
×
278

UNCOV
279
                if ([] !== $duplicates) {
×
280
                    $this->io->note(sprintf(
×
281
                        'App "%s" defines icons already provided by shared: %s. Shared icons will be used.',
×
282
                        $entry,
×
283
                        implode(', ', $duplicates),
×
284
                    ));
×
285
                }
286

UNCOV
287
                $result[$entry] = array_values(array_diff($icons, $sharedIcons));
×
288
            }
289
        } finally {
UNCOV
290
            closedir($handle);
×
291
        }
292

UNCOV
293
        return $result;
×
294
    }
295

296
    /**
297
     * @param array<string,array<int,string>> $appIcons
298
     *
299
     * @return array<string,array<int,string>>
300
     */
301
    private function determineTargets(
302
        $targetArgument,
303
        array $sharedIcons,
304
        array $appIcons,
305
    ): array {
UNCOV
306
        $targets = [];
×
307

UNCOV
308
        if (null === $targetArgument) {
×
309
            $targets[$this->sharedIdentifier] = $sharedIcons;
×
310

UNCOV
311
            foreach ($appIcons as $app => $icons) {
×
312
                $targets[$app] = $icons;
×
313
            }
314

UNCOV
315
            return $targets;
×
316
        }
317

UNCOV
318
        $target = (string) $targetArgument;
×
319

UNCOV
320
        $sharedIdentifier = $this->sharedIdentifier;
×
321

UNCOV
322
        if ($target === $sharedIdentifier) {
×
323
            $targets[$this->sharedIdentifier] = $sharedIcons;
×
324

UNCOV
325
            return $targets;
×
326
        }
327

UNCOV
328
        if (isset($appIcons[$target]) || [] === $appIcons[$target]) {
×
329
            $this->io->warning(sprintf('No icons.json found for app "%s" or no icons defined.', $target));
×
330

UNCOV
331
            return [];
×
332
        }
333

UNCOV
334
        $targets[$this->sharedIdentifier] = $sharedIcons;
×
335
        $targets[$target] = $appIcons[$target];
×
336

UNCOV
337
        return $targets;
×
338
    }
339

340
    /**
341
     * @throws JsonException
342
     */
343
    private function ensureLucideIcons(): ?string
344
    {
345
        // First check if Lucide icons already exist locally
UNCOV
346
        $existingIconsDir = $this->findExistingLucideIcons();
×
347

UNCOV
348
        if (null !== $existingIconsDir) {
×
349
            $this->io->text(sprintf('Using existing Lucide icons from: %s', $existingIconsDir));
×
350

UNCOV
351
            return $existingIconsDir;
×
352
        }
353

354
        // If no existing icons found, download them using BinaryAssetManager
355
        try {
UNCOV
356
            LucideBinary::createForLucide($this->cacheRoot)->ensureLatest([$this->io, 'text']);
×
357

358
            // Look for the icons directory
UNCOV
359
            $iconsDir = $this->locateIconsDirectory($this->cacheRoot);
×
360

UNCOV
361
            if (null === $iconsDir) {
×
362
                throw new RuntimeException('Lucide icons directory could not be located after download.');
×
363
            }
364

UNCOV
365
            return $iconsDir;
×
366
        } catch (RuntimeException $exception) {
×
367
            $this->io->error(sprintf('Failed to ensure Lucide icons: %s', $exception->getMessage()));
×
368

UNCOV
369
            return null;
×
370
        }
371
    }
372

373
    private function findExistingLucideIcons(): ?string
374
    {
375
        // Check if Lucide icons already exist in the standard cache directory
UNCOV
376
        if (!is_dir($this->cacheRoot)) {
×
377
            return null;
×
378
        }
379

380
        // Look for icons directory in the standard location where BinaryAssetManager downloads
UNCOV
381
        $iconsDir = $this->cacheRoot . '/icons';
×
382

UNCOV
383
        if ($this->iconDirectoryLooksValid($iconsDir)) {
×
384
            return $iconsDir;
×
385
        }
386

UNCOV
387
        return null;
×
388
    }
389

390
    /**
391
     * @param array<int,string> $icons
392
     */
393
    private function generateForTarget(
394
        string $target,
395
        array $icons,
396
        string $localIconsDir,
397
        string $sharedIconsDir,
398
        ?string $lucideIconDir,
399
    ): int {
UNCOV
400
        $icons = array_map('strval', $icons);
×
401

402
        $sharedIdentifier = $this->sharedIdentifier;
×
403

404
        $icons = array_values($icons);
×
UNCOV
405
        $count = count($icons);
×
406

407
        $destination = ($target === $sharedIdentifier)
×
UNCOV
408
            ? $this->getInfrastructureDir() . '/templates/icons'
×
UNCOV
409
            : $this->getAppsDir() . '/' . $target . '/templates/icons';
×
410

411
        $this->ensureDirectory($destination);
×
412

413
        if (0 === $count) {
×
414
            $this->io->text(sprintf('[%s] No icons to generate, cleaning up any orphaned icons.', $target));
×
415
            // Clean up any orphaned icons even when no new icons are generated
UNCOV
416
            $this->cleanOrphanedIcons($destination, $icons);
×
417

UNCOV
418
            return 0;
×
419
        }
420

UNCOV
421
        $this->cleanExistingTwigIcons($destination);
×
422

UNCOV
423
        $generated = 0;
×
424

UNCOV
425
        foreach ($icons as $icon) {
×
UNCOV
426
            $source = $this->locateIconSource($icon, $localIconsDir, $sharedIconsDir, $lucideIconDir);
×
427

UNCOV
428
            if (null === $source) {
×
429
                $this->io->warning(sprintf('[%s] Icon "%s" not found in local, shared, or lucide sources.', $target, $icon));
×
430

431
                continue;
×
432
            }
433

434
            if ($this->writeTwigIcon($icon, $source, $destination)) {
×
435
                $generated++;
×
436
            }
437
        }
438

439
        // Clean up any orphaned icons after generation
440
        $this->cleanOrphanedIcons($destination, $icons);
×
441

UNCOV
442
        $this->io->success(sprintf('[%s] Generated %d icon%s.', $target, $generated, 1 === $generated ? '' : 's'));
×
443

UNCOV
444
        return $generated;
×
445
    }
446

447
    private function iconDirectoryLooksValid(
448
        string $path,
449
    ): bool {
450
        if (!is_dir($path)) {
×
UNCOV
451
            return false;
×
452
        }
453

UNCOV
454
        $files = glob($path . '/*.svg', GLOB_NOSORT);
×
455

456
        return false !== $files && [] !== $files;
×
457
    }
458

459
    private function locateIconSource(
460
        string $icon,
461
        string $localDir,
462
        string $sharedDir,
463
        ?string $lucideDir,
464
    ): ?string {
UNCOV
465
        $candidates = [
×
UNCOV
466
            $localDir . '/' . $icon . '.svg',
×
UNCOV
467
            $sharedDir . '/' . $icon . '.svg',
×
UNCOV
468
        ];
×
469

UNCOV
470
        if (null !== $lucideDir && is_dir($lucideDir)) {
×
471
            $candidates[] = rtrim($lucideDir, '/') . '/' . $icon . '.svg';
×
472
        }
473

474
        return array_find($candidates, static fn ($candidate) => is_file($candidate));
×
475
    }
476

477
    private function locateIconsDirectory(
478
        string $baseDir,
479
    ): ?string {
480
        // Since BinaryAssetManager downloads to var/lucide, just look for icons subdirectory
UNCOV
481
        $iconsDir = $baseDir . '/icons';
×
482

UNCOV
483
        if ($this->iconDirectoryLooksValid($iconsDir)) {
×
UNCOV
484
            return $iconsDir;
×
485
        }
486

487
        return null;
×
488
    }
489

490
    private function readJsonList(
491
        string $path,
492
    ): array {
493
        if (!is_file($path)) {
×
UNCOV
494
            $this->io->warning(sprintf('Icons manifest missing at %s', $path));
×
495

UNCOV
496
            return [];
×
497
        }
498

499
        $raw = file_get_contents($path);
×
500

UNCOV
501
        if (false === $raw || '' === $raw) {
×
502
            return [];
×
503
        }
504

505
        try {
UNCOV
506
            $data = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
×
507
        } catch (JsonException $exception) {
×
508
            $this->io->warning(sprintf('Invalid JSON in %s: %s', $path, $exception->getMessage()));
×
509

UNCOV
510
            return [];
×
511
        }
512

513
        return array_map('strval', $data);
×
514
    }
515

516
    private function writeTwigIcon(
517
        string $icon,
518
        string $sourcePath,
519
        string $destinationDir,
520
    ): bool {
UNCOV
521
        $svg = file_get_contents($sourcePath);
×
522

UNCOV
523
        if (false === $svg) {
×
UNCOV
524
            $this->io->warning('Unable to read icon source ' . $sourcePath);
×
525

UNCOV
526
            return false;
×
527
        }
528

529
        $document = new DOMDocument();
×
530
        $document->preserveWhiteSpace = false;
×
UNCOV
531
        $document->formatOutput = false;
×
532

UNCOV
533
        if (!@$document->loadXML($svg)) {
×
UNCOV
534
            $this->io->warning(sprintf('Invalid SVG for icon %s (%s)', $icon, $sourcePath));
×
535

536
            return false;
×
537
        }
538

539
        $svgElement = $document->getElementsByTagName('svg')->item(0);
×
540

UNCOV
541
        if (null === $svgElement) {
×
542
            $this->io->warning(sprintf('SVG element missing for icon %s (%s)', $icon, $sourcePath));
×
543

UNCOV
544
            return false;
×
545
        }
546

547
        $viewBox = $svgElement->getAttribute('viewBox') ?: '0 0 24 24';
×
548

UNCOV
549
        $inner = '';
×
550

UNCOV
551
        foreach ($svgElement->childNodes as $child) {
×
UNCOV
552
            $inner .= $document->saveXML($child);
×
553
        }
554

555
        if ('logo' === $icon) {
×
UNCOV
556
            $wrapped = sprintf(
×
557
                '{# twig-cs-fixer-disable #}<svg xmlns="http://www.w3.org/2000/svg" viewBox="%s" fill="currentColor">%s</svg>',
×
558
                $viewBox,
×
UNCOV
559
                $inner,
×
UNCOV
560
            );
×
561
        } else {
562
            $wrapped = sprintf(
×
563
                '{# twig-cs-fixer-disable #}<svg xmlns="http://www.w3.org/2000/svg" viewBox="%s" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">%s</svg>',
×
564
                $viewBox,
×
565
                $inner,
×
566
            );
×
567
        }
568

569
        $outputPath = $destinationDir . '/' . $icon . '.svg.twig';
×
570
        file_put_contents($outputPath, $wrapped);
×
571

572
        return true;
×
573
    }
574
}
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