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

valksor / php-dev-build / 19705532601

26 Nov 2025 01:33PM UTC coverage: 30.503% (+2.6%) from 27.943%
19705532601

push

github

k0d3r1s
generic binary provider

131 of 243 new or added lines in 7 files covered. (53.91%)

135 existing lines in 7 files now uncovered.

783 of 2567 relevant lines covered (30.5%)

1.15 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 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
        private readonly BinaryRegistry $binaryRegistry,
64
    ) {
65
        parent::__construct($parameterBag, $providerRegistry);
×
66
        $this->sharedIdentifier = $this->getInfrastructureDir();
×
67
    }
68

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

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

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

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

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

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

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

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

106
        $generated = 0;
×
107

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

208
        $handle = opendir($directory);
×
209

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

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

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

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

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

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

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

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

256
        $handle = opendir($appsDir);
×
257

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

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

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

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

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

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

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

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

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

294
        return $result;
×
295
    }
296

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

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

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

316
            return $targets;
×
317
        }
318

319
        $target = (string) $targetArgument;
×
320

321
        $sharedIdentifier = $this->sharedIdentifier;
×
322

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

326
            return $targets;
×
327
        }
328

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

332
            return [];
×
333
        }
334

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

338
        return $targets;
×
339
    }
340

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

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

352
            return $existingIconsDir;
×
353
        }
354

355
        // If no existing icons found, download lucide-static using generic provider
356
        try {
NEW
357
            $genericProvider = $this->binaryRegistry->getGenericNpmProvider();
×
358

NEW
359
            if (!$genericProvider) {
×
NEW
360
                $this->io->warning('Generic NPM provider not available.');
×
361

NEW
362
                return null;
×
363
            }
364

365
            // Create manager for lucide-static only
NEW
366
            $projectRoot = $this->resolveProjectRoot();
×
NEW
367
            $varDir = $projectRoot . '/var';
×
NEW
368
            $manager = $genericProvider->createManager($varDir, 'lucide-static');
×
369

NEW
370
            if (!$manager) {
×
NEW
371
                $this->io->warning('lucide-static not configured in generic provider.');
×
372

NEW
373
                return null;
×
374
            }
375

376
            // Download lucide-static only
NEW
377
            $manager->ensureLatest([$this->io, 'text']);
×
378

379
            // Look for the icons directory in the generic provider structure
UNCOV
380
            $iconsDir = $this->locateIconsDirectory($this->cacheRoot);
×
381

382
            if (null === $iconsDir) {
×
383
                throw new RuntimeException('Lucide icons directory could not be located after download.');
×
384
            }
385

386
            return $iconsDir;
×
387
        } catch (RuntimeException $exception) {
×
388
            $this->io->error(sprintf('Failed to ensure Lucide icons: %s', $exception->getMessage()));
×
389

390
            return null;
×
391
        }
392
    }
393

394
    private function findExistingLucideIcons(): ?string
395
    {
396
        // Check if Lucide icons already exist in the standard cache directory
397
        if (!is_dir($this->cacheRoot)) {
×
398
            return null;
×
399
        }
400

401
        // For lucide-static, icons are always in the icons subdirectory
NEW
402
        $iconsSubdir = $this->cacheRoot . '/icons';
×
403

NEW
404
        if ($this->iconDirectoryLooksValid($iconsSubdir)) {
×
NEW
405
            return $iconsSubdir;
×
406
        }
407

408
        return null;
×
409
    }
410

411
    /**
412
     * @param array<int,string> $icons
413
     */
414
    private function generateForTarget(
415
        string $target,
416
        array $icons,
417
        string $localIconsDir,
418
        string $sharedIconsDir,
419
        ?string $lucideIconDir,
420
    ): int {
421
        $icons = array_map('strval', $icons);
×
422

423
        $sharedIdentifier = $this->sharedIdentifier;
×
424

425
        $icons = array_values($icons);
×
426
        $count = count($icons);
×
427

428
        $destination = ($target === $sharedIdentifier)
×
429
            ? $this->getInfrastructureDir() . '/templates/icons'
×
430
            : $this->getAppsDir() . '/' . $target . '/templates/icons';
×
431

432
        $this->ensureDirectory($destination);
×
433

434
        if (0 === $count) {
×
435
            $this->io->text(sprintf('[%s] No icons to generate, cleaning up any orphaned icons.', $target));
×
436
            // Clean up any orphaned icons even when no new icons are generated
437
            $this->cleanOrphanedIcons($destination, $icons);
×
438

439
            return 0;
×
440
        }
441

442
        $this->cleanExistingTwigIcons($destination);
×
443

444
        $generated = 0;
×
445

446
        foreach ($icons as $icon) {
×
447
            $source = $this->locateIconSource($icon, $localIconsDir, $sharedIconsDir, $lucideIconDir);
×
448

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

452
                continue;
×
453
            }
454

455
            if ($this->writeTwigIcon($icon, $source, $destination)) {
×
456
                $generated++;
×
457
            }
458
        }
459

460
        // Clean up any orphaned icons after generation
461
        $this->cleanOrphanedIcons($destination, $icons);
×
462

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

465
        return $generated;
×
466
    }
467

468
    private function iconDirectoryLooksValid(
469
        string $path,
470
    ): bool {
471
        if (!is_dir($path)) {
×
472
            return false;
×
473
        }
474

475
        $files = glob($path . '/*.svg', GLOB_NOSORT);
×
476

477
        return false !== $files && [] !== $files;
×
478
    }
479

480
    private function locateIconSource(
481
        string $icon,
482
        string $localDir,
483
        string $sharedDir,
484
        ?string $lucideDir,
485
    ): ?string {
486
        $candidates = [
×
487
            $localDir . '/' . $icon . '.svg',
×
488
            $sharedDir . '/' . $icon . '.svg',
×
489
        ];
×
490

491
        if (null !== $lucideDir && is_dir($lucideDir)) {
×
492
            $candidates[] = rtrim($lucideDir, '/') . '/' . $icon . '.svg';
×
493
        }
494

495
        return array_find($candidates, static fn ($candidate) => is_file($candidate));
×
496
    }
497

498
    private function locateIconsDirectory(
499
        string $baseDir,
500
    ): ?string {
501
        // For lucide-static, icons are always in the icons subdirectory
NEW
502
        $iconsSubdir = $baseDir . '/icons';
×
503

NEW
504
        if ($this->iconDirectoryLooksValid($iconsSubdir)) {
×
NEW
505
            return $iconsSubdir;
×
506
        }
507

508
        return null;
×
509
    }
510

511
    private function readJsonList(
512
        string $path,
513
    ): array {
514
        if (!is_file($path)) {
×
515
            $this->io->warning(sprintf('Icons manifest missing at %s', $path));
×
516

517
            return [];
×
518
        }
519

520
        $raw = file_get_contents($path);
×
521

522
        if (false === $raw || '' === $raw) {
×
523
            return [];
×
524
        }
525

526
        try {
527
            $data = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
×
528
        } catch (JsonException $exception) {
×
529
            $this->io->warning(sprintf('Invalid JSON in %s: %s', $path, $exception->getMessage()));
×
530

531
            return [];
×
532
        }
533

534
        return array_map('strval', $data);
×
535
    }
536

537
    private function writeTwigIcon(
538
        string $icon,
539
        string $sourcePath,
540
        string $destinationDir,
541
    ): bool {
542
        $svg = file_get_contents($sourcePath);
×
543

544
        if (false === $svg) {
×
545
            $this->io->warning('Unable to read icon source ' . $sourcePath);
×
546

547
            return false;
×
548
        }
549

550
        $document = new DOMDocument();
×
551
        $document->preserveWhiteSpace = false;
×
552
        $document->formatOutput = false;
×
553

554
        if (!@$document->loadXML($svg)) {
×
555
            $this->io->warning(sprintf('Invalid SVG for icon %s (%s)', $icon, $sourcePath));
×
556

557
            return false;
×
558
        }
559

560
        $svgElement = $document->getElementsByTagName('svg')->item(0);
×
561

562
        if (null === $svgElement) {
×
563
            $this->io->warning(sprintf('SVG element missing for icon %s (%s)', $icon, $sourcePath));
×
564

565
            return false;
×
566
        }
567

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

570
        $inner = '';
×
571

572
        foreach ($svgElement->childNodes as $child) {
×
573
            $inner .= $document->saveXML($child);
×
574
        }
575

576
        if ('logo' === $icon) {
×
577
            $wrapped = sprintf(
×
578
                '{# twig-cs-fixer-disable #}<svg xmlns="http://www.w3.org/2000/svg" viewBox="%s" fill="currentColor">%s</svg>',
×
579
                $viewBox,
×
580
                $inner,
×
581
            );
×
582
        } else {
583
            $wrapped = sprintf(
×
584
                '{# 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>',
×
585
                $viewBox,
×
586
                $inner,
×
587
            );
×
588
        }
589

590
        $outputPath = $destinationDir . '/' . $icon . '.svg.twig';
×
591
        file_put_contents($outputPath, $wrapped);
×
592

593
        return true;
×
594
    }
595
}
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