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

valksor / php-dev-build / 19113736544

05 Nov 2025 11:28AM UTC coverage: 18.191% (+0.06%) from 18.133%
19113736544

push

github

k0d3r1s
code cleanup

5 of 27 new or added lines in 7 files covered. (18.52%)

1 existing line in 1 file now uncovered.

372 of 2045 relevant lines covered (18.19%)

0.97 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
                $sharedIcons,
×
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

NEW
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 them using BinaryAssetManager
356
        try {
357
            LucideBinary::createForLucide($this->cacheRoot)->ensureLatest([$this->io, 'text']);
×
358

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

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

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

370
            return null;
×
371
        }
372
    }
373

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

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

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

388
        return null;
×
389
    }
390

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

404
        $sharedIdentifier = $this->sharedIdentifier;
×
405

406
        if ($target === $sharedIdentifier) {
×
407
            $icons = array_map('strval', array_diff($icons, $sharedIcons));
×
408
        }
409

410
        $icons = array_values($icons);
×
411
        $count = count($icons);
×
412

413
        $destination = ($target === $sharedIdentifier)
×
414
            ? $this->getInfrastructureDir() . '/templates/icons'
×
415
            : $this->getAppsDir() . '/' . $target . '/templates/icons';
×
416

417
        $this->ensureDirectory($destination);
×
418

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

424
            return 0;
×
425
        }
426

427
        $this->cleanExistingTwigIcons($destination);
×
428

429
        $generated = 0;
×
430

431
        foreach ($icons as $icon) {
×
432
            $source = $this->locateIconSource($icon, $localIconsDir, $sharedIconsDir, $lucideIconDir);
×
433

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

437
                continue;
×
438
            }
439

440
            if ($this->writeTwigIcon($icon, $source, $destination)) {
×
441
                $generated++;
×
442
            }
443
        }
444

445
        // Clean up any orphaned icons after generation
446
        $this->cleanOrphanedIcons($destination, $icons);
×
447

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

450
        return $generated;
×
451
    }
452

453
    private function iconDirectoryLooksValid(
454
        string $path,
455
    ): bool {
456
        if (!is_dir($path)) {
×
457
            return false;
×
458
        }
459

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

462
        return false !== $files && [] !== $files;
×
463
    }
464

465
    private function locateIconSource(
466
        string $icon,
467
        string $localDir,
468
        string $sharedDir,
469
        ?string $lucideDir,
470
    ): ?string {
471
        $candidates = [
×
472
            $localDir . '/' . $icon . '.svg',
×
473
            $sharedDir . '/' . $icon . '.svg',
×
474
        ];
×
475

476
        if (null !== $lucideDir && is_dir($lucideDir)) {
×
477
            $candidates[] = rtrim($lucideDir, '/') . '/' . $icon . '.svg';
×
478
        }
479

480
        return array_find($candidates, static fn ($candidate) => is_file($candidate));
×
481
    }
482

483
    private function locateIconsDirectory(
484
        string $baseDir,
485
    ): ?string {
486
        // Since BinaryAssetManager downloads to var/lucide, just look for icons subdirectory
487
        $iconsDir = $baseDir . '/icons';
×
488

489
        if ($this->iconDirectoryLooksValid($iconsDir)) {
×
490
            return $iconsDir;
×
491
        }
492

493
        return null;
×
494
    }
495

496
    private function readJsonList(
497
        string $path,
498
    ): array {
499
        if (!is_file($path)) {
×
500
            $this->io->warning(sprintf('Icons manifest missing at %s', $path));
×
501

502
            return [];
×
503
        }
504

505
        $raw = file_get_contents($path);
×
506

507
        if (false === $raw || '' === $raw) {
×
508
            return [];
×
509
        }
510

511
        try {
512
            $data = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
×
513
        } catch (JsonException $exception) {
×
514
            $this->io->warning(sprintf('Invalid JSON in %s: %s', $path, $exception->getMessage()));
×
515

516
            return [];
×
517
        }
518

519
        return array_map('strval', $data);
×
520
    }
521

522
    private function writeTwigIcon(
523
        string $icon,
524
        string $sourcePath,
525
        string $destinationDir,
526
    ): bool {
527
        $svg = file_get_contents($sourcePath);
×
528

529
        if (false === $svg) {
×
530
            $this->io->warning('Unable to read icon source ' . $sourcePath);
×
531

532
            return false;
×
533
        }
534

535
        $document = new DOMDocument();
×
536
        $document->preserveWhiteSpace = false;
×
537
        $document->formatOutput = false;
×
538

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

542
            return false;
×
543
        }
544

545
        $svgElement = $document->getElementsByTagName('svg')->item(0);
×
546

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

550
            return false;
×
551
        }
552

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

555
        $inner = '';
×
556

557
        foreach ($svgElement->childNodes as $child) {
×
558
            $inner .= $document->saveXML($child);
×
559
        }
560

561
        if ('logo' === $icon) {
×
562
            $wrapped = sprintf(
×
563
                '{# twig-cs-fixer-disable #}<svg xmlns="http://www.w3.org/2000/svg" viewBox="%s" fill="currentColor">%s</svg>',
×
564
                $viewBox,
×
565
                $inner,
×
566
            );
×
567
        } else {
568
            $wrapped = sprintf(
×
569
                '{# 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>',
×
570
                $viewBox,
×
571
                $inner,
×
572
            );
×
573
        }
574

575
        $outputPath = $destinationDir . '/' . $icon . '.svg.twig';
×
576
        file_put_contents($outputPath, $wrapped);
×
577

578
        return true;
×
579
    }
580
}
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