• 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
/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\BinaryRegistry;
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 dirname;
34
use function file_get_contents;
35
use function file_put_contents;
36
use function glob;
37
use function implode;
38
use function in_array;
39
use function is_dir;
40
use function is_file;
41
use function json_decode;
42
use function opendir;
43
use function preg_match;
44
use function readdir;
45
use function rtrim;
46
use function sprintf;
47
use function str_contains;
48
use function str_ends_with;
49
use function str_starts_with;
50
use function substr;
51
use function trim;
52
use function unlink;
53

54
use const DIRECTORY_SEPARATOR;
55
use const GLOB_NOSORT;
56
use const JSON_THROW_ON_ERROR;
57

58
#[AsCommand(name: 'valksor:icons', description: 'Generate Twig SVG icons using Lucide icons only.')]
59
final class IconsGenerateCommand extends AbstractCommand
60
{
61
    private string $cacheRoot;
62
    private SymfonyStyle $io;
63
    private string $sharedIdentifier;
64

65
    public function __construct(
66
        ParameterBagInterface $parameterBag,
67
        ProviderRegistry $providerRegistry,
68
        private readonly BinaryRegistry $binaryRegistry,
69
    ) {
UNCOV
70
        parent::__construct($parameterBag, $providerRegistry);
×
UNCOV
71
        $this->sharedIdentifier = $this->getInfrastructureDir();
×
72
    }
73

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

90
        // Validate icon sources (FontAwesome and/or Lucide)
91
        try {
92
            $iconSources = $this->validateAndPrepareIconSources();
×
UNCOV
93
        } catch (RuntimeException $exception) {
×
94
            $this->io->error($exception->getMessage());
×
95

96
            return 1; // Return error code when no icon sources are available
×
97
        }
98

UNCOV
99
        $sharedIcons = $this->readJsonList($this->getInfrastructureDir() . '/assets/icons.json');
×
100
        $appIcons = $this->collectAppIcons($sharedIcons);
×
101

UNCOV
102
        $targets = $this->determineTargets($target, $sharedIcons, $appIcons);
×
103

104
        if ([] === $targets) {
×
UNCOV
105
            $this->io->warning('No icon targets found.');
×
106
            $this->cleanAllIconDirectories();
×
107

108
            return $this->handleCommandSuccess();
×
109
        }
110

111
        $generated = 0;
×
112

113
        /** @var array<string, array{viewBox: string, content: string, fill: string}> $sharedSpriteSymbols */
114
        $sharedSpriteSymbols = [];
×
115

UNCOV
116
        foreach ($targets as $targetId => $iconNames) {
×
UNCOV
117
            $result = $this->generateForTarget(
×
118
                $targetId,
×
119
                $iconNames,
×
UNCOV
120
                $iconSources,
×
121
                $sharedSpriteSymbols,
×
UNCOV
122
            );
×
UNCOV
123
            $generated += $result['generated'];
×
124

125
            // Store shared symbols for use in app sprites
UNCOV
126
            if ($targetId === $this->sharedIdentifier) {
×
UNCOV
127
                $sharedSpriteSymbols = $result['symbols'];
×
128
            }
129
        }
130

UNCOV
131
        if (0 === $generated) {
×
132
            $this->io->warning('No icons generated.');
×
133

UNCOV
134
            return $this->handleCommandSuccess();
×
135
        }
136

137
        return $this->handleCommandSuccess(sprintf('Generated %d icon file%s.', $generated, 1 === $generated ? '' : 's'), $this->io);
×
138
    }
139

140
    /**
141
     * Clean all known icon directories when no targets are found.
142
     */
143
    private function cleanAllIconDirectories(): void
144
    {
145
        $this->io->text('[CLEANUP] No icon targets found, cleaning all known icon directories...');
×
146

147
        // Clean shared icons directory
148
        $sharedIconsDir = $this->getInfrastructureDir() . '/templates/icons';
×
149

150
        if (is_dir($sharedIconsDir)) {
×
151
            $this->cleanExistingTwigIcons($sharedIconsDir);
×
152
            $this->io->text('[CLEANUP] Cleaned shared icons directory: ' . $sharedIconsDir);
×
153
        }
154

155
        // Clean app-specific icons directories
UNCOV
156
        $appsDir = $this->getAppsDir();
×
157

158
        if (is_dir($appsDir)) {
×
159
            $handle = opendir($appsDir);
×
160

UNCOV
161
            if (false !== $handle) {
×
162
                try {
163
                    while (($entry = readdir($handle)) !== false) {
×
UNCOV
164
                        if ('.' === $entry || '..' === $entry) {
×
UNCOV
165
                            continue;
×
166
                        }
167

UNCOV
168
                        $appIconsDir = $appsDir . '/' . $entry . '/templates/icons';
×
169

UNCOV
170
                        if (is_dir($appIconsDir)) {
×
UNCOV
171
                            $this->cleanExistingTwigIcons($appIconsDir);
×
172
                            $this->io->text(sprintf('[CLEANUP] Cleaned app icons directory: %s (%s)', $appIconsDir, $entry));
×
173
                        }
174
                    }
175
                } finally {
UNCOV
176
                    closedir($handle);
×
177
                }
178
            }
179
        }
180
    }
181

182
    private function cleanExistingTwigIcons(
183
        string $directory,
184
    ): void {
185
        $handle = opendir($directory);
×
186

UNCOV
187
        if (false === $handle) {
×
188
            return;
×
189
        }
190

191
        try {
UNCOV
192
            while (($entry = readdir($handle)) !== false) {
×
UNCOV
193
                if ('.' === $entry || '..' === $entry) {
×
UNCOV
194
                    continue;
×
195
                }
196

UNCOV
197
                if (!str_ends_with($entry, '.svg.twig')) {
×
UNCOV
198
                    continue;
×
199
                }
200

UNCOV
201
                @unlink($directory . DIRECTORY_SEPARATOR . $entry);
×
202
            }
203
        } finally {
204
            closedir($handle);
×
205
        }
206
    }
207

208
    /**
209
     * Clean up orphaned icons that are no longer in the current icon list.
210
     *
211
     * @param array<int,string> $currentIcons
212
     */
213
    private function cleanOrphanedIcons(
214
        string $directory,
215
        array $currentIcons,
216
    ): void {
217
        if (!is_dir($directory)) {
×
UNCOV
218
            return;
×
219
        }
220

221
        $handle = opendir($directory);
×
222

UNCOV
223
        if (false === $handle) {
×
UNCOV
224
            return;
×
225
        }
226

227
        try {
228
            while (($entry = readdir($handle)) !== false) {
×
229
                if ('.' === $entry || '..' === $entry) {
×
UNCOV
230
                    continue;
×
231
                }
232

UNCOV
233
                if (!str_ends_with($entry, '.svg.twig')) {
×
234
                    continue;
×
235
                }
236

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

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

UNCOV
244
                    if (@unlink($filePath)) {
×
UNCOV
245
                        $this->io->text(sprintf('[CLEANUP] Removed orphaned icon: %s', $iconName));
×
246
                    } else {
UNCOV
247
                        $this->io->warning(sprintf('[CLEANUP] Failed to remove orphaned icon: %s', $iconName));
×
248
                    }
249
                }
250
            }
251
        } finally {
252
            closedir($handle);
×
253
        }
254
    }
255

256
    /**
257
     * Clean sprite file for a target.
258
     */
259
    private function cleanSpriteFile(
260
        string $target,
261
    ): void {
UNCOV
262
        $spritePath = $this->getSpriteFilePath($target);
×
263

264
        if ('' === $spritePath) {
×
265
            return;
×
266
        }
267

268
        if (is_file($spritePath)) {
×
UNCOV
269
            @unlink($spritePath);
×
270
            $this->io->text(sprintf('[%s] Removed sprite file.', $target));
×
271
        }
272
    }
273

274
    /**
275
     * @return array<string,array<int,string>>
276
     */
277
    private function collectAppIcons(
278
        array $sharedIcons,
279
    ): array {
280
        $appsDir = $this->getAppsDir();
×
281
        $result = [];
×
282

283
        if (!is_dir($appsDir)) {
×
284
            return $result;
×
285
        }
286

UNCOV
287
        $handle = opendir($appsDir);
×
288

UNCOV
289
        if (false === $handle) {
×
UNCOV
290
            return $result;
×
291
        }
292

293
        try {
294
            while (($entry = readdir($handle)) !== false) {
×
UNCOV
295
                if ('.' === $entry || '..' === $entry) {
×
UNCOV
296
                    continue;
×
297
                }
298

UNCOV
299
                $iconsPath = $appsDir . '/' . $entry . '/assets/icons.json';
×
300

UNCOV
301
                if (!is_file($iconsPath)) {
×
UNCOV
302
                    continue;
×
303
                }
304

UNCOV
305
                $icons = $this->readJsonList($iconsPath);
×
306

307
                // Don't skip empty icons.json files - they need cleanup
308

309
                $duplicates = array_values(array_intersect($icons, $sharedIcons));
×
310

UNCOV
311
                if ([] !== $duplicates) {
×
312
                    $this->io->note(sprintf(
×
313
                        'App "%s" defines icons already provided by shared: %s. Shared icons will be used.',
×
UNCOV
314
                        $entry,
×
UNCOV
315
                        implode(', ', $duplicates),
×
316
                    ));
×
317
                }
318

319
                $result[$entry] = array_values(array_diff($icons, $sharedIcons));
×
320
            }
321
        } finally {
UNCOV
322
            closedir($handle);
×
323
        }
324

UNCOV
325
        return $result;
×
326
    }
327

328
    /**
329
     * @param array<string,array<int,string>> $appIcons
330
     *
331
     * @return array<string,array<int,string>>
332
     */
333
    private function determineTargets(
334
        $targetArgument,
335
        array $sharedIcons,
336
        array $appIcons,
337
    ): array {
338
        $targets = [];
×
339

UNCOV
340
        if (null === $targetArgument) {
×
UNCOV
341
            $targets[$this->sharedIdentifier] = $sharedIcons;
×
342

UNCOV
343
            foreach ($appIcons as $app => $icons) {
×
UNCOV
344
                $targets[$app] = $icons;
×
345
            }
346

347
            return $targets;
×
348
        }
349

350
        $target = (string) $targetArgument;
×
351

352
        $sharedIdentifier = $this->sharedIdentifier;
×
353

UNCOV
354
        if ($target === $sharedIdentifier) {
×
UNCOV
355
            $targets[$this->sharedIdentifier] = $sharedIcons;
×
356

357
            return $targets;
×
358
        }
359

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

UNCOV
363
            return [];
×
364
        }
365

366
        $targets[$this->sharedIdentifier] = $sharedIcons;
×
367
        $targets[$target] = $appIcons[$target];
×
368

UNCOV
369
        return $targets;
×
370
    }
371

372
    /**
373
     * @throws JsonException
374
     */
375
    private function ensureLucideIcons(): ?string
376
    {
377
        // First check if Lucide icons already exist locally
UNCOV
378
        $existingIconsDir = $this->findExistingLucideIcons();
×
379

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

383
            return $existingIconsDir;
×
384
        }
385

386
        // If no existing icons found, download lucide-static using generic provider
387
        try {
388
            $genericProvider = $this->binaryRegistry->getGenericNpmProvider();
×
389

390
            if (!$genericProvider) {
×
UNCOV
391
                $this->io->warning('Generic NPM provider not available.');
×
392

UNCOV
393
                return null;
×
394
            }
395

396
            // Create manager for lucide-static only
397
            $projectRoot = $this->resolveProjectRoot();
×
398
            $varDir = $projectRoot . '/var';
×
UNCOV
399
            $manager = $genericProvider->createManager($varDir, 'lucide-static');
×
400

UNCOV
401
            if (!$manager) {
×
402
                $this->io->warning('lucide-static not configured in generic provider.');
×
403

404
                return null;
×
405
            }
406

407
            // Download lucide-static only
408
            $manager->ensureLatest([$this->io, 'text']);
×
409

410
            // Look for the icons directory in the generic provider structure
UNCOV
411
            $iconsDir = $this->locateIconsDirectory($this->cacheRoot);
×
412

UNCOV
413
            if (null === $iconsDir) {
×
UNCOV
414
                throw new RuntimeException('Lucide icons directory could not be located after download.');
×
415
            }
416

UNCOV
417
            return $iconsDir;
×
UNCOV
418
        } catch (RuntimeException $exception) {
×
UNCOV
419
            $this->io->error(sprintf('Failed to ensure Lucide icons: %s', $exception->getMessage()));
×
420

421
            return null;
×
422
        }
423
    }
424

425
    /**
426
     * Extract symbol data from an SVG file for sprite generation.
427
     *
428
     * @return array{viewBox: string, content: string, fill: string}|null
429
     */
430
    private function extractSymbolData(
431
        string $icon,
432
        string $sourcePath,
433
    ): ?array {
434
        $svg = file_get_contents($sourcePath);
×
435

UNCOV
436
        if (false === $svg) {
×
437
            return null;
×
438
        }
439

440
        // Strip comments (FontAwesome license comments)
UNCOV
441
        $svg = (string) preg_replace('/<!--.*?-->/s', '', $svg);
×
442

UNCOV
443
        $document = new DOMDocument();
×
444
        $document->preserveWhiteSpace = false;
×
UNCOV
445
        $document->formatOutput = false;
×
446

447
        if (!@$document->loadXML($svg)) {
×
UNCOV
448
            return null;
×
449
        }
450

UNCOV
451
        $svgElement = $document->getElementsByTagName('svg')->item(0);
×
452

UNCOV
453
        if (null === $svgElement) {
×
UNCOV
454
            return null;
×
455
        }
456

UNCOV
457
        $viewBox = $svgElement->getAttribute('viewBox') ?: '0 0 24 24';
×
458

459
        // Determine fill type based on icon type
UNCOV
460
        $parsed = $this->parseIconName($icon);
×
461
        $fill = 'currentColor';
×
462

463
        if (null === $parsed) {
×
464
            // Lucide icons use stroke, not fill
465
            $fill = 'none';
×
466
        }
467

UNCOV
468
        $inner = '';
×
469

UNCOV
470
        foreach ($svgElement->childNodes as $child) {
×
471
            $inner .= $document->saveXML($child);
×
472
        }
473

UNCOV
474
        return [
×
475
            'viewBox' => $viewBox,
×
UNCOV
476
            'content' => trim($inner),
×
477
            'fill' => $fill,
×
UNCOV
478
        ];
×
479
    }
480

481
    private function findExistingLucideIcons(): ?string
482
    {
483
        // Check if Lucide icons already exist in the standard cache directory
UNCOV
484
        if (!is_dir($this->cacheRoot)) {
×
UNCOV
485
            return null;
×
486
        }
487

488
        // For lucide-static, icons are always in the icons subdirectory
489
        $iconsSubdir = $this->cacheRoot . '/icons';
×
490

491
        if ($this->iconDirectoryLooksValid($iconsSubdir)) {
×
492
            return $iconsSubdir;
×
493
        }
494

495
        return null;
×
496
    }
497

498
    /**
499
     * @param array<int,string>                                                    $icons
500
     * @param array<string, string|null>                                           $iconSources   ['fontawesome' => ?string, 'lucide' => ?string]
501
     * @param array<string, array{viewBox: string, content: string, fill: string}> $sharedSymbols Shared symbols to include in app sprites
502
     *
503
     * @return array{generated: int, symbols: array<string, array{viewBox: string, content: string, fill: string}>}
504
     */
505
    private function generateForTarget(
506
        string $target,
507
        array $icons,
508
        array $iconSources,
509
        array $sharedSymbols = [],
510
    ): array {
UNCOV
511
        $icons = array_map('strval', $icons);
×
512

UNCOV
513
        $sharedIdentifier = $this->sharedIdentifier;
×
514
        $isSharedTarget = $target === $sharedIdentifier;
×
515

UNCOV
516
        $icons = array_values($icons);
×
517
        $count = count($icons);
×
518

UNCOV
519
        $destination = $isSharedTarget
×
520
            ? $this->getInfrastructureDir() . '/templates/icons'
×
UNCOV
521
            : $this->getAppsDir() . '/' . $target . '/templates/icons';
×
522

523
        $this->ensureDirectory($destination);
×
524

UNCOV
525
        if (0 === $count) {
×
UNCOV
526
            $this->io->text(sprintf('[%s] No icons to generate, cleaning up any orphaned icons.', $target));
×
527
            // Clean up any orphaned icons even when no new icons are generated
528
            $this->cleanOrphanedIcons($destination, $icons);
×
529
            // Clean sprite file if it exists
UNCOV
530
            $this->cleanSpriteFile($target);
×
531

UNCOV
532
            return ['generated' => 0, 'symbols' => []];
×
533
        }
534

UNCOV
535
        $this->cleanExistingTwigIcons($destination);
×
536

UNCOV
537
        $generated = 0;
×
538

539
        /** @var array<string, array{viewBox: string, content: string, fill: string}> $spriteSymbols */
UNCOV
540
        $spriteSymbols = [];
×
541

542
        foreach ($icons as $icon) {
×
UNCOV
543
            $source = $this->locateIconSource($icon, $iconSources);
×
544

545
            if (null === $source) {
×
UNCOV
546
                $this->io->warning(sprintf('[%s] Icon "%s" not found in available icon sources.', $target, $icon));
×
547

UNCOV
548
                continue;
×
549
            }
550

551
            if ($this->writeTwigIcon($icon, $source, $destination)) {
×
552
                $generated++;
×
553
            }
554

555
            // Extract symbol data for sprite
UNCOV
556
            $symbolData = $this->extractSymbolData($icon, $source);
×
557

UNCOV
558
            if (null !== $symbolData) {
×
UNCOV
559
                $spriteSymbols[$icon] = $symbolData;
×
560
            }
561
        }
562

563
        // Generate sprite file only for app targets (shared symbols are merged into app sprites)
UNCOV
564
        if (!$isSharedTarget && ([] !== $spriteSymbols || [] !== $sharedSymbols)) {
×
565
            $allSymbols = [...$sharedSymbols, ...$spriteSymbols];
×
UNCOV
566
            $this->generateSpriteFile($target, $allSymbols);
×
567
        }
568

569
        // Clean up any orphaned icons after generation
570
        $this->cleanOrphanedIcons($destination, $icons);
×
571

572
        $symbolCount = $isSharedTarget ? count($spriteSymbols) : count($spriteSymbols) + count($sharedSymbols);
×
573
        $spriteInfo = $isSharedTarget ? '' : sprintf(' + sprite (%d symbols)', $symbolCount);
×
UNCOV
574
        $this->io->success(sprintf('[%s] Generated %d icon%s%s.', $target, $generated, 1 === $generated ? '' : 's', $spriteInfo));
×
575

576
        return ['generated' => $generated, 'symbols' => $spriteSymbols];
×
577
    }
578

579
    /**
580
     * Generate SVG sprite file containing all icons as symbols.
581
     *
582
     * @param array<string, array{viewBox: string, content: string, fill: string}> $symbols
583
     */
584
    private function generateSpriteFile(
585
        string $target,
586
        array $symbols,
587
    ): void {
UNCOV
588
        $spritePath = $this->getSpriteFilePath($target);
×
UNCOV
589
        $this->ensureDirectory(dirname($spritePath));
×
590

591
        $symbolsContent = '';
×
592

593
        foreach ($symbols as $iconName => $data) {
×
UNCOV
594
            $symbolsContent .= sprintf(
×
UNCOV
595
                '<symbol id="%s" viewBox="%s" fill="%s">%s</symbol>',
×
UNCOV
596
                $iconName,
×
UNCOV
597
                $data['viewBox'],
×
UNCOV
598
                $data['fill'],
×
UNCOV
599
                $data['content'],
×
UNCOV
600
            );
×
601
        }
602

UNCOV
603
        $sprite = sprintf(
×
UNCOV
604
            '<svg xmlns="http://www.w3.org/2000/svg" style="display:none">%s</svg>',
×
UNCOV
605
            $symbolsContent,
×
UNCOV
606
        );
×
607

UNCOV
608
        file_put_contents($spritePath, $sprite);
×
UNCOV
609
        $this->io->text(sprintf('[%s] Generated sprite with %d symbols.', $target, count($symbols)));
×
610
    }
611

612
    /**
613
     * Get FontAwesome path from BuildConfiguration.
614
     */
615
    private function getFontAwesomePath(): ?string
616
    {
UNCOV
617
        $servicesConfig = $this->parameterBag->get('valksor.build.services');
×
UNCOV
618
        $iconsConfig = $servicesConfig['icons']['options'] ?? [];
×
UNCOV
619
        $fontawesomePath = $iconsConfig['fontawesome_path'] ?? null;
×
620

UNCOV
621
        if (empty($fontawesomePath)) {
×
UNCOV
622
            return null;
×
623
        }
624

625
        // If path is relative, resolve it from project root using existing pattern
UNCOV
626
        if (!str_starts_with($fontawesomePath, '/')) {
×
UNCOV
627
            return $this->resolveProjectRoot() . '/' . $fontawesomePath;
×
628
        }
629

UNCOV
630
        return $fontawesomePath;
×
631
    }
632

633
    /**
634
     * Get the sprite file path for a target.
635
     */
636
    private function getSpriteFilePath(
637
        string $target,
638
    ): string {
639
        // Shared target doesn't get its own sprite - symbols are merged into app sprites
UNCOV
640
        if ($target === $this->sharedIdentifier) {
×
UNCOV
641
            return '';
×
642
        }
643

UNCOV
644
        return $this->getAppsDir() . '/' . $target . '/assets/icons/sprite.svg';
×
645
    }
646

647
    private function iconDirectoryLooksValid(
648
        string $path,
649
    ): bool {
UNCOV
650
        if (!is_dir($path)) {
×
UNCOV
651
            return false;
×
652
        }
653

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

UNCOV
656
        return false !== $files && [] !== $files;
×
657
    }
658

659
    /**
660
     * Locate FontAwesome icon files using configured path.
661
     */
662
    private function locateFontAwesomeIcon(
663
        array $parsed,
664
    ): ?string {
UNCOV
665
        $fontAwesomePath = $this->getFontAwesomePath();
×
666

UNCOV
667
        if (empty($fontAwesomePath)) {
×
UNCOV
668
            return null;
×
669
        }
670

UNCOV
671
        $styleDir = $fontAwesomePath . '/' . $parsed['style'];
×
672

UNCOV
673
        if (!is_dir($styleDir)) {
×
UNCOV
674
            return null;
×
675
        }
676

UNCOV
677
        $iconPath = rtrim($styleDir, '/') . '/' . $parsed['name'] . '.svg';
×
678

UNCOV
679
        return is_file($iconPath) ? $iconPath : null;
×
680
    }
681

682
    /**
683
     * @param array<string, string|null> $iconSources ['fontawesome' => ?string, 'lucide' => ?string]
684
     */
685
    private function locateIconSource(
686
        string $icon,
687
        array $iconSources,
688
    ): ?string {
689
        // Check if this is a FontAwesome icon
UNCOV
690
        $parsed = $this->parseIconName($icon);
×
691

UNCOV
692
        if (null !== $parsed && 'fontawesome' === $parsed['type']) {
×
693
            // Only look for FontAwesome icons if FontAwesome is available
UNCOV
694
            if ($iconSources['fontawesome']) {
×
UNCOV
695
                return $this->locateFontAwesomeIcon($parsed);
×
696
            }
697

UNCOV
698
            return null;
×
699
        }
700

701
        // Default Lucide processing
UNCOV
702
        $lucideDir = $iconSources['lucide'];
×
703

UNCOV
704
        if (null === $lucideDir || !is_dir($lucideDir)) {
×
UNCOV
705
            return null;
×
706
        }
707

UNCOV
708
        $iconPath = rtrim($lucideDir, '/') . '/' . $icon . '.svg';
×
709

UNCOV
710
        return is_file($iconPath) ? $iconPath : null;
×
711
    }
712

713
    private function locateIconsDirectory(
714
        string $baseDir,
715
    ): ?string {
716
        // For lucide-static, icons are always in the icons subdirectory
UNCOV
717
        $iconsSubdir = $baseDir . '/icons';
×
718

UNCOV
719
        if ($this->iconDirectoryLooksValid($iconsSubdir)) {
×
UNCOV
720
            return $iconsSubdir;
×
721
        }
722

UNCOV
723
        return null;
×
724
    }
725

726
    /**
727
     * Parse FontAwesome icon name format: fa-<type>-<icon>.
728
     */
729
    private function parseIconName(
730
        string $icon,
731
    ): ?array {
UNCOV
732
        if (!str_starts_with($icon, 'fa-')) {
×
UNCOV
733
            return null;
×
734
        }
735

736
        // Pattern: fa-<type>-<icon>
UNCOV
737
        if (!preg_match('/^fa-([a-z]+)-(.+)$/', $icon, $matches)) {
×
UNCOV
738
            return null;
×
739
        }
740

UNCOV
741
        $type = $matches[1]; // solid, regular, light, duotone, brands
×
UNCOV
742
        $iconName = $matches[2]; // the actual icon name
×
743

UNCOV
744
        $validTypes = ['solid', 'regular', 'light', 'duotone', 'brands'];
×
745

UNCOV
746
        if (!in_array($type, $validTypes, true)) {
×
UNCOV
747
            return null;
×
748
        }
749

UNCOV
750
        return [
×
UNCOV
751
            'type' => 'fontawesome',
×
UNCOV
752
            'style' => $type,
×
UNCOV
753
            'name' => $iconName,
×
UNCOV
754
        ];
×
755
    }
756

757
    private function readJsonList(
758
        string $path,
759
    ): array {
UNCOV
760
        if (!is_file($path)) {
×
UNCOV
761
            $this->io->warning(sprintf('Icons manifest missing at %s', $path));
×
762

UNCOV
763
            return [];
×
764
        }
765

UNCOV
766
        $raw = file_get_contents($path);
×
767

UNCOV
768
        if (false === $raw || '' === $raw) {
×
UNCOV
769
            return [];
×
770
        }
771

772
        try {
UNCOV
773
            $data = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
×
UNCOV
774
        } catch (JsonException $exception) {
×
UNCOV
775
            $this->io->warning(sprintf('Invalid JSON in %s: %s', $path, $exception->getMessage()));
×
776

UNCOV
777
            return [];
×
778
        }
779

UNCOV
780
        return array_map('strval', $data);
×
781
    }
782

783
    /**
784
     * Scan all requested icons to determine which icon types are needed.
785
     *
786
     * @return array<string, bool> ['fontawesome' => bool, 'lucide' => bool]
787
     */
788
    private function scanRequestedIconTypes(): array
789
    {
UNCOV
790
        $needsFontAwesome = false;
×
UNCOV
791
        $needsLucide = false;
×
792

793
        // Check shared icons
UNCOV
794
        $sharedIcons = $this->readJsonList($this->getInfrastructureDir() . '/assets/icons.json');
×
795

UNCOV
796
        foreach ($sharedIcons as $icon) {
×
UNCOV
797
            $parsed = $this->parseIconName($icon);
×
798

UNCOV
799
            if ($parsed && 'fontawesome' === $parsed['type']) {
×
UNCOV
800
                $needsFontAwesome = true;
×
801
            } else {
UNCOV
802
                $needsLucide = true;
×
803
            }
804
        }
805

806
        // Check app-specific icons
UNCOV
807
        $appsDir = $this->getAppsDir();
×
808

UNCOV
809
        if (is_dir($appsDir)) {
×
UNCOV
810
            $handle = opendir($appsDir);
×
811

UNCOV
812
            if (false !== $handle) {
×
813
                try {
UNCOV
814
                    while (($entry = readdir($handle)) !== false) {
×
UNCOV
815
                        if ('.' === $entry || '..' === $entry) {
×
UNCOV
816
                            continue;
×
817
                        }
818

UNCOV
819
                        $iconsPath = $appsDir . '/' . $entry . '/assets/icons.json';
×
820

UNCOV
821
                        if (!is_file($iconsPath)) {
×
UNCOV
822
                            continue;
×
823
                        }
824

UNCOV
825
                        $appIcons = $this->readJsonList($iconsPath);
×
826

UNCOV
827
                        foreach ($appIcons as $icon) {
×
UNCOV
828
                            $parsed = $this->parseIconName($icon);
×
829

UNCOV
830
                            if ($parsed && 'fontawesome' === $parsed['type']) {
×
UNCOV
831
                                $needsFontAwesome = true;
×
832
                            } else {
UNCOV
833
                                $needsLucide = true;
×
834
                            }
835
                        }
836
                    }
837
                } finally {
UNCOV
838
                    closedir($handle);
×
839
                }
840
            }
841
        }
842

UNCOV
843
        return [
×
UNCOV
844
            'fontawesome' => $needsFontAwesome,
×
UNCOV
845
            'lucide' => $needsLucide,
×
UNCOV
846
        ];
×
847
    }
848

849
    /**
850
     * Validate and prepare icon sources from both FontAwesome and Lucide.
851
     * Returns array with available sources or throws exception when neither is available.
852
     *
853
     * @return array<string, string|null> ['fontawesome' => ?string, 'lucide' => ?string]
854
     *
855
     * @throws RuntimeException When neither icon source is available
856
     */
857
    private function validateAndPrepareIconSources(): array
858
    {
UNCOV
859
        $sources = [
×
UNCOV
860
            'fontawesome' => null,
×
UNCOV
861
            'lucide' => null,
×
UNCOV
862
        ];
×
863

864
        // First, scan what icon types are actually needed
UNCOV
865
        $neededTypes = $this->scanRequestedIconTypes();
×
866

UNCOV
867
        $this->io->text(sprintf(
×
UNCOV
868
            'Icon types needed: FontAwesome=%s, Lucide=%s',
×
UNCOV
869
            $neededTypes['fontawesome'] ? 'yes' : 'no',
×
UNCOV
870
            $neededTypes['lucide'] ? 'yes' : 'no',
×
UNCOV
871
        ));
×
872

873
        // Only validate FontAwesome if it's needed
UNCOV
874
        if ($neededTypes['fontawesome']) {
×
UNCOV
875
            $sources['fontawesome'] = $this->validateFontAwesomeAvailability();
×
876

UNCOV
877
            if ($sources['fontawesome']) {
×
UNCOV
878
                $this->io->success(sprintf('✅ Using FontAwesome icons from: %s', $sources['fontawesome']));
×
879
            } else {
UNCOV
880
                $this->io->warning('⚠️  FontAwesome icons needed but not available');
×
881
            }
882
        }
883

884
        // Only validate Lucide if it's needed
UNCOV
885
        if ($neededTypes['lucide']) {
×
UNCOV
886
            $lucideDir = $this->findExistingLucideIcons();
×
887

UNCOV
888
            if (null === $lucideDir) {
×
889
                // Try to download Lucide if not available locally
UNCOV
890
                $lucideDir = $this->ensureLucideIcons();
×
891
            }
892

UNCOV
893
            if ($lucideDir) {
×
UNCOV
894
                $sources['lucide'] = $lucideDir;
×
UNCOV
895
                $this->io->success(sprintf('✅ Using Lucide icons from: %s', $lucideDir));
×
896
            } else {
UNCOV
897
                $this->io->warning('⚠️  Lucide icons needed but not available');
×
898
            }
899
        }
900

901
        // Validate that all needed icon sources are available
UNCOV
902
        $errors = [];
×
903

UNCOV
904
        if ($neededTypes['fontawesome'] && null === $sources['fontawesome']) {
×
UNCOV
905
            $errors[] = 'FontAwesome icons are requested but not available';
×
906
        }
907

UNCOV
908
        if ($neededTypes['lucide'] && null === $sources['lucide']) {
×
UNCOV
909
            $errors[] = 'Lucide icons are requested but not available';
×
910
        }
911

UNCOV
912
        if (!empty($errors)) {
×
UNCOV
913
            throw new RuntimeException('❌ ERROR: ' . implode('; ', $errors));
×
914
        }
915

916
        // Provide summary when both sources are available
UNCOV
917
        if ($sources['fontawesome'] && $sources['lucide']) {
×
UNCOV
918
            $this->io->success('✅ Using both FontAwesome and Lucide icons');
×
919
        }
920

UNCOV
921
        return $sources;
×
922
    }
923

924
    /**
925
     * Validate FontAwesome configuration and directory structure.
926
     */
927
    private function validateFontAwesomeAvailability(): ?string
928
    {
UNCOV
929
        $servicesConfig = $this->parameterBag->get('valksor.build.services');
×
UNCOV
930
        $iconsConfig = $servicesConfig['icons']['options'] ?? [];
×
931

932
        // Check if FontAwesome is enabled
UNCOV
933
        $fontawesomeEnabled = $iconsConfig['fontawesome_enabled'] ?? true;
×
934

UNCOV
935
        if (!$fontawesomeEnabled) {
×
UNCOV
936
            $this->io->text('FontAwesome is disabled in configuration.');
×
937

UNCOV
938
            return null;
×
939
        }
940

UNCOV
941
        $fontawesomePath = $iconsConfig['fontawesome_path'] ?? null;
×
942

UNCOV
943
        if (empty($fontawesomePath)) {
×
UNCOV
944
            return null;
×
945
        }
946

947
        // If path is relative, resolve it from project root using existing pattern
UNCOV
948
        if (!str_starts_with($fontawesomePath, '/')) {
×
UNCOV
949
            $fontawesomePath = $this->resolveProjectRoot() . '/' . $fontawesomePath;
×
950
        }
951

952
        // Validate directory exists
UNCOV
953
        if (!is_dir($fontawesomePath)) {
×
UNCOV
954
            $this->io->warning(sprintf('FontAwesome configured but path is invalid: %s', $fontawesomePath));
×
955

UNCOV
956
            return null;
×
957
        }
958

959
        // Check for FontAwesome directory structure (at least one style subdirectory)
UNCOV
960
        $validStyles = ['solid', 'regular', 'light', 'duotone', 'brands'];
×
UNCOV
961
        $hasValidStructure = false;
×
962

UNCOV
963
        foreach ($validStyles as $style) {
×
UNCOV
964
            $styleDir = $fontawesomePath . '/' . $style;
×
965

UNCOV
966
            if (is_dir($styleDir)) {
×
UNCOV
967
                $hasValidStructure = true;
×
968

UNCOV
969
                break;
×
970
            }
971
        }
972

UNCOV
973
        if (!$hasValidStructure) {
×
UNCOV
974
            $this->io->warning(sprintf('FontAwesome directory exists but has invalid structure. Expected subdirectories: %s', implode(', ', $validStyles)));
×
975

UNCOV
976
            return null;
×
977
        }
978

UNCOV
979
        return $fontawesomePath;
×
980
    }
981

982
    /**
983
     * Write duotone icon with special CSS class handling.
984
     */
985
    private function writeDuotoneTwigIcon(
986
        string $icon,
987
        array $parsed,
988
        string $svg,
989
        string $destinationDir,
990
    ): bool {
991
        // Extract viewBox using regex
UNCOV
992
        if (preg_match('/viewBox="([^"]+)"/', $svg, $matches)) {
×
UNCOV
993
            $viewBox = $matches[1];
×
994
        } else {
UNCOV
995
            $viewBox = '0 0 24 24';
×
996
        }
997

998
        // Extract content between SVG tags (skip opening and closing tags)
UNCOV
999
        if (preg_match('/<svg[^>]*>(.*)<\/svg>/s', $svg, $matches)) {
×
UNCOV
1000
            $inner = $matches[1];
×
1001
        } else {
UNCOV
1002
            $inner = '';
×
1003
        }
1004

1005
        // Ensure CSS styles are present for duotone
UNCOV
1006
        if (!str_contains($inner, '.fa-secondary{opacity:.4}')) {
×
UNCOV
1007
            $inner = '<defs><style>.fa-secondary{opacity:.4}</style></defs>' . $inner;
×
1008
        }
1009

UNCOV
1010
        $wrapped = sprintf(
×
UNCOV
1011
            '{# twig-cs-fixer-disable #}<svg xmlns="http://www.w3.org/2000/svg" viewBox="%s" fill="currentColor">%s</svg>',
×
UNCOV
1012
            $viewBox,
×
UNCOV
1013
            $inner,
×
UNCOV
1014
        );
×
1015

UNCOV
1016
        $outputPath = $destinationDir . '/' . $icon . '.svg.twig';
×
UNCOV
1017
        file_put_contents($outputPath, $wrapped);
×
1018

UNCOV
1019
        return true;
×
1020
    }
1021

1022
    /**
1023
     * Write FontAwesome icon with proper processing.
1024
     */
1025
    private function writeFontAwesomeTwigIcon(
1026
        string $icon,
1027
        array $parsed,
1028
        string $sourcePath,
1029
        string $destinationDir,
1030
    ): bool {
UNCOV
1031
        $svg = file_get_contents($sourcePath);
×
1032

UNCOV
1033
        if (false === $svg) {
×
UNCOV
1034
            $this->io->warning('Unable to read FontAwesome icon source ' . $sourcePath);
×
1035

UNCOV
1036
            return false;
×
1037
        }
1038

1039
        // Special handling for duotone icons
UNCOV
1040
        if ('duotone' === $parsed['style']) {
×
UNCOV
1041
            return $this->writeDuotoneTwigIcon($icon, $parsed, $svg, $destinationDir);
×
1042
        }
1043

UNCOV
1044
        $document = new DOMDocument();
×
UNCOV
1045
        $document->preserveWhiteSpace = false;
×
UNCOV
1046
        $document->formatOutput = false;
×
1047

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

UNCOV
1051
            return false;
×
1052
        }
1053

UNCOV
1054
        $svgElement = $document->getElementsByTagName('svg')->item(0);
×
1055

UNCOV
1056
        if (null === $svgElement) {
×
UNCOV
1057
            $this->io->warning(sprintf('SVG element missing for FontAwesome icon %s (%s)', $icon, $sourcePath));
×
1058

UNCOV
1059
            return false;
×
1060
        }
1061

UNCOV
1062
        $viewBox = $svgElement->getAttribute('viewBox') ?: '0 0 24 24';
×
1063

UNCOV
1064
        $inner = '';
×
1065

UNCOV
1066
        foreach ($svgElement->childNodes as $child) {
×
UNCOV
1067
            $inner .= $document->saveXML($child);
×
1068
        }
1069

1070
        // FontAwesome icons use fill="currentColor" instead of stroke
UNCOV
1071
        $wrapped = sprintf(
×
UNCOV
1072
            '{# twig-cs-fixer-disable #}<svg xmlns="http://www.w3.org/2000/svg" viewBox="%s" fill="currentColor"%s>%s</svg>',
×
UNCOV
1073
            $viewBox,
×
UNCOV
1074
            'brands' === $parsed['style'] ? '' : ' stroke="currentColor" stroke-width="2"',
×
UNCOV
1075
            $inner,
×
UNCOV
1076
        );
×
1077

UNCOV
1078
        $outputPath = $destinationDir . '/' . $icon . '.svg.twig';
×
UNCOV
1079
        file_put_contents($outputPath, $wrapped);
×
1080

UNCOV
1081
        return true;
×
1082
    }
1083

1084
    /**
1085
     * Write Lucide icon (original logic).
1086
     */
1087
    private function writeLucideTwigIcon(
1088
        string $icon,
1089
        string $sourcePath,
1090
        string $destinationDir,
1091
    ): bool {
UNCOV
1092
        $svg = file_get_contents($sourcePath);
×
1093

UNCOV
1094
        if (false === $svg) {
×
UNCOV
1095
            $this->io->warning('Unable to read Lucide icon source ' . $sourcePath);
×
1096

UNCOV
1097
            return false;
×
1098
        }
1099

UNCOV
1100
        $document = new DOMDocument();
×
UNCOV
1101
        $document->preserveWhiteSpace = false;
×
UNCOV
1102
        $document->formatOutput = false;
×
1103

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

UNCOV
1107
            return false;
×
1108
        }
1109

UNCOV
1110
        $svgElement = $document->getElementsByTagName('svg')->item(0);
×
1111

UNCOV
1112
        if (null === $svgElement) {
×
UNCOV
1113
            $this->io->warning(sprintf('SVG element missing for Lucide icon %s (%s)', $icon, $sourcePath));
×
1114

UNCOV
1115
            return false;
×
1116
        }
1117

UNCOV
1118
        $viewBox = $svgElement->getAttribute('viewBox') ?: '0 0 24 24';
×
1119

UNCOV
1120
        $inner = '';
×
1121

UNCOV
1122
        foreach ($svgElement->childNodes as $child) {
×
UNCOV
1123
            $inner .= $document->saveXML($child);
×
1124
        }
1125

UNCOV
1126
        $wrapped = sprintf(
×
UNCOV
1127
            '{# 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>',
×
UNCOV
1128
            $viewBox,
×
UNCOV
1129
            $inner,
×
UNCOV
1130
        );
×
1131

UNCOV
1132
        $outputPath = $destinationDir . '/' . $icon . '.svg.twig';
×
UNCOV
1133
        file_put_contents($outputPath, $wrapped);
×
1134

UNCOV
1135
        return true;
×
1136
    }
1137

1138
    private function writeTwigIcon(
1139
        string $icon,
1140
        string $sourcePath,
1141
        string $destinationDir,
1142
    ): bool {
1143
        // Check if this is a FontAwesome icon
UNCOV
1144
        $parsed = $this->parseIconName($icon);
×
1145

UNCOV
1146
        if (null !== $parsed && 'fontawesome' === $parsed['type']) {
×
UNCOV
1147
            return $this->writeFontAwesomeTwigIcon($icon, $parsed, $sourcePath, $destinationDir);
×
1148
        }
1149

1150
        // Default Lucide processing
UNCOV
1151
        return $this->writeLucideTwigIcon($icon, $sourcePath, $destinationDir);
×
1152
    }
1153
}
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